add login/logout

This commit is contained in:
birdhead 2025-10-21 15:17:21 +00:00
parent 8e7e06c4f1
commit 868dde7d94
66 changed files with 7655 additions and 7 deletions

10
.env.example Executable file
View File

@ -0,0 +1,10 @@
// =======================================================================
// .env (프로젝트 최상위 폴더에 생성하세요)
// 중요: 이 파일은 Git에 포함시키지 마세요.
// 실제 데이터베이스 접속 정보로 채워주세요.
// =======================================================================
DB_HOST=ssdoctors.com
DB_PORT=15433
DB_USER=spacs
DB_PASSWORD=scaps
DB_DATABASE=polyp

0
.gitignore vendored Normal file → Executable file
View File

1
.npmrc Executable file
View File

@ -0,0 +1 @@
engine-strict=true

9
.prettierignore Executable file
View File

@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/

16
.prettierrc Executable file
View File

@ -0,0 +1,16 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
],
"tailwindStylesheet": "./src/app.css"
}

13
.vscode/launch.json vendored Executable file
View File

@ -0,0 +1,13 @@
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"name": "Vite: Launch Chrome",
"request": "launch", // 'attach' 'launch'
"type": "pwa-chrome",
"url": "http://localhost:14022", //
"webRoot": "${workspaceFolder}"
}
]
}

6
.vscode/settings.json vendored Executable file
View File

@ -0,0 +1,6 @@
{
"files.associations": {
"string": "cpp",
"cstdlib": "cpp"
}
}

14
.vscode/tasks.json vendored Executable file
View File

@ -0,0 +1,14 @@
// .vscode/tasks.json
{
"version": "2.0.0",
"tasks": [
{
"label": "npm: dev",
"type": "shell",
"command": "npx vite dev --host", // 'npm run dev' vite
"isBackground": true,
"problemMatcher": "$vite",
"detail": "vite dev --host"
}
]
}

197
package-lock.json generated
View File

@ -11,7 +11,15 @@
"@ffmpeg/core": "^0.12.10", "@ffmpeg/core": "^0.12.10",
"@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2", "@ffmpeg/util": "^0.12.2",
<<<<<<< HEAD
"canvas": "^3.2.0", "canvas": "^3.2.0",
=======
"bcrypt": "^6.0.0",
"canvas": "^3.2.0",
"jsonwebtoken": "^9.0.2",
"mime": "^4.1.0",
"mime-types": "^3.0.1",
>>>>>>> 0bab142 (add login/logout)
"pg": "^8.16.3" "pg": "^8.16.3"
}, },
"devDependencies": { "devDependencies": {
@ -2439,6 +2447,32 @@
], ],
"license": "MIT" "license": "MIT"
}, },
<<<<<<< HEAD
=======
"node_modules/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/bcrypt/node_modules/node-addon-api": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
"integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
>>>>>>> 0bab142 (add login/logout)
"node_modules/bl": { "node_modules/bl": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
@ -2498,6 +2532,15 @@
"ieee754": "^1.1.13" "ieee754": "^1.1.13"
} }
}, },
<<<<<<< HEAD
=======
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
>>>>>>> 0bab142 (add login/logout)
"node_modules/cac": { "node_modules/cac": {
"version": "6.7.14", "version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@ -2786,6 +2829,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
<<<<<<< HEAD
=======
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
>>>>>>> 0bab142 (add login/logout)
"node_modules/end-of-stream": { "node_modules/end-of-stream": {
"version": "1.4.5", "version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@ -3558,6 +3613,49 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
"license": "MIT",
"dependencies": {
"jws": "^3.2.2",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jwa": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
"license": "MIT",
"dependencies": {
"jwa": "^1.4.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@ -3871,6 +3969,45 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
<<<<<<< HEAD
=======
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
>>>>>>> 0bab142 (add login/logout)
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -3878,6 +4015,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/loupe": { "node_modules/loupe": {
"version": "3.2.1", "version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
@ -3942,6 +4085,45 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
<<<<<<< HEAD
=======
"node_modules/mime": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz",
"integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==",
"funding": [
"https://github.com/sponsors/broofa"
],
"license": "MIT",
"bin": {
"mime": "bin/cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">= 0.6"
}
},
>>>>>>> 0bab142 (add login/logout)
"node_modules/mimic-response": { "node_modules/mimic-response": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
@ -4039,7 +4221,6 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
@ -4092,6 +4273,20 @@
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT" "license": "MIT"
}, },
<<<<<<< HEAD
=======
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
>>>>>>> 0bab142 (add login/logout)
"node_modules/once": { "node_modules/once": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",

View File

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

View File

@ -1,4 +1,5 @@
// src/hooks.server.ts // src/hooks.server.ts
<<<<<<< HEAD
import type { Handle } from '@sveltejs/kit'; import type { Handle } from '@sveltejs/kit';
@ -9,4 +10,32 @@ export const handle: Handle = async ({ event, resolve }) => {
response.headers.set('Cross-Origin-Opener-Policy', 'same-origin'); response.headers.set('Cross-Origin-Opener-Policy', 'same-origin');
response.headers.set('Cross-Origin-Embedder-Policy', 'require-corp'); response.headers.set('Cross-Origin-Embedder-Policy', 'require-corp');
return response; return response;
}; };
=======
import type { Handle } from '@sveltejs/kit';
import jwt from 'jsonwebtoken';
const SECRET_KEY = 'my_secret_key'; // 실제 운영 시 .env 파일에 보관
export const handle: Handle = async ({ event, resolve }) => {
const token = event.cookies.get('session');
if (token) {
try {
const user = jwt.verify(token, SECRET_KEY);
event.locals.user = user;
} catch (err) {
event.locals.user = null;
}
} else {
event.locals.user = null;
}
const response = await resolve(event);
// ffmpeg.wasm 관련 헤더 유지
response.headers.set('Cross-Origin-Opener-Policy', 'same-origin');
response.headers.set('Cross-Origin-Embedder-Policy', 'require-corp');
return response;
};
>>>>>>> 0bab142 (add login/logout)

View File

@ -0,0 +1,24 @@
// src/lib/server/processes.ts
import type { ChildProcess } from 'child_process';
export interface ProcessInfo {
id: string;
startTime: number;
status: 'running' | 'completed' | 'error' | 'cancelled';
progress: number;
message?: string;
}
export const activeProcesses = new Map<string, ChildProcess>();
export const processInfo = new Map<string, ProcessInfo>();
// cleanup interval (10분 주기)
setInterval(() => {
const now = Date.now();
for (const [id, info] of processInfo.entries()) {
if (info.status !== 'running' && now - info.startTime > 10 * 60 * 1000) {
console.log(`🧹 Removing expired process info: ${id}`);
processInfo.delete(id);
}
}
}, 10 * 60 * 1000);

4
src/lib/stores/user.ts Normal file
View File

@ -0,0 +1,4 @@
// src/lib/stores/user.ts
import { writable } from 'svelte/store';
export const user = writable<{ WorkerID: number; WorkerName: string; login_id: string } | null>(null);

11
src/lib/utils.ts Normal file
View File

@ -0,0 +1,11 @@
// src/lib/utils.ts
/**
*
* (e.g., "video/my_file.mp4" -> "my_file.mp4")
*/
export const path = {
pop: (p: string) => p.split(/[/\\]/).pop() || p
};
// 여기에 다른 공통 유틸리티 함수들을 추가할 수 있습니다.

View File

@ -0,0 +1,19 @@
// src/routes/(protected)/(admin)/+layout.server.ts
import { error } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ parent }) => {
// 부모 레이아웃(protected)에서 'user' 데이터를 가져옵니다.
// 이 시점에 'user'는 로그인 상태이며 'role'도 포함되어 있습니다.
const { user } = await parent();
// (admin) 그룹에 속한 모든 페이지(video-crop 등)에 대해 이 검사를 실행합니다.
// 만약 사용자의 role이 'admin'이 아니라면,
if (user.role !== 'admin') {
// 403 Forbidden (금지됨) 에러를 발생시켜 페이지 접근을 막습니다.
throw error(403, 'Forbidden: 이 페이지에 접근할 권한이 없습니다.');
}
// 어드민이 맞다면, 페이지 로드를 허용합니다.
return { user };
};

View File

@ -0,0 +1,677 @@
<!-- src/routes/image-generate/+page.svelte -->
<script lang="ts">
// [추가] onMount를 import
import { onMount } from 'svelte';
// --- 상태 변수 선언 ---
let videoURL: string = '';
let videoElement: HTMLVideoElement;
let originalFileName: string = '';
let message: string = '동영상 파일을 선택하세요.';
// --- 시간 변수 (편집기용) ---
let startTime: number = 0;
let endTime: number = 0;
let startTimeStr: string = '00:00:00.000';
let endTimeStr: string = '00:00:00.000';
let stopAtTime: number | null = null; // 미리보기 자동 정지를 위한 변수
// --- [수정] 작업 진행 상태 변수 ---
let isCuttingSegments: boolean = false;
let isUploadingSegments: boolean = false;
let isLoadingList: boolean = false; // [추가] DB 목록 로딩 상태
let eventSource: EventSource | null = null; // 구간 자르기(Segments) 용
// --- [추가] Crop 동영상 목록 ---
interface CroppedVideo {
Index: number;
LocationFile: string;
// (DB 스키마에 따라 다른 필드도 추가 가능)
}
let croppedVideos: CroppedVideo[] = [];
let selectedVideoIndex: number | null = null;
let selectedVideo: CroppedVideo | null = null;
// --- [수정] TimeSegment 인터페이스 ---
interface TimeSegment {
id: number;
start: number;
end: number;
resultURL?: string;
isUploading?: boolean;
isUploaded?: boolean;
isCutting?: boolean;
progress?: number;
newIndex?: number;
stage?: 'cut' | 'frames' | 'db_save' | 'done'; // [추가] 현재 작업 세부 단계
progressStage2?: number; // [추가] 2단계: 프레임 추출 진행률 (0-100)
}
let segments: TimeSegment[] = [];
let nextSegmentId = 0;
// --- 시간 포맷 변환 헬퍼 함수 (유지) ---
const formatTime = (timeInSeconds: number): string => {
if (isNaN(timeInSeconds) || timeInSeconds < 0) return '00:00:00.000';
const pad = (num: number, size: number) => num.toString().padStart(size, '0');
const hours = Math.floor(timeInSeconds / 3600);
const minutes = Math.floor((timeInSeconds % 3600) / 60);
const seconds = Math.floor(timeInSeconds % 60);
const milliseconds = Math.round((timeInSeconds - Math.floor(timeInSeconds)) * 1000);
return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)}.${pad(milliseconds, 3)}`;
};
const parseTime = (timeString: string): number => {
const regex = /^(\d{2}):(\d{2}):(\d{2})\.(\d{3})$/;
const match = timeString.match(regex);
if (!match) return 0;
const [, hours, minutes, seconds, milliseconds] = match.map(Number);
return hours * 3600 + minutes * 60 + seconds + milliseconds / 1000;
};
// --- [수정] 반응형 변수 및 로직 ---
$: allProcessed = segments.length > 0 && segments.every((s) => s.resultURL);
$: isLoading = isLoadingList || isCuttingSegments || isUploadingSegments; // [수정]
$: segmentsReadyToUpload = segments.filter((s) => s.resultURL && !s.isUploaded).length > 0;
$: startTime = parseTime(startTimeStr);
$: endTime = parseTime(endTimeStr);
// --- [삭제] uploadOriginalVideo, handleExecuteCrop, handleCancelCrop, handleFileSelect 함수 ---
// 이 함수들은 모두 "1단계: video-crop" 페이지로 이동했으므로 삭제합니다.
// --- [추가] DB에서 MovieType=1 목록을 불러오는 함수 ---
async function loadCroppedVideos() {
isLoadingList = true;
message = 'Crop 동영상 목록을 불러오는 중...';
try {
// 이 API는 /api/get-cropped-videos/+server.ts를 호출합니다.
const response = await fetch('/api/get-cropped-videos');
if (!response.ok) throw new Error('목록 로드 실패');
// DB 스키마의 대소문자(Index, LocationFile)를 정확히 맞춰야 합니다.
croppedVideos = (await response.json()) as CroppedVideo[];
message = '편집할 동영상을 선택하세요.';
} catch (error) {
message = 'Crop 동영상 목록을 불러오는 데 실패했습니다.';
console.error(error);
} finally {
isLoadingList = false;
}
}
// [추가] 페이지 로드 시 목록을 불러옵니다.
onMount(() => {
loadCroppedVideos();
});
// [추가] 드롭다운에서 동영상을 선택했을 때 호출되는 함수
async function handleVideoSelect() {
if (selectedVideoIndex === null) {
videoURL = '';
originalFileName = '';
segments = [];
return;
}
selectedVideo = croppedVideos.find((v) => v.Index === selectedVideoIndex) || null;
if (selectedVideo) {
const fileName = selectedVideo.LocationFile.split(path.sep).pop() || 'video.mp4';
originalFileName = fileName;
// ✅ Range 요청 대응 스트리밍 URL 생성
// 캐시 방지용으로 timestamp 추가 (브라우저가 같은 Range 요청을 재사용하지 않게)
videoURL = `/api/video-stream/${selectedVideo.Index}?t=${Date.now()}`;
segments = [];
message = `${fileName}이(가) 선택되었습니다. 편집할 구간을 추가하세요.`;
}
}
// --- [수정] cutAllSegments 함수 ---
const cutAllSegments = async () => {
if (segments.length === 0) {
message = '먼저 구간을 추가해주세요.';
return;
}
if (!selectedVideo) {
message = '동영상이 선택되지 않았습니다.';
return;
}
isCuttingSegments = true;
segments = segments.map((s) => ({
...s,
isCutting: true,
progress: 0,
progressStage2: 0,
stage: 'cut', // 'cut' 단계부터 시작
resultURL: undefined,
newIndex: undefined
}));
message = '서버에서 동영상 잘라내기 작업을 시작합니다...';
const segmentsData = JSON.stringify(segments.map((s) => ({ id: s.id, start: s.start, end: s.end })));
// [수정]
// 1. crop 파라미터를 제거합니다. (이미 Crop된 영상을 편집하기 때문)
// 2. selectedVideoIndex (TumerMovie의 Index)를 API로 넘겨줍니다.
// (서버가 어떤 파일을 잘라야 하는지 알아야 함)
const url = `/api/cut-segments-progress?segments=${encodeURIComponent(
segmentsData
)}&videoIndex=${selectedVideo.Index}`; // <-- videoIndex 파라미터 추가
eventSource = new EventSource(url); // ... (이하 eventSource 로직은 동일) ...
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
// [수정] 'progress' 메시지 (1단계 또는 2단계)
if (data.type === 'progress') {
segments = segments.map((s) => {
if (s.id !== data.segmentId) return s;
if (data.stage === 'cut') {
message = `[${data.segmentId}번] 1/2. 동영상 자르는 중... ${data.progress.toFixed(0)}%`;
return { ...s, progress: data.progress, stage: 'cut' };
}
if (data.stage === 'frames') {
message = `[${data.segmentId}번] 2/2. 프레임 추출 중... ${data.progress.toFixed(0)}%`;
// 1단계가 100%가 아니더라도 2단계가 시작되면 1단계를 100%로 채워줌
return { ...s, progress: 100, progressStage2: data.progress, stage: 'frames' };
}
return s;
});
}
// [추가] 2단계 또는 3단계(DB저장) 시작 신호
if (data.type === 'progress_stage_start') {
segments = segments.map((s) => {
if (s.id !== data.segmentId) return s;
if (data.stage === 'frames') {
message = `[${data.segmentId}번] 2/2. 프레임 추출 시작... (총 ${data.totalFrames}개)`;
return { ...s, stage: 'frames', progress: 100, progressStage2: 0 }; // 1단계(cut) 100% 완료
}
if (data.stage === 'db_save') {
message = `[${data.segmentId}번] 2/2. 프레임 DB 저장 중...`;
// 2단계(frames) 100% 완료, DB 저장 단계(시각적으로는 2단계 100% 유지)
return { ...s, stage: 'db_save', progressStage2: 100 };
}
return s;
});
}
// [수정] 'done' 메시지 (모든 단계 완료)
if (data.type === 'done') {
segments = segments.map((s) =>
s.id === data.segmentId
? {
...s,
isCutting: false, // 작업 완료
progress: 100,
progressStage2: 100,
stage: 'done', // 완료 상태
resultURL: data.url,
newIndex: data.newIndex
}
: s
);
}
if (data.type === 'all_complete') {
message = '✅ 모든 구간 작업 완료!';
isCuttingSegments = false;
eventSource?.close();
eventSource = null;
}
if (data.type === 'error') {
console.error('Cutting Error:', data.error);
message = `오류 발생: ${data.error}`;
isCuttingSegments = false;
// [수정] 오류 시 isCutting 플래그 해제
segments = segments.map((s) => ({ ...s, isCutting: false, stage: undefined }));
eventSource?.close();
eventSource = null;
}
};
eventSource.onerror = (error) => {
console.error('EventSource failed:', error);
message = '오류: 서버와 연결이 끊어졌습니다.';
isCuttingSegments = false;
segments = segments.map((s) => ({ ...s, isCutting: false, stage: undefined }));
if (eventSource) {
eventSource.close();
eventSource = null;
}
};
};
// --- uploadSegment, uploadAllSegments 함수 (유지) ---
// ... (uploadSegment, uploadAllSegments 함수 코드는 원본과 동일) ...
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);
// [수정] /api/upload-cut API에도 원본 비디오의 Index를 넘겨줍니다. (DB 저장용)
formData.append('originalVideoIndex', selectedVideoIndex?.toString() || '');
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 헬퍼 함수들 (유지) ---
// ... (setStartTime, setEndTime, addSegment, removeSegment, downloadAllSegments, previewSegment, handleTimeUpdate 함수 코드는 원본과 동일) ...
const setStartTime = () => {
if (videoElement) {
startTimeStr = formatTime(videoElement.currentTime);
}
};
const setEndTime = () => {
if (videoElement) {
endTimeStr = formatTime(videoElement.currentTime);
}
};
const addSegment = () => {
if (startTime >= endTime) {
alert('시작 시간은 종료 시간보다 빨라야 합니다.');
return;
}
segments = [
...segments,
{
id: nextSegmentId++,
start: startTime,
end: endTime,
isUploaded: false,
isUploading: false,
isCutting: false,
progress: 0
}
];
};
const removeSegment = async (id: number) => {
const segment = segments.find((s) => s.id === id);
if (!segment) return;
// Case 1: 아직 서버에 생성되지 않은 경우 (newIndex 없음)
// -> UI에서만 즉시 제거
if (!segment.newIndex) {
segments = segments.filter((s) => s.id !== id);
return;
}
// Case 2: 서버에 생성된 경우 (newIndex 있음)
// -> 사용자 확인 후 API 호출
const fileName = segment.resultURL?.split('/').pop() || '해당 동영상';
if (
!confirm(
`[서버 영구 삭제] "${fileName}" (Index: ${segment.newIndex})을(를) 서버에서 영구적으로 삭제하시겠습니까?\n\n이 작업은 되돌릴 수 없으며, 연관된 모든 프레임 이미지와 DB 데이터가 함께 삭제됩니다.`
)
) {
return; // 사용자가 '취소' 클릭
}
// 로딩 상태 UI (선택 사항)
message = `[Index: ${segment.newIndex}] 삭제 요청 중...`;
// segments = segments.map(s => s.id === id ? { ...s, isCutting: true, progress: 50 } : s); // 삭제 중임을 시각적으로 표시
try {
// [중요] 새로운 삭제 API 호출
const response = await fetch(`/api/delete-cut-video?index=${segment.newIndex}`, {
method: 'DELETE'
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.message || '서버 삭제에 실패했습니다.');
}
// API 호출 성공 시 UI에서 제거
segments = segments.filter((s) => s.id !== id);
message = `[Index: ${segment.newIndex}] 성공적으로 삭제되었습니다.`;
} catch (error: any) {
console.error('Delete failed:', error);
message = `[삭제 오류] ${error.message}`;
// segments = segments.map(s => s.id === id ? { ...s, isCutting: false } : s); // 로딩 상태 해제
}
};
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();
}
});
};
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;
}
};
// [추가] 경로 파싱을 위한 'path' 모듈 (브라우저용)
// 'path-browserify'를 'npm install path-browserify'로 설치해야 할 수 있습니다.
// 간단하게 string split으로 대체합니다.
const path = {
sep: '/',
pop: (p: string) => p.split(path.sep).pop()
};
const getStageMessage = (segment: TimeSegment): string => {
if (!segment.isCutting && segment.stage !== 'done') return '';
switch(segment.stage) {
case 'cut':
return `1/2. 동영상 자르기...`;
case 'frames':
return `2/2. 프레임 추출 중...`;
case 'db_save':
return `2/2. DB 저장 중...`;
case 'done':
return `완료!`; // isCutting=false일 때 표시됨
default:
return `대기 중...`;
}
};
</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">
🎬 2단계: 동영상 구간 편집
</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">
<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>
{#if videoURL}
{#key videoURL}
<video
bind:this={videoElement}
class="w-full rounded-lg bg-gray-100"
controls
autoplay
on:timeupdate={handleTimeUpdate}
src={videoURL}
>
브라우저가 비디오 태그를 지원하지 않습니다.
</video>
{/key}
{:else}
<div class="aspect-video w-full flex items-center justify-center">
<p class="text-gray-500">동영상을 선택하세요.</p>
</div>
{/if}
</section>
</div>
<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>
<select
bind:value={selectedVideoIndex}
on:change={handleVideoSelect}
disabled={isLoading}
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 disabled:opacity-50"
>
{#if isLoadingList}
<option value={null} disabled>목록 로딩 중...</option>
{:else if croppedVideos.length === 0}
<option value={null} disabled>Crop된 동영상이 없습니다.</option>
{:else}
<option value={null} disabled selected>-- 동영상 선택 --</option>
{#each croppedVideos as video (video.Index)}
<option value={video.Index}>
{path.pop(video.LocationFile)} (ID: {video.Index})
</option>
{/each}
{/if}
</select>
</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-2 mb-4">
<div>
<label for="startTime" class="block text-sm font-medium text-gray-700"
>시작 시간 (HH:MM:SS.ms)</label
>
<input
type="text"
id="startTime"
bind:value={startTimeStr}
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 font-mono"
placeholder="00:00:00.000"
/>
</div>
<div>
<label for="endTime" class="block text-sm font-medium text-gray-700"
>종료 시간 (HH:MM:SS.ms)</label
>
<input
type="text"
id="endTime"
bind:value={endTimeStr}
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 font-mono"
placeholder="00:00:00.000"
/>
</div>
</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 transition-colors"
class:bg-green-50={segment.isUploaded}
class:bg-blue-50={segment.isUploading}
class:bg-indigo-50={segment.isCutting}
>
<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="클릭하여 구간 미리보기"
>
{formatTime(segment.start)} ~ {formatTime(segment.end)}
</p>
{#if segment.isCutting}
<div class="mt-2 space-y-2">
<p class="text-xs font-medium text-indigo-700">
{getStageMessage(segment)}
</p>
<div>
<progress
class="w-full h-2 rounded-full overflow-hidden"
max="100"
value={segment.progress || 0}
></progress>
<p class="text-xs text-right font-mono text-gray-600">
{(segment.progress || 0).toFixed(0)}%
</p>
</div>
{#if (segment.progress || 0) >= 100}
<div class="mt-1">
<progress
class="w-full h-2 rounded-full overflow-hidden"
max="100"
value={segment.progressStage2 || 0}
></progress>
<p class="text-xs text-right font-mono text-gray-600">
{(segment.progressStage2 || 0).toFixed(0)}%
</p>
</div>
{/if}
</div>
{:else if segment.resultURL}
<div class="text-sm mt-1 flex items-center gap-4">
<a
href={segment.resultURL}
download={`${
originalFileName.split('.').slice(0, -1).join('.')
}_cut-${segment.id}.mp4`}
class="text-indigo-600 hover:underline">다운로드</a
>
{#if segment.isUploading}
<span class="text-gray-500 animate-pulse">업로드 중...</span>
{:else if segment.isUploaded}
<span class="font-medium text-green-600">✓ 업로드 완료</span>
{:else}
<button
on:click={() => uploadSegment(segment.id)}
class="text-blue-600 hover:underline"
disabled={isLoading}>업로드</button
>
{/if}
</div>
{/if}
</div>
<button
on:click={() => removeSegment(segment.id)}
class="text-red-500 hover:text-red-700 font-bold px-2 ml-2 self-start"
>X</button
>
</div>
{:else}
<p class="text-gray-500 text-center py-4">추가된 구간이 없습니다.</p>
{/each}
</div>
<div class="mt-auto grid grid-cols-1 md:grid-cols-3 gap-2">
<button
on:click={cutAllSegments}
disabled={isLoading || segments.length === 0}
class="w-full rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 disabled:opacity-50"
>
✂️ 전체 잘라내기
</button>
<button
on:click={uploadAllSegments}
disabled={isLoading || !segmentsReadyToUpload}
class="w-full rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 disabled:opacity-50"
>
📤 전체 업로드
</button>
<button
on:click={downloadAllSegments}
disabled={isLoading || !allProcessed}
class="w-full rounded-md border border-transparent bg-green-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-green-700 disabled:opacity-50"
>
📦 전체 다운로드
</button>
</div>
</section>
</div>
{/if}
</div>
</main>

View File

@ -0,0 +1,18 @@
// src/routes/(protected)/register/+page.server.ts
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ parent }) => {
// 부모 레이아웃(src/routes/(protected)/+layout.server.ts)의 데이터를 기다립니다.
// 이 시점에는 'user'가 무조건 존재합니다. (없었다면 이미 /login으로 리다이렉트됨)
const { user } = await parent();
// 'birdhead' 계정이 아닌 경우
if (user.login_id !== 'birdhead') {
// 403 Forbidden (금지됨) 에러를 발생시켜 페이지 접근을 막습니다.
throw error(403, 'Forbidden: 이 페이지에 접근할 권한이 없습니다.');
}
// 'birdhead' 계정인 경우, 페이지 로드를 허용합니다.
return {};
};

View File

@ -0,0 +1,81 @@
<!-- src/routes/register/+page.svelte -->
<script lang="ts">
import { goto } from '$app/navigation';
let workerName = '';
let login_id = '';
let password = '';
let message = '';
async function handleRegister(e: Event) {
e.preventDefault();
message = '';
try {
const res = await fetch('/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workerName, login_id, password })
});
const data = await res.json();
if (!res.ok) {
message = `❌ ${data.error ?? '등록 실패'}`;
return;
}
message = '✅ 등록 성공! 자동 로그인 중...';
setTimeout(() => goto('/'), 800); // 0.8초 뒤 홈으로 이동
} catch (err) {
console.error(err);
message = '❌ 서버 오류';
}
}
</script>
<div class="flex flex-col items-center mt-20">
<h1 class="text-3xl font-bold mb-6">작업자 등록</h1>
<form on:submit={handleRegister} class="w-80 space-y-4">
<input
type="text"
bind:value={workerName}
placeholder="이름"
class="w-full border p-2 rounded"
required
/>
<input
type="text"
bind:value={login_id}
placeholder="로그인 아이디"
class="w-full border p-2 rounded"
required
/>
<input
type="password"
bind:value={password}
placeholder="비밀번호"
class="w-full border p-2 rounded"
required
/>
<button
type="submit"
class="w-full bg-purple-600 text-white p-2 rounded hover:bg-purple-700 transition"
>
등록하기
</button>
</form>
{#if message}
<p class="mt-4 text-center text-sm">{message}</p>
{/if}
</div>
<style>
input {
outline: none;
}
</style>

View File

@ -0,0 +1,48 @@
// src/routes/register/+server.ts
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import pool from '$lib/server/database';
import { JWT_SECRET } from '$env/static/private';
export const POST: RequestHandler = async ({ request, cookies }) => {
try {
const { workerName, login_id, password } = await request.json();
if (!workerName || !login_id || !password)
return json({ error: '모든 항목을 입력하세요.' }, { status: 400 });
if (login_id.toLowerCase().includes('admin'))
return json({ error: '관리자 전용 아이디는 등록할 수 없습니다.' }, { status: 403 });
//if (password.length < 8 || !/[0-9]/.test(password) || !/[A-Za-z]/.test(password))
// return json({ error: '비밀번호는 8자 이상이며 숫자와 문자를 포함해야 합니다.' }, { status: 400 });
const hashedPassword = await bcrypt.hash(password, 10);
const result = await pool.query(
`INSERT INTO "Worker" (WorkerName, login_id, password_hash)
VALUES ($1, $2, $3)
RETURNING WorkerID`,
[workerName, login_id, hashedPassword]
);
const worker = { id: result.rows[0].workerid, login_id, name: workerName };
const token = jwt.sign(worker, JWT_SECRET, { expiresIn: '7d' });
cookies.set('session', token, {
path: '/',
httpOnly: true,
secure: false,
maxAge: 60 * 60 * 24 * 7
});
return json({ success: true, worker });
} catch (err: any) {
console.error('회원 등록 실패:', err);
if (err.code === '23505')
return json({ error: '이미 사용 중인 아이디입니다.' }, { status: 400 });
return json({ error: '서버 오류' }, { status: 500 });
}
};

View File

@ -0,0 +1,515 @@
<script lang="ts">
// ... (이전과 동일한 <script> ) ...
import { onMount } from 'svelte';
// --- 1. 상태 변수 선언 ---
// UI 상태
let videoURL: string = '';
let videoElement: HTMLVideoElement;
let originalFileName: string = '';
let message: string = '동영상 파일을 선택하세요.';
// 작업 진행 상태
let isUploadingOriginal: boolean = false; // 원본 임시 업로드
let uploadProgress: number = 0;
let isDetectingCrop: boolean = false; // 자동 Crop 감지
let isSavingCrop: boolean = false; // "Crop 저장하기" (최종 저장)
let cropProgress: number = 0;
let currentProcessId: string | null = null; // 취소용 ID
let cropEventSource: EventSource | null = null; // Crop 저장용 SSE
// Crop 정보
let cropX: number = 0;
let cropY: number = 0;
let cropWidth: number = 0;
let cropHeight: number = 0;
// Crop 리스트 상태
// TumerMovie 스키마를 기반으로 한 인터페이스 (가정)
interface CroppedVideo {
Index: number; // DB의 Primary Key
movie_file: string; // 파일명 (예: my_video_crop.mp4)
movie_path: string; // 파일 경로 (예: /workspace/image/video/crop)
created_at: string; // 생성일
movie_type: number; // 1 (Crop본), 2 (원본), 0 (bigdata 편집본)
original_path: string; // 원본 파일 경로 (예: /workspace/image/video/original)
// ...TumorMovie의 다른 필드들...
}
let croppedVideos: CroppedVideo[] = [];
let isLoadingList: boolean = false;
// --- 2. 반응형 변수 ---
$: isLoading = isUploadingOriginal || isDetectingCrop || isSavingCrop || isLoadingList;
$: detectedCropParams = `${cropWidth}:${cropHeight}:${cropX}:${cropY}`;
// --- 3. 핵심 기능 함수 ---
/**
* [패널 3] Crop된 동영상 리스트(MovieType: 1)를 DB에서 불러옵니다.
*/
const loadCroppedVideos = async () => {
isLoadingList = true;
try {
// 이 API는 MovieType=1인 동영상 목록을 반환해야 합니다.
const response = await fetch('/api/get-cropped-videos');
if (!response.ok) throw new Error('데이터를 불러오는 데 실패했습니다.');
const data = (await response.json()) as CroppedVideo[];
// 최신순으로 정렬 (id 또는 created_at 기준)
croppedVideos = data.sort((a, b) => b.id - a.id);
} catch (error) {
console.error('Error loading cropped videos:', error);
message = 'Crop 리스트를 불러오는 중 오류가 발생했습니다.';
} finally {
isLoadingList = false;
}
};
/**
* [패널 1] 파일을 선택하면 서버에 임시 업로드하고 자동 Crop을 감지합니다.
*/
const handleFileSelect = async (e: Event) => {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
// 이전 상태 초기화
resetForm();
videoURL = URL.createObjectURL(file);
originalFileName = file.name;
try {
// 1. 원본을 서버 임시 폴더에 업로드
await uploadOriginalVideo(file);
// 2. 임시 업로드된 파일을 기반으로 Crop 영역 감지
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 영역 계산 완료! "Crop 저장하기"를 눌러주세요.';
} else {
message = '자동 Crop 영역을 찾지 못했습니다. 수동으로 영역을 지정하세요.';
}
} catch (error) {
console.error('처리 중 오류:', error);
message = '오류가 발생했습니다. 콘솔을 확인해주세요.';
} finally {
isDetectingCrop = false;
}
}
};
/**
* (Helper) handleFileSelect에 사용되며, 임시 업로드를 수행합니다.
*/
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 = () => {
isUploadingOriginal = false;
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
message = '원본 파일 업로드 중 오류가 발생했습니다.';
reject(new Error(xhr.statusText));
}
};
xhr.onerror = () => {
isUploadingOriginal = false;
message = '원본 파일 업로드 중 네트워크 오류가 발생했습니다.';
reject(new Error('네트워크 오류'));
};
// 이 API는 파일을 '임시' 위치에 저장해야 합니다.
xhr.open('POST', '/api/upload-original', true);
xhr.send(formData);
});
};
/**
* [패널 1] "Crop 저장하기" 버튼 클릭 시 실행됩니다.
* 원본(Type:2) 저장 및 Crop본(Type:1) 저장을 서버에 요청합니다.
*/
const handleSaveCrop = () => {
if (!(cropWidth > 0 && cropHeight > 0) || isSavingCrop) {
message = 'Crop 영역의 너비와 높이는 0보다 커야 합니다.';
return;
}
isSavingCrop = true;
cropProgress = 0;
message = '서버에서 원본 저장 및 Crop 동영상 생성 중...';
// 새 API 엔드포인트: 원본 저장(Type 2)과 Crop본 저장(Type 1)을 모두 처리
// 임시 업로드된 파일을 기반으로 작동해야 함
cropEventSource = new EventSource(
`/api/save-crop-video?crop=${encodeURIComponent(
detectedCropParams
)}&fileName=${encodeURIComponent(originalFileName)}`
);
cropEventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.processId) {
currentProcessId = data.processId;
}
if (data.error) {
console.error('Crop Save Error:', data.error);
message = `오류 발생: ${data.error}`;
isSavingCrop = false;
currentProcessId = null;
cropEventSource?.close();
cropEventSource = null;
return;
}
if (data.progress) {
cropProgress = data.progress;
message = `동영상 Crop 및 저장 중... ${cropProgress.toFixed(0)}%`;
}
if (data.status === 'completed') {
setTimeout(() => {
message = '✅ 원본 및 Crop 동영상 저장이 완료되었습니다.';
isSavingCrop = false;
currentProcessId = null;
cropEventSource?.close();
cropEventSource = null;
// 폼 초기화
resetForm();
// [패널 3] 리스트 새로고침
loadCroppedVideos();
}, 500);
}
};
cropEventSource.onerror = (error) => {
console.error('EventSource failed:', error);
if (!message.includes('취소')) {
message = '오류: 서버와 연결이 끊어졌습니다.';
}
isSavingCrop = false;
currentProcessId = null;
cropEventSource?.close();
cropEventSource = null;
};
};
/**
* [패널 1] Crop 저장 작업을 취소합니다.
*/
const handleCancelCrop = async () => {
if (!currentProcessId || !isSavingCrop) return;
message = 'Crop 작업을 취소하는 중...';
try {
// 이 API는 'save-crop-video'에서 받은 processId를 취소해야 합니다.
const response = await fetch('/api/cancel-process', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ processId: currentProcessId })
});
const result = await response.json();
if (result.success) {
message = '작업이 성공적으로 취소되었습니다.';
} else {
message = `취소 실패: ${result.error || '알 수 없는 오류'}`;
}
} catch (error) {
console.error('Failed to send cancellation request:', error);
message = '취소 요청 중 네트워크 오류가 발생했습니다.';
} finally {
isSavingCrop = false;
currentProcessId = null;
cropEventSource?.close();
cropEventSource = null;
}
};
/**
* [패널 3] Crop 리스트에서 동영상을 삭제합니다. (Type: 1 동영상)
*/
const handleDeleteVideo = async (Index: number) => {
if (isLoading) return;
if (!confirm(`[Index: ${Index}] 동영상을 DB 및 파일 시스템에서 삭제하시겠습니까?`)) {
return;
}
message = `[Index: ${Index}] 동영상 삭제 중...`;
isLoadingList = true;
try {
// 이 API는 DB 레코드(id)와 실제 파일(movie_path)을 모두 삭제해야 함
const response = await fetch('/api/delete-video', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Index: Index, movieType: 1 }) // movieType 1 (Crop본) 삭제
});
const result = await response.json();
if (result.success) {
message = `[Index: ${Index}] 동영상이 성공적으로 삭제되었습니다.`;
// 리스트에서 즉시 제거
croppedVideos = croppedVideos.filter((v) => v.Index !== Index);
} else {
throw new Error(result.error || '알 수 없는 오류');
}
} catch (error) {
console.error('Error deleting video:', error);
message = `동영상 삭제 중 오류 발생: ${error.message}`;
} finally {
isLoadingList = false;
}
};
// --- 4. 유틸리티 함수 ---
/**
* 작업 완료 후 폼을 초기화합니다.
*/
const resetForm = () => {
videoURL = '';
originalFileName = '';
message = '동영상 파일을 선택하세요.';
cropX = 0;
cropY = 0;
cropWidth = 0;
cropHeight = 0;
uploadProgress = 0;
isUploadingOriginal = false;
isDetectingCrop = false;
isSavingCrop = false;
// 파일 입력 필드 값 초기화
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
if (fileInput) fileInput.value = '';
};
/**
* 페이지 로드 시 Crop 리스트를 불러옵니다.
*/
onMount(() => {
loadCroppedVideos();
});
</script>
<main class="mx-auto max-w-screen-2xl p-4 sm:p-6 lg:p-8 flex flex-col h-screen">
<div class="text-center mb-8 flex-shrink-0">
<h1 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl">
🎬 1단계: 동영상 업로드 및 Crop
</h1>
</div>
<p
class="p-4 mb-6 bg-blue-50 text-blue-700 border border-blue-200 rounded-lg text-center flex-shrink-0"
>
{message}
</p>
<div class="flex-1 flex gap-6 overflow-hidden">
<section class="w-1/3 bg-white shadow-sm ring-1 ring-gray-900/5 rounded-xl p-6 flex flex-col overflow-hidden">
<h2 class="text-xl font-semibold text-gray-800 mb-4 flex-shrink-0">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 flex-shrink-0"
>
동영상 파일 불러오기
<input
type="file"
accept="video/*"
on:change={handleFileSelect}
class="sr-only"
disabled={isLoading}
/>
</label>
<div class="flex-1 overflow-y-auto mt-4 space-y-2 text-sm pr-2">
{#if isUploadingOriginal}
<div>
<p class="font-medium text-gray-900 mb-1">서버로 업로드 중...</p>
<progress class="w-full" max="100" value={uploadProgress} />
<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 || videoURL}
<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"
disabled={isSavingCrop}
/>
</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"
disabled={isSavingCrop}
/>
</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"
disabled={isSavingCrop}
/>
</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"
disabled={isSavingCrop}
/>
</div>
</div>
</div>
<button
on:click={isSavingCrop ? handleCancelCrop : handleSaveCrop}
disabled={(isLoading && !isSavingCrop) ||
(!isSavingCrop && !(cropWidth > 0 && cropHeight > 0))}
class="mt-4 w-full text-center cursor-pointer rounded-md border px-4 py-2 text-sm font-medium shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
class:bg-white={!isSavingCrop}
class:text-gray-700={!isSavingCrop}
class:hover:bg-gray-50={!isSavingCrop}
class:border-gray-300={!isSavingCrop}
class:bg-red-600={isSavingCrop}
class:text-white={isSavingCrop}
class:hover:bg-red-700={isSavingCrop}
class:border-transparent={isSavingCrop}
>
{#if isSavingCrop}
🔴 취소하기
{:else}
✅ Crop 저장하기
{/if}
</button>
{#if isSavingCrop}
<div class="mt-2">
<progress class="w-full" max="100" value={cropProgress} />
<p class="text-right font-mono text-gray-700">{cropProgress.toFixed(0)}%</p>
</div>
{/if}
{/if}
</div>
</section>
<section class="w-1/3 bg-white shadow-sm ring-1 ring-gray-900/5 rounded-xl p-6 flex flex-col">
<h2 class="text-xl font-semibold text-gray-800 mb-4 flex-shrink-0">2. 동영상 미리보기</h2>
{#if videoURL}
{#key videoURL}
<video
bind:this={videoElement}
class="w-full rounded-lg"
controls
autoplay
src={videoURL}
>
브라우저가 비디오 태그를 지원하지 않습니다.
</video>
{/key}
{:else}
<div
class="flex-1 h-full w-full flex items-center justify-center bg-gray-100 rounded-lg border border-dashed"
>
<p class="text-gray-500">동영상을 선택하세요.</p>
</div>
{/if}
</section>
<section class="w-1/3 bg-white shadow-sm ring-1 ring-gray-900/5 rounded-xl p-6 flex flex-col overflow-hidden">
<h2 class="text-xl font-semibold text-gray-800 mb-4 flex-shrink-0">
3. Crop 동영상 리스트 ({croppedVideos.length}개)
</h2>
<div class="flex-1 overflow-y-auto space-y-3 mb-6 pr-2">
{#if isLoadingList && croppedVideos.length === 0}
<p class="text-gray-500 text-center py-4 animate-pulse">리스트 로딩 중...</p>
{:else if croppedVideos.length === 0}
<p class="text-gray-500 text-center py-4">저장된 Crop 동영상이 없습니다.</p>
{:else}
{#each croppedVideos as video (video.Index)}
<div class="p-3 border rounded-lg flex items-center justify-between gap-2">
<div class="flex-1 overflow-hidden">
<p class="font-medium text-sm text-gray-800 truncate" title={video.movie_file}>
{video.movie_file}
</p>
<p class="text-xs text-gray-500">
{new Date(video.created_at).toLocaleString('ko-KR')}
</p>
</div>
<div class="flex-shrink-0 flex gap-2">
<a
href={`/video-editor?id=${video.Index}`}
class="px-3 py-1 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700"
title="이 동영상으로 구간 편집하기"
>
선택
</a>
<button
on:click={() => handleDeleteVideo(video.Index)}
class="px-2 py-1 text-sm font-medium text-red-600 hover:text-red-800"
title="삭제"
disabled={isLoading}
>
삭제
</button>
</div>
</div>
{/each}
{/if}
</div>
<div class="flex-shrink-0">
<button
on:click={loadCroppedVideos}
disabled={isLoading}
class="w-full 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"
>
🔄 새로고침
</button>
</div>
</section>
</div>
</main>

View File

@ -0,0 +1,378 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { path } from '$lib/utils';
// --- Type Definitions ---
interface Video {
Index: number;
LocationFile: string;
original_path?: string;
WorkerID?: number | null;
}
interface Worker {
id: number;
name: string;
}
// --- State Variables ---
let originals: Video[] = [];
let allCrops: Video[] = [];
let allSegments: Video[] = [];
let workers: Worker[] = [];
let selectedOriginal: Video | null = null;
let selectedCrop: Video | null = null;
let videoURL: string = '';
let selectedVideoForPlayer: Video | null = null;
let isLoading: boolean = true;
let message: string = 'Loading video list...';
// Manages the loading state for an individual worker update
let updatingWorkerIndex: number | null = null;
// --- Reactive Statements ---
$: filteredCrops = selectedOriginal
? allCrops.filter((crop) => crop.original_path === selectedOriginal.LocationFile)
: [];
$: filteredSegments = selectedCrop
? allSegments.filter((segment) => segment.original_path === selectedCrop.LocationFile)
: [];
/**
* Fetches the list of workers from the server.
*/
async function loadWorkers() {
try {
const response = await fetch('/api/workers');
if (!response.ok) {
throw new Error('Failed to load worker list');
}
workers = (await response.json()) as Worker[];
} catch (err: any) {
message = `Error loading workers: ${err.message}`;
console.error(err);
}
}
/**
* Fetches all video lists from the server.
*/
async function loadAllVideos() {
isLoading = true;
message = 'Reloading video list...';
try {
const response = await fetch('/api/video-lists');
if (!response.ok) {
throw new Error('Failed to fetch video lists from the server.');
}
const data = await response.json();
originals = data.originals || [];
allCrops = data.crops || [];
allSegments = data.segments || [];
message = 'Select a video from the list to manage.';
} catch (err: any) {
message = `Error: ${err.message}`;
console.error(err);
} finally {
isLoading = false;
}
}
// Load all necessary data when the component mounts.
onMount(() => {
Promise.all([loadAllVideos(), loadWorkers()]);
});
/**
* Plays the selected video in the player.
*/
function playVideo(video: Video) {
selectedVideoForPlayer = video;
videoURL = `/api/video-stream/${video.Index}?t=${Date.now()}`; // Cache busting
}
/**
* Handles selection of an original video.
*/
function handleOriginalSelect(video: Video) {
selectedOriginal = video;
selectedCrop = null; // Reset child selection
playVideo(video);
}
/**
* Handles selection of a cropped video.
*/
function handleCropSelect(video: Video) {
selectedCrop = video;
playVideo(video);
}
/**
* Handles changing the assigned worker for a video segment.
*/
async function handleWorkerChange(video, event) {
const newWorkerId = parseInt(event.target.value, 10);
updatingWorkerIndex = video.Index;
try {
const response = await fetch('/api/assign-worker', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
videoIndex: video.Index,
workerId: newWorkerId
})
});
if (!response.ok) {
const errData = await response.json();
throw new Error(errData.message || 'Failed to assign worker.');
}
// ✅ 반응성 갱신
video.WorkerID = newWorkerId;
allSegments = allSegments.map(v =>
v.Index === video.Index ? { ...v, WorkerID: newWorkerId } : v
);
await tick();
console.log(`[Success] Video ${video.Index} assigned to Worker ${newWorkerId}`);
} catch (err) {
console.error(err);
event.target.value = String(video.WorkerID || '0');
} finally {
updatingWorkerIndex = null;
}
}
/**
* Handles the deletion of a video and its descendants.
*/
async function handleDelete(video: Video, type: '원본' | 'Crop' | '구간') {
const fileName = path.pop(video.LocationFile);
const confirmationMessage = `[Permanent Deletion Warning]
Are you sure you want to delete this ${type} video?
- Target: ${fileName} (Index: ${video.Index})
This action will permanently delete not only the selected video but also all descendant videos, tens of thousands of frame images, and all related database information from the server. This action cannot be undone.`;
if (!confirm(confirmationMessage)) {
return;
}
isLoading = true;
message = `[Index: ${video.Index}] Deletion in progress...`;
try {
const response = await fetch(`/api/delete-video-recursive?index=${video.Index}`, {
method: 'DELETE'
});
if (!response.ok) {
const errData = await response.json().catch(() => ({
message: 'Response is not valid JSON.'
}));
throw new Error(errData.message || 'An error occurred during deletion on the server.');
}
// Reset UI state on successful deletion
if (selectedVideoForPlayer?.Index === video.Index) {
videoURL = '';
selectedVideoForPlayer = null;
}
if (selectedOriginal?.Index === video.Index) selectedOriginal = null;
if (selectedCrop?.Index === video.Index) selectedCrop = null;
await loadAllVideos(); // Reload list to reflect changes
message = `[Index: ${video.Index}] Deletion complete.`;
} catch (err: any) {
message = `Deletion failed: ${err.message}`;
console.error('[Delete] Error in handleDelete function:', err);
isLoading = false; // Ensure loading is stopped on error
}
}
</script>
<svelte:head>
<title>3단계: 동영상 관리</title>
</svelte:head>
<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">
🎬 3단계: 동영상 관리
</h1>
</div>
{#if message}
<p
class="p-4 mb-6 text-center rounded-lg"
class:bg-blue-50={!message.includes('Error')}
class:text-blue-700={!message.includes('Error')}
class:bg-red-50={message.includes('Error')}
class:text-red-700={message.includes('Error')}
>
{message}
</p>
{/if}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start">
<!-- Video Player Section -->
<section class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-xl p-6 sticky top-8">
<h2 class="text-xl font-semibold text-gray-800 mb-4">
Video Preview
{#if selectedVideoForPlayer}
<span class="text-sm font-normal text-gray-500 ml-2"
>(ID: {selectedVideoForPlayer.Index})</span
>
{/if}
</h2>
{#if videoURL}
{#key videoURL}
<video src={videoURL} class="w-full rounded-lg bg-gray-100" controls autoplay>
Your browser does not support the video tag.
</video>
{/key}
{:else}
<div class="aspect-video w-full flex items-center justify-center bg-gray-100 rounded-lg">
<p class="text-gray-500">Select a video from the list to play.</p>
</div>
{/if}
</section>
<!-- Original and Crop Lists Section -->
<div class="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">
Original List ({originals.length})
</h2>
<ul class="h-64 overflow-y-auto space-y-2 pr-2">
{#each originals as video (video.Index)}
<li
class="flex items-center justify-between p-2 rounded-md cursor-pointer"
class:bg-blue-100={selectedOriginal?.Index === video.Index}
class:hover:bg-gray-50={selectedOriginal?.Index !== video.Index}
>
<span
class="truncate flex-1"
on:click={() => handleOriginalSelect(video)}
title={video.LocationFile}
>
{path.pop(video.LocationFile)} (ID: {video.Index})
</span>
<button
on:click|stopPropagation={() => handleDelete(video, '원본')}
disabled={isLoading}
class="text-red-500 hover:text-red-700 font-bold px-2 disabled:opacity-50 flex-shrink-0 ml-2"
title="Permanently delete this original and all related crops and segments"
>
삭제
</button>
</li>
{:else}
<li class="text-gray-500 text-center pt-4">No original videos found.</li>
{/each}
</ul>
</section>
<section
class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-xl p-6"
class:opacity-50={!selectedOriginal}
>
<h2 class="text-xl font-semibold text-gray-800 mb-4">
Crop List ({filteredCrops.length})
</h2>
<ul class="h-64 overflow-y-auto space-y-2 pr-2">
{#if selectedOriginal}
{#each filteredCrops as video (video.Index)}
<li
class="flex items-center justify-between p-2 rounded-md cursor-pointer"
class:bg-blue-100={selectedCrop?.Index === video.Index}
class:hover:bg-gray-50={selectedCrop?.Index !== video.Index}
>
<span
class="truncate flex-1"
on:click={() => handleCropSelect(video)}
title={video.LocationFile}
>
{path.pop(video.LocationFile)} (ID: {video.Index})
</span>
<button
on:click|stopPropagation={() => handleDelete(video, 'Crop')}
disabled={isLoading}
class="text-red-500 hover:text-red-700 font-bold px-2 disabled:opacity-50 flex-shrink-0 ml-2"
title="Permanently delete this crop and all related segments"
>
삭제
</button>
</li>
{:else}
<li class="text-gray-500 text-center pt-4">No cropped videos for the selected original.</li>
{/each}
{:else}
<li class="text-gray-500 text-center pt-4">Select an original video first.</li>
{/if}
</ul>
</section>
</div>
<!-- Segments List Section -->
<section class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-xl p-6" class:opacity-50={!selectedCrop}>
<h2 class="text-xl font-semibold text-gray-800 mb-4">
Saved Segments List ({filteredSegments.length})
</h2>
<ul class="h-[33.5rem] overflow-y-auto space-y-2 pr-2">
{#if selectedCrop}
{#each filteredSegments as video (video.Index)}
<li
class="flex items-center justify-between p-2 rounded-md"
class:bg-blue-100={selectedVideoForPlayer?.Index === video.Index}
class:hover:bg-gray-50={selectedVideoForPlayer?.Index !== video.Index}
>
<span
class="truncate flex-1 cursor-pointer"
on:click={() => playVideo(video)}
title={video.LocationFile}
>
{path.pop(video.LocationFile)} (ID: {video.Index})
</span>
<select
value={String(video.WorkerID || '0')}
on:change={(e) => handleWorkerChange(video, e)}
on:click|stopPropagation
disabled={updatingWorkerIndex === video.Index || isLoading}
class="text-sm rounded border-gray-300 py-1 px-2 mx-2 flex-shrink-0
disabled:opacity-50 disabled:bg-gray-100 cursor-pointer"
>
<option value="0">[Unassigned]</option>
{#each workers as worker (worker.id)}
<option value={String(worker.id)}>{worker.name}</option>
{/each}
</select>
<button
on:click|stopPropagation={() => handleDelete(video, '구간')}
disabled={isLoading}
class="text-red-500 hover:text-red-700 font-bold px-1 disabled:opacity-50 flex-shrink-0"
title="Permanently delete this segment"
>
삭제
</button>
</li>
{:else}
<li class="text-gray-500 text-center pt-4">No saved segments for the selected crop.</li>
{/each}
{:else}
<li class="text-gray-500 text-center pt-4">Select a cropped video first.</li>
{/if}
</ul>
</section>
</div>
</main>

View File

@ -0,0 +1,19 @@
// src/routes/(protected)/+layout.server.ts
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ parent }) => {
// 부모 레이아웃(src/routes/+layout.server.ts)의 데이터를 기다립니다.
const { user } = await parent();
// (protected) 그룹의 모든 페이지에 대해 실행됩니다.
// 만약 'user' 객체가 없다면(즉, 로그인하지 않았다면)
if (!user) {
// 로그인 페이지로 리다이렉트시킵니다.
throw redirect(303, '/login');
}
// 로그인한 사용자는 페이지에 접근할 수 있습니다.
// user 데이터를 하위 페이지로 전달합니다.
return { user };
};

View File

@ -0,0 +1,931 @@
<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[] = [];
// OpenCV 이미지 변환
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);
const imageWidth = gray1.cols;
const imageHeight = gray1.rows;
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;
}
// ROI 좌표 보정
for (let i = 0; i < points1.rows; i++) {
points1.data32F[i * 2] += prevRect.x;
points1.data32F[i * 2 + 1] += prevRect.y;
}
// Optical Flow 계산
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;
}
// 이동된 좌표
let newX = prevRect.x + final_avg_dx;
let newY = prevRect.y + final_avg_dy;
let newWidth = prevRect.width;
let newHeight = prevRect.height;
// === 경계 보정 ===
if (newX < 1) newX = 1;
if (newY < 1) newY = 1;
if (newX + newWidth > imageWidth - 1)
newWidth = imageWidth - newX - 1;
if (newY + newHeight > imageHeight - 1)
newHeight = imageHeight - newY - 1;
stabilizedRects.push({
...prevRect,
x: Math.round(newX),
y: Math.round(newY),
width: Math.max(1, Math.round(newWidth)),
height: Math.max(1, Math.round(newHeight))
});
// 메모리 해제
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'];
// --- Auto Flow 버튼용 함수 (완전 통합 버전) ---
async function autoApplyFlowAndSave() {
if (!selectedDataNumberInfo) {
alert("현재 선택된 데이터 번호가 없습니다.");
return;
}
if (!prevRectsForDisplay || prevRectsForDisplay.length === 0) {
alert("이전 프레임 사각형이 없습니다.");
return;
}
try {
// 1⃣ 이전 프레임 사각형만큼 Add
const newRectsData = prevRectsForDisplay.map(rect => ({
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height
}));
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];
originalRectsFromDB = [...originalRectsFromDB, ...finalNewRects];
selectedRectIds = finalNewRects.map(r => r.id);
// 2⃣ Optical Flow 자동 계산
if (prevRectsForDisplay.length > 0 && isCvReady) {
const prevImgSrc = selectedDataNumberInfo?.LocationFile ? `/api/images/${selectedDataNumberInfo.LocationFile}` : null;
const currentImgSrc = `/api/images/${selectedDataNumberInfo.LocationFile}`;
if (prevImgSrc) {
stabilizedRectsResult = await calculateOpticalFlow(prevImgSrc, currentImgSrc, prevRectsForDisplay);
applyOpticalFlowResult();
}
}
// 3⃣ DB 저장
await saveRectangles(rectangles);
//alert("Auto Flow 적용 완료 ✅");
} catch (error) {
console.error(error);
alert(`Auto Flow 중 오류 발생 ❌: ${(error as Error).message}`);
}
}
async function autoApplyFlowLikeButton() {
if (!selectedDataNumberInfo) {
alert("현재 선택된 데이터 번호가 없습니다.");
return;
}
if (!prevRectsForDisplay || prevRectsForDisplay.length === 0) {
alert("이전 프레임 사각형이 없습니다.");
return;
}
try {
const url = `/api/rectangles/by-ref/${selectedDataNumberInfo.Index}`;
const finalNewRects = [];
// 1⃣ 이전 프레임 사각형을 순차적으로 DB에 저장
for (const rect of prevRectsForDisplay) {
const rectData = {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
};
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(rectData),
});
if (!res.ok) throw new Error("사각형 생성 실패");
const created = await res.json();
finalNewRects.push({ ...rectData, id: created.id });
}
// 2⃣ UI 배열 갱신
rectangles = [...rectangles, ...finalNewRects];
originalRectsFromDB = [...originalRectsFromDB, ...finalNewRects];
selectedRectIds = finalNewRects.map((r) => r.id);
// 3⃣ tick()을 이용해 Svelte DOM 업데이트 완료 대기
await tick();
const applyFlowButton =
document.querySelector('button[title="Apply Flow"]') || document.getElementById('apply-flow-btn');
// 4⃣ 실제 Apply Flow 버튼 클릭 이벤트 트리거
//const applyFlowButton = document.getElementById('apply-flow-btn');
if (applyFlowButton) {
console.log('✅ Apply Flow 버튼 클릭 시도 중...');
applyFlowButton.click();
console.log('✅ Apply Flow 버튼을 자동으로 클릭했습니다.');
} else {
console.warn('⚠️ Apply Flow 버튼을 찾을 수 없습니다.');
}
// 5⃣ 전체 DB 저장
await saveRectangles(rectangles);
//alert("✅ Auto Flow 적용 완료 (실제 버튼 클릭과 동일)");
} catch (error) {
console.error(error);
alert(`❌ Auto Flow 중 오류 발생: ${(error as Error).message}`);
}
}
</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 class="border-l border-gray-300 h-6 mx-2"></div>
<!-- Auto Flow 버튼 -->
<button
on:click={autoApplyFlowLikeButton}
title="Auto Flow"
class="p-2 bg-pink-600 hover:bg-pink-700 text-white rounded-md transition-colors"
>
Auto Flow
</button>
</div>
</div>
<div class="flex items-center justify-center md:justify-end gap-4 text-sm text-gray-600 pr-2">
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" bind:checked={showOpticalFlowVectors} class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
<span>Flow</span>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" bind:checked={showPrevRects} class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
<span>Prev</span>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" bind:checked={showCurrentRects} class="h-4 w-4 rounded border-gray-300 text-yellow-500 focus:ring-yellow-400" />
<span>Current</span>
</label>
</div>
</div>
<div class="flex-grow p-4 lg:p-6 overflow-auto">
{#if !selectedDataNumberInfo}
<div class="h-full flex justify-center items-center text-gray-500"><p>Select a data number to view the image.</p></div>
{:else}
<div class="relative w-full h-full shadow-lg bg-white image-viewer-container">
{#if !imageHasError}
<img
bind:this={imageElement}
src={`/api/images/${selectedDataNumberInfo.LocationFile}`}
alt={`Data ${selectedDataNumberInfo.DataNumber}`}
class="block w-full h-full object-contain"
on:error={() => imageHasError = true}
/>
{/if}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<svg
bind:this={svgElement}
viewBox={viewBox}
class="absolute top-0 left-0 w-full h-full"
on:mousedown|self={deselectRect}
tabindex="0"
on:keydown={(e) => { if (e.key === 'Escape') deselectRect(e); }}
role="region"
aria-label="Image annotation area">
{#if !imageHasError}
{#if showPrevRects}
{#each prevRectsForDisplay as rect (rect.id)}
<rect
x={rect.x} y={rect.y} width={rect.width} height={rect.height}
class="fill-transparent stroke-blue-500 stroke-2"
style="stroke-dasharray: 6 3;"
/>
{/each}
{/if}
{#if showCurrentRects}
{#each rectangles as rect (rect.id)}
{@const isSelected = selectedRectIds.includes(rect.id)}
<rect
x={rect.x} y={rect.y} width={rect.width} height={rect.height}
role="button"
tabindex="0"
aria-label={`Annotation ${rect.id}`}
class="fill-yellow-400/20 stroke-yellow-400 stroke-2"
class:selected={isSelected} class:cursor-move={isSelected}
on:mousedown|stopPropagation={(e) => handleInteractionStart(e, rect, 'move', 'body')}
/>
{#if selectedRectangles.length === 1 && selectedRectangles[0].id === rect.id}
{#each handles as handle}
{@const handleX = handle.includes('w') ? rect.x : (handle.includes('e') ? rect.x + rect.width : rect.x + rect.width / 2)}
{@const handleY = handle.includes('n') ? rect.y : (handle.includes('s') ? rect.y + rect.height : rect.y + rect.height / 2)}
<rect
x={handleX - 4} y={handleY - 4} width="8" height="8"
role="button"
tabindex="0"
aria-label={`Resize handle ${handle}`}
class="fill-white stroke-blue-600 stroke-2 cursor-{handle}-resize"
on:mousedown|stopPropagation={(e) => handleInteractionStart(e, rect, 'resize', handle)}
/>
{/each}
{/if}
{/each}
{/if}
{/if}
</svg>
<canvas bind:this={opticalFlowCanvas} class="absolute top-0 left-0 w-full h-full pointer-events-none"></canvas>
</div>
{/if}
</div>
</div>
</div>

View File

@ -0,0 +1,10 @@
<script lang="ts">
export let data;
</script>
<div class="p-6">
<h1 class="text-2xl font-bold">
안녕하세요, {data.user.name} 님 👋
</h1>
<p class="text-gray-700 mt-2">작업자 ID: {data.user.login_id}</p>
</div>

View File

@ -0,0 +1,55 @@
<script lang="ts">
import type { PageData } from './$types';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
export let data: PageData;
// data.user는 루트 +layout.server.ts에서 옵니다.
// 이미 로그인한 사용자는 이 페이지를 볼 필요가 없습니다.
// onMount는 이 코드가 클라이언트에서 실행되도록 보장합니다.
onMount(() => {
if (data.user) {
// 기본 대시보드 페이지(메뉴 1번)로 리다이렉트
goto('/image-generate');
}
});
</script>
{#if !data.user}
<div class="flex flex-col items-center justify-center min-h-full p-4">
<div
class="w-full max-w-md bg-white p-10 md:p-12 rounded-2xl shadow-xl text-center transform -translate-y-10"
>
<div class="mb-6">
<svg
class="w-16 h-16 mx-auto text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4M4 7l8 5 8-5"
></path>
<path stroke-linecap="round" stroke-linejoin="round" d="M4 12l8 5 8-5"></path>
</svg>
</div>
<h1 class="text-3xl md:text-4xl font-bold text-gray-800 mb-4">BigData Server</h1>
<p class="text-lg text-gray-600 mb-10">데이터를 분석하고 시각화하는 최고의 플랫폼</p>
<a
href="/login"
class="w-full px-6 py-3 bg-blue-600 text-white text-lg font-semibold rounded-lg shadow-lg
hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50
transition-transform transform hover:scale-105"
>
시작하기
</a>
</div>
</div>
{/if}

View File

@ -0,0 +1,96 @@
<!-- src/routes/login/+page.svelte -->
<script lang="ts">
// invalidateAll을 import 합니다.
import { goto, invalidateAll } from '$app/navigation';
let login_id = '';
let password = '';
let message = '';
let isLoading = false;
async function handleLogin(e: Event) {
e.preventDefault();
message = '';
isLoading = true;
try {
const res = await fetch('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ login_id, password })
});
const data = await res.json();
if (!res.ok) {
message = `❌ ${data.error ?? '로그인 실패'}`;
return;
}
// --- 여기가 핵심 변경사항 ---
// 로그인 성공 후, SvelteKit에게 모든 load 함수를 다시 실행하라고 알립니다.
// 이렇게 하면 +layout.server.ts가 새로운 쿠키를 읽어 사용자 정보를 갱신합니다.
await invalidateAll();
// -------------------------
message = '✅ 로그인 성공! 잠시 후 이동합니다...';
// bigdata-generate 페이지로 이동합니다.
setTimeout(() => goto('/bigdata-generate'), 800);
} catch (err) {
console.error('Login error:', err);
message = '❌ 서버에 문제가 발생했습니다.';
} finally {
isLoading = false;
}
}
</script>
<div class="flex flex-col items-center justify-center min-h-screen bg-gray-50">
<div class="w-full max-w-md p-8 space-y-6 bg-white rounded-xl shadow-lg">
<h1 class="text-3xl font-bold text-center text-gray-800">로그인</h1>
<form on:submit={handleLogin} class="space-y-4">
<div>
<label for="login_id" class="sr-only">로그인 아이디</label>
<input
id="login_id"
type="text"
bind:value={login_id}
placeholder="로그인 아이디"
class="w-full px-4 py-2 text-gray-700 bg-gray-100 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
required
/>
</div>
<div>
<label for="password" class="sr-only">비밀번호</label>
<input
id="password"
type="password"
bind:value={password}
placeholder="비밀번호"
class="w-full px-4 py-2 text-gray-700 bg-gray-100 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
required
/>
</div>
<button
type="submit"
disabled={isLoading}
class="w-full px-4 py-2 font-semibold text-white bg-purple-600 rounded-md hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 disabled:bg-purple-300 transition-colors duration-300"
>
{isLoading ? '로그인 중...' : '로그인'}
</button>
</form>
{#if message}
<p class="mt-4 text-center text-sm" class:text-red-500={message.startsWith('❌')}>
{message}
</p>
{/if}
<div class="text-center">
<a href="/register" class="text-sm text-purple-600 hover:underline">아직 회원이 아니신가요?</a>
</div>
</div>
</div>

View File

@ -0,0 +1,58 @@
// src/routes/login/+server.ts
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import pool from '$lib/server/database';
import { JWT_SECRET } from '$env/static/private';
export const POST: RequestHandler = async ({ request, cookies }) => {
try {
const { login_id, password } = await request.json();
// 1. 입력 값 유효성 검사
if (!login_id || !password) {
return json({ error: '아이디와 비밀번호를 모두 입력하세요.' }, { status: 400 });
}
// 2. 데이터베이스에서 사용자 정보 조회
const result = await pool.query('SELECT * FROM "Worker" WHERE login_id = $1', [login_id]);
const workerRecord = result.rows[0];
if (!workerRecord) {
return json({ error: '아이디 또는 비밀번호가 일치하지 않습니다.' }, { status: 401 });
}
// 3. 입력된 비밀번호와 DB에 저장된 해시 비밀번호 비교
const isPasswordValid = await bcrypt.compare(password, workerRecord.password_hash);
if (!isPasswordValid) {
return json({ error: '아이디 또는 비밀번호가 일치하지 않습니다.' }, { status: 401 });
}
// 4. 로그인 성공, JWT 토큰 생성
const workerPayload = {
id: workerRecord.workerid,
login_id: workerRecord.login_id,
name: workerRecord.workername
};
const token = jwt.sign(workerPayload, JWT_SECRET, { expiresIn: '7d' });
// 5. 토큰을 쿠키에 저장
cookies.set('session', token, {
path: '/',
httpOnly: true,
// 개발 환경에서는 false, 프로덕션에서는 true로 설정하는 것이 좋습니다.
secure: process.env.NODE_ENV === 'production',
// 7 days
maxAge: 60 * 60 * 24 * 7,
sameSite: 'lax'
});
return json({ success: true, worker: workerPayload });
} catch (err) {
console.error('로그인 실패:', err);
return json({ error: '서버 내부 오류가 발생했습니다.' }, { status: 500 });
}
};

View File

@ -0,0 +1,65 @@
// src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types';
import jwt from 'jsonwebtoken';
import { JWT_SECRET } from '$env/static/private';
// --- 1. 메뉴 및 역할 정의 ---
// 공통 타입을 정의합니다.
type UserRole = 'admin' | 'user';
type MenuItem = { title: string; path: string };
// 역할별 메뉴 목록을 미리 정의합니다.
const menuConfig: Record<UserRole, MenuItem[]> = {
// 'admin' (birdhead) 사용자가 볼 메뉴
admin: [
{ title: '4. 빅데이터 만들기', path: '/bigdata-generate' },
{ title: '3. 동영상 관리', path: '/video-manage' },
{ title: '2. 동영상 구간 편집', path: '/video-crop' },
{ title: '1. 동영상 업로드 및 crop', path: '/image-generate' }
],
// 'user' (그 외) 사용자가 볼 메뉴
user: [{ title: '4. 빅데이터 만들기', path: '/bigdata-generate' }]
};
// --- 2. load 함수 수정 ---
export const load: LayoutServerLoad = ({ cookies }) => {
const token = cookies.get('session');
// 1. 토큰이 없으면: 로그아웃 상태
if (!token) {
return { user: null, menuItems: [] }; // 빈 메뉴 반환
}
try {
// 3. JWT 토큰 검증
const decoded = jwt.verify(token, JWT_SECRET);
if (typeof decoded === 'object' && decoded !== null && decoded.login_id) {
// --- 4. 역할(Role) 부여 ---
const role: UserRole = decoded.login_id === 'birdhead' ? 'admin' : 'user';
// 5. 사용자 객체에 role을 포함시킵니다.
const user = {
id: decoded.id,
login_id: decoded.login_id,
name: decoded.name,
role: role //
};
// 6. 역할에 맞는 메뉴를 가져옵니다.
const menuItems = menuConfig[role];
// 7. 사용자 정보와 메뉴 목록을 함께 반환
return { user, menuItems };
}
return { user: null, menuItems: [] };
} catch (err) {
// 5. 토큰이 유효하지 않은 경우
console.error('Invalid token:', err);
cookies.delete('session', { path: '/' });
return { user: null, menuItems: [] };
}
};

View File

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

View File

@ -0,0 +1,41 @@
// src/routes/api/assign-worker/+server.ts
import { json, error } from '@sveltejs/kit';
import pool from '$lib/server/database';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ request }) => {
const { videoIndex, workerId } = (await request.json()) as {
videoIndex: number;
workerId: number;
};
if (!videoIndex) {
throw error(400, 'videoIndex가 필요합니다.');
}
console.log(`[API] /assign-worker: Video ${videoIndex}에 Worker ${workerId} 할당 요청`);
// 프론트엔드에서 '[미지정]' (값 0)을 보낸 경우 DB에 NULL로 저장합니다.
const finalWorkerId = workerId === 0 ? null : workerId;
const client = await pool.connect();
try {
const result = await client.query(
'UPDATE public."TumerMovie" SET "WorkerID" = $1 WHERE "Index" = $2',
[finalWorkerId, videoIndex]
);
if (result.rowCount === 0) {
console.warn(`[API] /assign-worker: 업데이트할 Video(Index: ${videoIndex})를 찾지 못함`);
throw error(404, '해당 동영상을 찾을 수 없습니다.');
}
console.log(`[API] /assign-worker: Video ${videoIndex}의 WorkerID 업데이트 완료`);
return json({ success: true, videoIndex, assignedWorkerId: finalWorkerId });
} catch (err: any) {
console.error('[API] /assign-worker: DB 업데이트 중 오류:', err);
throw error(500, '데이터베이스 업데이트 중 오류가 발생했습니다.');
} finally {
client.release();
}
};

View File

@ -0,0 +1,32 @@
// src/routes/api/cancel-process/+server.ts
import { json, type RequestHandler } from '@sveltejs/kit';
import { activeProcesses } from '$lib/server/processes';
/**
* API
*/
export const POST: RequestHandler = async ({ request }) => {
try {
const { processId } = await request.json();
if (!processId) {
return json({ success: false, error: 'Process ID is required' }, { status: 400 });
}
const processToKill = activeProcesses.get(processId);
if (processToKill) {
console.log(`[${processId}] Received cancellation request. Killing process.`);
// SIGKILL 신호를 보내 프로세스를 강제 종료합니다.
processToKill.kill('SIGKILL');
activeProcesses.delete(processId); // 맵에서 프로세스를 제거합니다.
return json({ success: true, message: `Process ${processId} was cancelled.` });
} else {
console.warn(`[${processId}] Attempted to cancel a process that does not exist or has already completed.`);
return json({ success: false, error: 'Process not found or already completed.' }, { status: 404 });
}
} catch (error) {
console.error('Error cancelling process:', error);
return json({ success: false, error: 'Internal server error' }, { status: 500 });
}
};

View File

@ -0,0 +1,134 @@
// src/routes/video/crop-progress/+server.ts
import { type RequestHandler } from '@sveltejs/kit';
import { spawn, exec } from 'child_process';
import * as fs from 'fs';
import * as util from 'util';
import * as path from 'path';
import { activeProcesses, processInfo } from '$lib/server/processes';
const execPromise = util.promisify(exec);
async function getVideoDuration(filePath: string): Promise<number> {
try {
const { stdout } = await execPromise(
`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${filePath}"`
);
return parseFloat(stdout);
} catch {
return 0;
}
}
// ✅ Node 종료 시 FFmpeg 종료
process.on('exit', () => {
for (const [id, proc] of activeProcesses.entries()) {
try {
proc.kill('SIGKILL');
} catch {}
}
});
// ✅ 좀비 감시
setInterval(() => {
exec('ps -eo pid,ppid,stat,comm | grep ffmpeg | grep Z', (err, stdout) => {
if (stdout.trim()) console.warn('⚠️ Zombie ffmpeg:\n', stdout);
});
}, 60000);
// ✅ GET: SSE Crop Progress
export const GET: RequestHandler = ({ request }) => {
const url = new URL(request.url);
const cropParams = url.searchParams.get('crop');
if (!cropParams || !/^\d+:\d+:\d+:\d+$/.test(cropParams)) {
return new Response('Invalid crop parameters', { status: 400 });
}
const inputStream = new ReadableStream({
async start(controller) {
const processId = `crop-${Date.now()}`;
const inputPath = path.join(process.cwd(), 'tmp', 'process.mp4');
const outputPath = path.join(process.cwd(), 'tmp', `crop_${processId}.mp4`);
const enqueue = (data: string) => {
try { controller.enqueue(data); } catch {}
};
const close = () => {
try { controller.close(); } catch {}
};
const totalDuration = await getVideoDuration(inputPath);
if (totalDuration === 0) {
enqueue('data: {"error": "Failed to get duration"}\n\n');
close();
return;
}
const args = [
'-i', inputPath,
'-vf', `crop=${cropParams}`,
'-qp', '10',
'-c:v', 'h264_nvenc',
'-c:a', 'copy',
'-y', outputPath,
'-progress', 'pipe:1'
];
const proc = spawn('ffmpeg', args);
activeProcesses.set(processId, proc);
processInfo.set(processId, {
id: processId,
startTime: Date.now(),
status: 'running',
progress: 0
});
enqueue(`data: ${JSON.stringify({ processId })}\n\n`);
proc.stdout.on('data', (d) => {
const str = d.toString();
const match = str.match(/out_time_ms=(\d+)/);
if (match) {
const t = parseInt(match[1], 10) / 1_000_000;
const progress = Math.round((t / totalDuration) * 100);
processInfo.get(processId)!.progress = progress;
enqueue(`data: ${JSON.stringify({ progress })}\n\n`);
}
});
proc.on('close', async (code, signal) => {
activeProcesses.delete(processId);
const info = processInfo.get(processId);
if (code === 0) {
info!.status = 'completed';
info!.progress = 100;
try {
await fs.promises.rename(outputPath, inputPath);
enqueue(`data: ${JSON.stringify({ progress: 100, status: 'completed' })}\n\n`);
} catch {
enqueue(`data: ${JSON.stringify({ error: 'rename failed' })}\n\n`);
}
} else {
info!.status = proc.killed ? 'cancelled' : 'error';
enqueue(`data: ${JSON.stringify({ error: 'failed' })}\n\n`);
await fs.promises.rm(outputPath, { force: true });
}
close();
});
proc.on('error', (e) => {
enqueue(`data: ${JSON.stringify({ error: e.message })}\n\n`);
processInfo.get(processId)!.status = 'error';
close();
});
}
});
return new Response(inputStream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
}
});
};

View File

@ -0,0 +1,34 @@
// src/routes/api/crop-progress/[id]/+server.ts
import { type RequestHandler } from '@sveltejs/kit';
import { activeProcesses, processInfo } from '$lib/server/processes';
export const DELETE: RequestHandler = async ({ params }) => {
const { id } = params;
const proc = activeProcesses.get(id);
if (!proc) {
return new Response(JSON.stringify({ error: 'Process not found or already completed' }), {
status: 404,
headers: { 'Content-Type': 'application/json' }
});
}
try {
proc.kill('SIGKILL');
activeProcesses.delete(id);
const info = processInfo.get(id);
if (info) {
info.status = 'cancelled';
info.message = 'Process cancelled by user';
}
return new Response(JSON.stringify({ success: true, id }), {
headers: { 'Content-Type': 'application/json' }
});
} catch (err) {
return new Response(JSON.stringify({ error: (err as Error).message }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
};

View File

@ -0,0 +1,17 @@
// src/routes/api/crop-progress/list/+server.ts
import { type RequestHandler } from '@sveltejs/kit';
import { processInfo } from '$lib/server/processes';
export const GET: RequestHandler = () => {
const list = Array.from(processInfo.values()).map((info) => ({
id: info.id,
status: info.status,
progress: info.progress,
started: new Date(info.startTime).toISOString(),
message: info.message ?? null
}));
return new Response(JSON.stringify({ count: list.length, items: list }), {
headers: { 'Content-Type': 'application/json' }
});
};

View File

@ -0,0 +1,178 @@
import { type RequestHandler } from '@sveltejs/kit';
import * as path from 'path';
import { spawn } from 'child_process';
import * as fs from 'fs';
import * as util from 'util';
import { exec } from 'child_process';
import { activeProcesses } from '$lib/server/processes';
const execPromise = util.promisify(exec);
// ----------------------
// 🔸 비디오 길이 가져오기
// ----------------------
const getVideoDuration = async (filePath: string): Promise<number> => {
try {
const { stdout } = await execPromise(
`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${filePath}"`
);
return parseFloat(stdout);
} catch (error) {
console.error('Error getting video duration:', error);
return 0;
}
};
// ----------------------
// 🔸 Node 종료 시 FFmpeg 정리
// ----------------------
process.on('exit', () => {
console.log('🧹 Cleaning up leftover FFmpeg processes...');
for (const [id, proc] of activeProcesses.entries()) {
try {
proc.kill('SIGKILL');
} catch {}
}
});
// ----------------------
// 🔸 (선택) 좀비 모니터링
// ----------------------
setInterval(() => {
exec('ps -eo pid,ppid,stat,comm | grep ffmpeg | grep Z', (err, stdout) => {
if (stdout.trim()) {
console.warn('⚠️ Found zombie ffmpeg processes:\n', stdout);
}
});
}, 60000); // 1분마다 체크
// ----------------------
// 🔸 GET Handler
// ----------------------
export const GET: RequestHandler = ({ request }) => {
const url = new URL(request.url);
const cropParams = url.searchParams.get('crop');
if (!cropParams || !/^\d+:\d+:\d+:\d+$/.test(cropParams)) {
return new Response('Invalid crop parameters', { status: 400 });
}
const inputStream = new ReadableStream({
async start(controller) {
const processId = `crop-${Date.now()}`;
const inputPath = path.join(process.cwd(), 'tmp', 'process.mp4');
const tempOutputPath = path.join(process.cwd(), 'tmp', `process_crop_${Date.now()}.mp4`);
let isStreamClosed = false;
// 안전한 enqueue 함수
const safeEnqueue = (data: string) => {
try {
if (!isStreamClosed) controller.enqueue(data);
} catch {
console.warn('⚠️ Attempted to enqueue after close. Ignoring.');
}
};
// 안전한 close 함수
const safeClose = () => {
if (!isStreamClosed) {
isStreamClosed = true;
try {
controller.close();
} catch {
console.warn('⚠️ Attempted to close an already closed stream. Ignoring.');
}
}
};
const totalDuration = await getVideoDuration(inputPath);
if (totalDuration === 0) {
safeEnqueue('data: {"error": "Failed to get video duration."}\n\n');
safeClose();
return;
}
const ffmpegArgs = [
'-i', inputPath,
'-vf', `crop=${cropParams}`,
'-qp', '10',
'-c:v', 'h264_nvenc',
'-c:a', 'copy',
'-y', tempOutputPath,
'-progress', 'pipe:1'
];
console.log('🚀 Spawning FFmpeg with args:', ffmpegArgs.join(' '));
const ffmpegProcess = spawn('ffmpeg', ffmpegArgs);
ffmpegProcess.unref(); // ✅ Node 종료 시 좀비 방지
activeProcesses.set(processId, ffmpegProcess);
safeEnqueue(`data: ${JSON.stringify({ processId })}\n\n`);
const sendProgress = (progress: number) => {
safeEnqueue(`data: ${JSON.stringify({ progress })}\n\n`);
};
ffmpegProcess.stdout.on('data', (data) => {
const output = data.toString();
const timeMatch = output.match(/out_time_ms=(\d+)/);
if (timeMatch) {
const currentTimeMs = parseInt(timeMatch[1], 10);
const currentTime = currentTimeMs / 1_000_000;
const progress = Math.round((currentTime / totalDuration) * 100);
sendProgress(progress);
}
});
ffmpegProcess.stderr.on('data', (data) => {
console.error(`ffmpeg stderr: ${data}`);
});
ffmpegProcess.on('close', async (code, signal) => {
console.log(`[${processId}] FFmpeg process closed with code: ${code}, signal: ${signal}`);
// 먼저 프로세스 리스트에서 제거하고 리스너를 정리합니다.
activeProcesses.delete(processId);
ffmpegProcess.removeAllListeners();
if (code === 0) {
console.log('✅ FFmpeg Crop process completed successfully.');
try {
await fs.promises.rename(tempOutputPath, inputPath);
console.log(`✅ File renamed successfully: ${tempOutputPath} -> ${inputPath}`);
safeEnqueue(`data: ${JSON.stringify({ progress: 100, status: 'completed' })}\n\n`);
} catch (renameError) {
console.error(`❌ File rename failed:`, renameError);
safeEnqueue(`data: ${JSON.stringify({ error: 'Processing complete, but failed to save file.' })}\n\n`);
}
} else {
const msg = ffmpegProcess.killed
? 'Process was cancelled by user.'
: `FFmpeg failed (code ${code}, signal ${signal})`;
console.error(`[${processId}] ${msg}`);
// 실패 시 임시 파일 삭제
await fs.promises.rm(tempOutputPath, { force: true }).catch(() => {});
safeEnqueue(`data: ${JSON.stringify({ error: msg })}\n\n`);
}
// 모든 작업이 끝난 후 스트림을 닫습니다.
safeClose();
});
ffmpegProcess.on('error', (err) => {
if (isStreamClosed) return;
console.error('❌ Failed to start FFmpeg process:', err);
safeEnqueue(`data: ${JSON.stringify({ error: 'Failed to start FFmpeg.' })}\n\n`);
safeClose();
});
}
});
return new Response(inputStream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
}
});
};

View File

@ -0,0 +1,383 @@
import { error } from '@sveltejs/kit';
import { spawn, exec } from 'child_process';
import * as fs from 'fs/promises';
import * as path from 'path';
import { promisify } from 'util';
import pool from '../../../lib/server/database'; // DB 풀 가져오기
const execAsync = promisify(exec);
// [삭제] 고정된 FINAL_OUTPUT_DIR 및 관련 폴더 생성 로직을 제거하고 동적으로 생성합니다.
interface TimeSegment {
id: number;
start: number;
end: number;
}
/**
* FFmpeg '자르기' (stdout)
* out_time=... time=... .
*/
const parseFfmpegPipeProgress = (data: string): number | null => {
const lines = data.split('\n');
for (const line of lines) {
const key = line.startsWith('out_time=') ? 'out_time=' : (line.startsWith('time=') ? 'time=' : null);
if (key) {
const timeStr = line.substring(key.length).trim();
const timeMatch = timeStr.match(/(\d{2}):(\d{2}):(\d{2})\.(\d+)/);
if(timeMatch) {
const hours = parseInt(timeMatch[1], 10);
const minutes = parseInt(timeMatch[2], 10);
const seconds = parseInt(timeMatch[3], 10);
const fraction = parseFloat(`0.${timeMatch[4]}`);
return hours * 3600 + minutes * 60 + seconds + fraction;
}
}
}
return null;
};
/**
* FFmpeg '프레임 추출' (stderr)
* 'frame= 123' .
*/
const parseFfmpegFrameProgress = (data: string): number | null => {
const match = data.match(/frame=\s*(\d+)/);
if (match && match[1]) {
return parseInt(match[1], 10);
}
return null;
};
/**
* 'YYYYMMDD_HHmmSS' .
*/
const getCurrentTimestamp = (): string => {
const now = new Date();
const pad = (num: number) => num.toString().padStart(2, '0');
const year = now.getFullYear();
const month = pad(now.getMonth() + 1);
const day = pad(now.getDate());
const hours = pad(now.getHours());
const minutes = pad(now.getMinutes());
const seconds = pad(now.getSeconds());
return `${year}${month}${day}_${hours}${minutes}${seconds}`;
};
/**
* [] 'YYYYMMDD'
*/
const getCurrentDateFolder = (): string => {
const now = new Date();
const pad = (num: number) => num.toString().padStart(2, '0');
const year = now.getFullYear();
const month = pad(now.getMonth() + 1);
const day = pad(now.getDate());
return `${year}${month}${day}`;
};
/**
* 'HHmmSS_sss' .
*/
const formatDurationForFilename = (timeInSeconds: number): string => {
if (isNaN(timeInSeconds) || timeInSeconds < 0) return '000000_000';
const pad = (num: number, size: number) => num.toString().padStart(size, '0');
const hours = Math.floor(timeInSeconds / 3600);
const minutes = Math.floor((timeInSeconds % 3600) / 60);
const seconds = Math.floor(timeInSeconds % 60);
const milliseconds = Math.round((timeInSeconds - Math.floor(timeInSeconds)) * 1000);
return `${pad(hours, 2)}${pad(minutes, 2)}${pad(seconds, 2)}_${pad(milliseconds, 3)}`;
};
/**
* DB 'HH:mm:SS.sss' .
*/
const formatDurationForDB = (timeInSeconds: number): string => {
if (isNaN(timeInSeconds) || timeInSeconds < 0) return '00:00:00.000';
const pad = (num: number, size: number) => num.toString().padStart(size, '0');
const hours = Math.floor(timeInSeconds / 3600);
const minutes = Math.floor((timeInSeconds % 3600) / 60);
const seconds = Math.floor(timeInSeconds % 60);
const milliseconds = Math.round((timeInSeconds - Math.floor(timeInSeconds)) * 1000);
return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)}.${pad(milliseconds, 3)}`;
};
/**
* DB에
*/
const registerVideoInDB = async (
movieFilePath: string,
cutMovieFileName: string,
enqueue: (data: object) => void,
segmentId: number,
durationStart: number,
durationEnd: number,
originalPath: string // [추가] 원본 동영상 경로
): Promise<number> => {
const client = await pool.connect();
let tumerMovieIndex: number;
let totalFrameCount: number;
try {
await client.query('BEGIN');
// 1. FFprobe로 전체 프레임 수 빠르게 추정
enqueue({ type: 'progress_stage_start', stage: 'frames', segmentId: segmentId, message: '전체 프레임 계산 중...' });
const ffprobeCommand = `ffprobe -v error -select_streams v:0 -show_entries stream=duration,avg_frame_rate -of default=noprint_wrappers=1:nokey=1 "${movieFilePath}"`;
const { stdout } = await execAsync(ffprobeCommand);
const [fpsString, durationString] = stdout.trim().split('\n');
if (!fpsString || !durationString) throw new Error(`FFprobe failed to get duration/fps for ${movieFilePath}. Output: ${stdout}`);
const fpsParts = fpsString.split('/');
const avg_fps = parseFloat(fpsParts[0]) / (parseFloat(fpsParts[1] || '1'));
const duration = parseFloat(durationString);
totalFrameCount = Math.round(duration * avg_fps);
if (isNaN(totalFrameCount) || totalFrameCount <= 0) totalFrameCount = 1;
enqueue({ type: 'progress_stage_start', stage: 'frames', segmentId: segmentId, totalFrames: totalFrameCount });
// [수정] 2. TumerMovie에 INSERT (original_path 추가)
const locationNetwork = 'localhost';
const objectType = 0;
const durationStartStr = formatDurationForDB(durationStart);
const durationEndStr = formatDurationForDB(durationEnd);
const movieInsertQuery = `
INSERT INTO public."TumerMovie" (
"LocationNetwork", "LocationFile", "TotalFrameCount", "ObjectType", "durationStart", "durationEnd", "original_path"
) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING "Index"`;
const movieInsertRes = await client.query(movieInsertQuery, [
locationNetwork, movieFilePath, totalFrameCount, objectType, durationStartStr, durationEndStr, originalPath
]);
tumerMovieIndex = movieInsertRes.rows[0].Index;
console.log(`DB: TumerMovie 저장 완료 (Index: ${tumerMovieIndex})`);
// 3. FFmpeg로 모든 프레임 추출
const videoFileNameWithoutExt = cutMovieFileName.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 });
await new Promise<void>((resolve, reject) => {
//const ffmpeg = spawn('ffmpeg', ['-i', movieFilePath, `${tempFrameDir}/%d.png`]);
const ffmpeg = spawn('ffmpeg', [
'-i', movieFilePath,
'-vf', 'mpdecimate,setpts=N/FRAME_RATE/TB',
'-r', '60',
`${tempFrameDir}/%d.png`
]);
let stderr = '';
ffmpeg.stderr.on('data', (data: Buffer) => {
const dataStr = data.toString();
stderr += dataStr;
const currentFrame = parseFfmpegFrameProgress(dataStr);
if (currentFrame !== null) {
const progress = Math.min(100, (currentFrame / totalFrameCount) * 100);
enqueue({ type: 'progress', stage: 'frames', segmentId: segmentId, progress: progress });
}
});
ffmpeg.on('close', (code) => code === 0 ? resolve() : reject(new Error(`FFmpeg (frame extraction) exited with code ${code}. Stderr: ${stderr}`)));
ffmpeg.on('error', reject);
});
// 4. 프레임 재구성 및 DB 저장
enqueue({ type: 'progress_stage_start', stage: 'db_save', segmentId: segmentId });
const extractedFrames = await fs.readdir(tempFrameDir);
// [수정] 프레임 파일 목록을 숫자(frameNumber) 기준으로 정렬하고, 숫자가 아닌 파일은 제외합니다.
const sortedFrames = extractedFrames
.map(fileName => {
const frameNumber = parseInt(path.basename(fileName, '.png'), 10);
return { fileName, frameNumber };
})
.filter(frame => !isNaN(frame.frameNumber)) // 숫자로 변환할 수 없는 파일(e.g., .DS_Store) 제외
.sort((a, b) => a.frameNumber - b.frameNumber); // 숫자 오름차순으로 정렬
const datasetRefValues: (string | number)[] = [];
const queryParams: string[] = [];
let paramIndex = 1;
// [수정] 정렬된 프레임 목록으로 반복
for (const frame of sortedFrames) {
const { frameNumber, fileName } = frame;
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, fileName);
const newPath = path.join(finalDir, `${frameNumber}.png`);
await fs.rename(oldPath, newPath);
const dbLocationFile = newPath;
queryParams.push(`($${paramIndex++}, $${paramIndex++}, $${paramIndex++})`);
datasetRefValues.push(tumerMovieIndex, frameNumber, dbLocationFile);
}
await fs.rm(tempFrameDir, { recursive: true, force: true });
if (datasetRefValues.length > 0) {
const datasetRefQuery = `INSERT INTO public."TumerDatasetRef" ("IndexMovie", "DataNumber", "LocationFile") VALUES ${queryParams.join(', ')}`;
await client.query(datasetRefQuery, datasetRefValues);
}
await client.query('COMMIT');
return tumerMovieIndex;
} catch (err) {
await client.query('ROLLBACK');
console.error(`[${cutMovieFileName}] DB 처리 중 오류 발생:`, err);
throw err;
} finally {
client.release();
}
};
export const GET: import('@sveltejs/kit').RequestHandler = ({ url }) => {
const segmentsParam = url.searchParams.get('segments');
const videoIndexParam = url.searchParams.get('videoIndex');
if (!segmentsParam || !videoIndexParam || isNaN(Number(videoIndexParam))) {
throw error(400, '유효한 segments와 videoIndex가 필요합니다.');
}
const segments: TimeSegment[] = JSON.parse(segmentsParam);
let isStreamClosed = false;
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
const enqueue = (data: object) => {
if (isStreamClosed) return;
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
} catch (e: any) {
if (e.code === 'ERR_INVALID_STATE' || e.name === 'TypeError') {
isStreamClosed = true;
} else { throw e; }
}
};
let sourceVideoPath: string;
try {
const client = await pool.connect();
try {
const query = `SELECT "LocationFile" FROM "TumerMovie" WHERE "Index" = $1 AND "MovieType" = 1;`;
const result = await client.query(query, [videoIndexParam]);
if (result.rows.length === 0) throw new Error(`DB에서 Index ${videoIndexParam}에 해당하는 동영상을 찾을 수 없습니다.`);
sourceVideoPath = result.rows[0].LocationFile;
await fs.access(sourceVideoPath, fs.constants.F_OK);
} finally {
client.release();
}
const processSegment = (segment: TimeSegment): Promise<void> => {
return new Promise(async (resolve, reject) => {
try {
const segmentDuration = segment.end - segment.start;
if (segmentDuration <= 0) return reject(new Error(`Segment ${segment.id} has invalid duration.`));
const todaysDate = getCurrentDateFolder();
const sourceFileNameBase = path.basename(sourceVideoPath, path.extname(sourceVideoPath));
const timestamp = getCurrentTimestamp();
const durationStart = formatDurationForFilename(segment.start);
const durationEnd = formatDurationForFilename(segment.end);
const outputFileName = `${sourceFileNameBase}-cut-${timestamp}_${durationStart}_${durationEnd}.mp4`;
const newOutputDir = path.join('/workspace/image/video/bigdata', todaysDate);
await fs.mkdir(newOutputDir, { recursive: true });
const outputPath = path.join(newOutputDir, outputFileName);
const ffmpegArgs = [
'-ss', segment.start.toString(), '-i', sourceVideoPath,
'-t', segmentDuration.toString(), '-c:v', 'h264_nvenc',
'-qp', '5', '-r', '60', '-c:a', 'copy', '-progress', 'pipe:1', '-y', outputPath
];
const ffmpeg = spawn('ffmpeg', ffmpegArgs);
ffmpeg.stdout.on('data', (data: Buffer) => {
const time = parseFfmpegPipeProgress(data.toString());
if (time !== null) {
const progress = Math.min(100, (time / segmentDuration) * 100);
enqueue({ type: 'progress', stage: 'cut', segmentId: segment.id, progress });
}
});
let stderr = '';
ffmpeg.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
ffmpeg.on('close', async (code) => {
if (code === 0) {
try {
console.log(`[${outputFileName}] 1단계: FFmpeg cut complete. 2단계: DB/Frame 시작...`);
// [수정] registerVideoInDB 호출 시 sourceVideoPath 전달
const newIndex = await registerVideoInDB(
outputPath,
outputFileName,
enqueue,
segment.id,
segment.start,
segment.end,
sourceVideoPath // <-- 원본 경로 전달
);
const resultUrl = `/cuts/${outputFileName}`;
enqueue({ type: 'done', segmentId: segment.id, url: resultUrl, newIndex: newIndex });
resolve();
} catch (dbError) {
reject(dbError);
}
} else {
reject(new Error(`FFmpeg (cut) exited with code ${code}. Stderr: ${stderr}`));
}
});
ffmpeg.on('error', reject);
} catch (err) {
reject(err);
}
});
};
await Promise.all(segments.map(segment => processSegment(segment)));
enqueue({ type: 'all_complete' });
} catch (err: any) {
console.error('Segment cutting or DB processing failed (main catch):', err);
enqueue({ type: 'error', error: err.message || '알 수 없는 오류가 발생했습니다.' });
} finally {
if (!isStreamClosed) {
isStreamClosed = true;
controller.close();
}
}
},
cancel(reason) {
console.warn(`[Stream] Stream canceled by client. Reason: ${reason}`);
isStreamClosed = true;
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
}
});
};

View File

@ -0,0 +1,103 @@
// src/routes/api/delete-cut-video/+server.ts
import { error, json } from '@sveltejs/kit';
import pool from '$lib/server/database';
import * as fs from 'fs/promises';
import * as path from 'path';
// cut-segments-progress와 동일한 경로 설정이 필요합니다.
// (나중에 이 경로들은 $lib/server/config.ts 같은 곳으로 빼는 것이 좋습니다)
const FINAL_OUTPUT_DIR = path.resolve(process.cwd(), 'static', 'cuts');
const IMAGE_REF_BASE_DIR = '/workspace/image/image_ref'; // registerVideoInDB의 경로와 동일해야 함
export const DELETE: import('@sveltejs/kit').RequestHandler = async ({ url }) => {
const indexToDelete = url.searchParams.get('index');
if (!indexToDelete || isNaN(Number(indexToDelete))) {
throw error(400, '유효한 "index"가 필요합니다.');
}
console.log(`[Delete] 삭제 요청 수신: TumerMovie Index ${indexToDelete}`);
const client = await pool.connect();
let locationFile: string | null = null;
try {
// --- 1. DB 삭제 (트랜잭션) ---
await client.query('BEGIN');
// 1-1. TumerDatasetRef (자식) 레코드 삭제
console.log(`[Delete DB] "TumerDatasetRef" (IndexMovie=${indexToDelete}) 삭제 시작...`);
const refDeleteRes = await client.query(
'DELETE FROM public."TumerDatasetRef" WHERE "IndexMovie" = $1',
[indexToDelete]
);
console.log(`[Delete DB] "TumerDatasetRef" ${refDeleteRes.rowCount}개 레코드 삭제 완료.`);
// 1-2. TumerMovie (부모) 레코드 삭제 및 파일명 반환
console.log(`[Delete DB] "TumerMovie" (Index=${indexToDelete}) 삭제 시작...`);
const movieDeleteRes = await client.query(
'DELETE FROM public."TumerMovie" WHERE "Index" = $1 RETURNING "LocationFile"',
[indexToDelete]
);
if (movieDeleteRes.rows.length === 0) {
// 이미 삭제되었거나 존재하지 않는 Index일 수 있음
console.warn(`[Delete DB] "TumerMovie" (Index=${indexToDelete}) 찾을 수 없음.`);
await client.query('ROLLBACK'); // 롤백
throw error(404, `Index ${indexToDelete}에 해당하는 동영상을 DB에서 찾을 수 없습니다.`);
}
locationFile = movieDeleteRes.rows[0].LocationFile;
console.log(`[Delete DB] "TumerMovie" 삭제 완료. 파일명: ${locationFile}`);
// 1-3. 트랜잭션 커밋
await client.query('COMMIT');
} catch (dbError: any) {
await client.query('ROLLBACK');
console.error('[Delete DB] DB 삭제 트랜잭션 중 오류 발생:', dbError);
throw error(500, `DB 삭제 중 오류: ${dbError.message}`);
} finally {
client.release();
}
// --- 2. 파일 시스템 삭제 (DB 트랜잭션 성공 후에만 실행) ---
if (!locationFile) {
// 이 경우는 거의 없지만, 안전 장치
console.warn(`[Delete FS] LocationFile이 없어 파일 삭제를 건너뜁니다.`);
return json({ success: true, message: 'DB 삭제 완료. 파일 경보 없음.' });
}
try {
// 2-1. 동영상 파일 삭제
const videoPath = path.join(FINAL_OUTPUT_DIR, locationFile);
console.log(`[Delete FS] 동영상 파일 삭제 시도: ${videoPath}`);
try {
await fs.rm(videoPath, { force: true }); // force: true (파일 없으면 에러 안 냄)
console.log(`[Delete FS] 동영상 파일 삭제 완료.`);
} catch (videoErr) {
console.warn(`[Delete FS] 동영상 파일 삭제 중 경고 (무시):`, videoErr);
}
// 2-2. 이미지 프레임 폴더 삭제
// 'V00000001_crop..._cut-0.mp4' -> 'V00000001_crop..._cut-0'
const videoFileNameWithoutExt = locationFile.split('.').slice(0, -1).join('.');
const imageRefDir = path.join(IMAGE_REF_BASE_DIR, videoFileNameWithoutExt);
console.log(`[Delete FS] 이미지 폴더 삭제 시도: ${imageRefDir}`);
try {
await fs.rm(imageRefDir, { recursive: true, force: true });
console.log(`[Delete FS] 이미지 폴더 삭제 완료.`);
} catch (imgErr) {
console.warn(`[Delete FS] 이미지 폴더 삭제 중 경고 (무시):`, imgErr);
}
return json({ success: true, message: 'DB 및 파일 시스템 삭제 완료' });
} catch (fsError: any) {
// DB는 삭제됐지만 파일 삭제 중 심각한 오류 발생
console.error('[Delete FS] 파일 시스템 삭제 중 치명적 오류 발생:', fsError);
// 클라이언트에게는 오류를 알리지만, DB는 이미 커밋되었음을 인지해야 함
throw error(500, `파일 시스템 삭제 중 오류: ${fsError.message} (DB는 삭제되었을 수 있습니다)`);
}
};

View File

@ -0,0 +1,117 @@
import { error, json } from '@sveltejs/kit';
import pool from '$lib/server/database';
import * as fs from 'fs/promises';
import * as path from 'path';
// 삭제할 이미지 프레임이 저장된 기본 경로 (cut-segments-progress와 동일해야 함)
const IMAGE_REF_BASE_DIR = '/workspace/image/image_ref';
/**
* Index의
* @param indexToDelete TumerMovie의 Index
* @param client DB
* @param visited Index를 Set
*/
const deleteVideoAndChildren = async (indexToDelete: number, client: any, visited: Set<number>): Promise<void> => {
// [수정] 이미 방문한 Index이면, 무한 루프 방지를 위해 즉시 종료
if (visited.has(indexToDelete)) {
console.warn(`[Delete] 순환 참조 발견: Index ${indexToDelete}를 이미 방문했습니다. 삭제를 건너뜁니다.`);
return;
}
// 현재 Index를 방문 목록에 추가
visited.add(indexToDelete);
console.log(`[Delete] 삭제 프로세스 시작: Index ${indexToDelete}`);
// 1. 현재 Index의 정보를 가져옵니다.
const movieRes = await client.query(
'SELECT "LocationFile", "MovieType" FROM "TumerMovie" WHERE "Index" = $1',
[indexToDelete]
);
if (movieRes.rows.length === 0) {
console.warn(`[Delete] Index ${indexToDelete}는 이미 삭제되었거나 존재하지 않아 건너뜁니다.`);
return;
}
const { "LocationFile": locationFile, "MovieType": movieType } = movieRes.rows[0];
// 원본(Type 2) 또는 Crop(Type 1) 파일인 경우에만 자식을 탐색합니다.
const canHaveChildren = (movieType === 2 || movieType === 1);
if (canHaveChildren) {
console.log(`[Delete] 부모 파일(Index: ${indexToDelete}, Type: ${movieType}) 확인. 하위 요소를 먼저 삭제합니다.`);
// 2. 이 동영상을 'original_path'로 갖는 모든 자식 동영상을 찾습니다.
const childrenRes = await client.query(
'SELECT "Index" FROM "TumerMovie" WHERE "original_path" = $1 AND "Index" != $2',
[locationFile, indexToDelete]
);
// 3. 각 자식에 대해 재귀적으로 삭제 함수 호출 (visited Set 전달)
for (const child of childrenRes.rows) {
await deleteVideoAndChildren(child.Index, client, visited);
}
} else {
console.log(`[Delete] 최하위 파일(Index: ${indexToDelete}, Type: ${movieType}) 확인. 자식 탐색을 건너뜁니다.`);
}
// 4. 모든 자식이 삭제된 후 (또는 자식이 없는 경우), 현재 Index의 파일과 DB 레코드를 삭제합니다.
console.log(`[Delete] 이제 Index ${indexToDelete} 자체를 삭제합니다.`);
// 4-1. 파일 시스템에서 동영상 파일 삭제
try {
console.log(`[Delete FS] 동영상 파일 삭제 시도: ${locationFile}`);
await fs.rm(locationFile, { force: true }); // 파일이 없어도 오류 발생 안 함
} catch (e: any) {
console.warn(`[Delete FS] 동영상 파일 삭제 중 경고 (무시): ${e.message}`);
}
// 4-2. 파일 시스템에서 관련 프레임 이미지 폴더 삭제
try {
const videoFileNameWithoutExt = path.basename(locationFile, path.extname(locationFile));
const imageRefDir = path.join(IMAGE_REF_BASE_DIR, videoFileNameWithoutExt);
console.log(`[Delete FS] 이미지 폴더 삭제 시도: ${imageRefDir}`);
await fs.rm(imageRefDir, { recursive: true, force: true });
} catch (e: any) {
console.warn(`[Delete FS] 이미지 폴더 삭제 중 경고 (무시): ${e.message}`);
}
// 4-3. DB에서 TumerDatasetRef(자식 레코드) 삭제
await client.query('DELETE FROM "TumerDatasetRef" WHERE "IndexMovie" = $1', [indexToDelete]);
// 4-4. DB에서 TumerMovie(부모 레코드) 삭제
await client.query('DELETE FROM "TumerMovie" WHERE "Index" = $1', [indexToDelete]);
console.log(`[Delete] Index ${indexToDelete} 및 관련 파일/DB 완전 삭제 완료.`);
};
export const DELETE: import('./$types').RequestHandler = async ({ url }) => {
const indexToDelete = url.searchParams.get('index');
if (!indexToDelete || isNaN(Number(indexToDelete))) {
throw error(400, '유효한 "index" 파라미터가 필요합니다.');
}
const client = await pool.connect();
try {
// 모든 DB 및 파일 시스템 작업을 하나의 트랜잭션으로 묶습니다.
await client.query('BEGIN');
// [수정] 재귀 호출을 시작할 때 비어있는 Set을 생성하여 전달
await deleteVideoAndChildren(Number(indexToDelete), client, new Set<number>());
await client.query('COMMIT');
return json({ success: true, message: `Index ${indexToDelete} 및 모든 하위 요소가 성공적으로 삭제되었습니다.` });
} catch (err: any) {
await client.query('ROLLBACK');
console.error(`[API] delete-video-recursive: Index ${indexToDelete} 삭제 중 심각한 오류 발생:`, err);
throw error(500, `삭제 작업 중 오류가 발생했습니다: ${err.message}`);
} finally {
client.release();
}
};

View File

@ -0,0 +1,102 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import pool from '$lib/server/database';
import fs from 'fs/promises';
/**
* .
* ( )
*/
async function deleteFileIfExists(filePath: string | null | undefined) {
if (!filePath) {
console.warn('[File] 삭제할 파일 경로가 없습니다.');
return;
}
try {
await fs.unlink(filePath);
console.log(`[File] 삭제 완료: ${filePath}`);
} catch (e: any) {
if (e.code === 'ENOENT') {
console.warn(`[File] 삭제하려 했으나 이미 없음: ${filePath}`);
} else {
console.error(`[File] 파일 삭제 중 오류 발생: ${filePath}`, e);
throw new Error(`파일 삭제 실패: ${filePath}`);
}
}
}
// --- API 핸들러 ---
export const POST: RequestHandler = async ({ request }) => {
const { Index } = await request.json(); // video-crop/+page.svelte에서 보낸 id
if (!Index) {
return json({ success: false, error: 'Index가 필요합니다.' }, { status: 400 });
}
const client = await pool.connect();
try {
// --- 트랜잭션 시작 ---
await client.query('BEGIN');
// 1. [조회] 삭제할 Crop 동영상(Type 1)의 파일 경로와 원본 경로를 찾습니다.
const cropVideoQuery = `
SELECT "LocationFile", "original_path"
FROM "TumerMovie"
WHERE "Index" = $1 AND "MovieType" = 1;
`;
const cropResult = await client.query(cropVideoQuery, [Index]);
if (cropResult.rows.length === 0) {
// 이미 삭제되었거나 존재하지 않는 Index일 수 있습니다.
await client.query('ROLLBACK'); // 트랜잭션 롤백
return json(
{ success: false, error: `[Index: ${Index}] MovieType 1 동영상을 찾을 수 없습니다.` },
{ status: 404 }
);
}
const cropFilePath: string | null = cropResult.rows[0].LocationFile;
const originalPath: string | null = cropResult.rows[0].original_path;
// 2. [삭제] Crop 동영상(Type 1)의 DB 레코드를 삭제합니다.
await client.query(`DELETE FROM "TumerMovie" WHERE "Index" = $1;`, [Index]);
console.log(`[DB] MovieType 1 (Index: ${Index}) 레코드 삭제 완료.`);
// 3. [삭제] 원본 동영상(Type 2)의 DB 레코드와 파일을 삭제합니다.
if (originalPath) {
// 3a. 원본 DB 레코드 삭제
const deleteOriginalQuery = `
DELETE FROM "TumerMovie"
WHERE "LocationFile" = $1 AND "MovieType" = 2;
`;
const deleteResult = await client.query(deleteOriginalQuery, [originalPath]);
if (deleteResult.rowCount > 0) {
console.log(`[DB] MovieType 2 (Path: ${originalPath}) 레코드 삭제 완료.`);
} else {
console.warn(`[DB] MovieType 2 (Path: ${originalPath}) 레코드를 찾을 수 없습니다.`);
}
// 3b. 원본 파일 삭제
await deleteFileIfExists(originalPath);
}
// 4. [삭제] Crop 동영상(Type 1)의 파일을 삭제합니다.
await deleteFileIfExists(cropFilePath);
// --- 모든 작업 성공: 트랜잭션 커밋 ---
await client.query('COMMIT');
return json({ success: true, message: `[Index: ${Index}] Crop 동영상 및 원본이 삭제되었습니다.` });
} catch (error: any) {
// --- 오류 발생: 트랜잭션 롤백 ---
await client.query('ROLLBACK');
console.error('Error deleting video:', error);
return json({ success: false, error: '삭제 중 오류 발생', details: error.message }, { status: 500 });
} finally {
// 클라이언트를 풀에 반환
client.release();
}
};

View File

@ -0,0 +1,31 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import pool from '$lib/server/database'; // 1번에서 만든 DB 연결 풀
export const GET: RequestHandler = async () => {
console.log('GET /api/get-cropped-videos - 요청 수신');
const client = await pool.connect();
try {
// 사용자가 'TumerMovie'라고 했으나 'TumerMovie'일 수 있습니다.
// 테이블명과 컬럼명('MovieType', 'id')이 대소문자를 구분하는 경우 큰따옴표(")를 사용하세요.
const query = `
SELECT * FROM "TumerMovie"
WHERE "MovieType" = 1
ORDER BY "Index" DESC;
`;
const result = await client.query(query);
console.log(`GET /api/get-cropped-videos - ${result.rows.length}개 레코드 반환`);
return json(result.rows);
} catch (error) {
console.error('Error fetching cropped videos:', error);
// 'message'는 'any' 타입일 수 있으므로 캐스팅합니다.
const errorMessage = (error as Error).message || 'Unknown database error';
return json({ error: '데이터베이스 조회 중 오류가 발생했습니다.', details: errorMessage }, { status: 500 });
} finally {
// 클라이언트를 풀에 반환하여 연결 누수를 방지합니다.
client.release();
}
};

View File

@ -7,16 +7,36 @@ const IMAGE_BASE_DIRECTORY = '/workspace';
export const GET: RequestHandler = ({ params }) => { export const GET: RequestHandler = ({ params }) => {
const imagePath = params.path; const imagePath = params.path;
// 보안 검사: 상위 디렉토리 접근 방지
if (imagePath.includes('..')) { if (imagePath.includes('..')) {
throw error(403, 'Forbidden'); throw error(403, 'Forbidden');
} }
const fullPath = path.join(IMAGE_BASE_DIRECTORY, imagePath);
// [수정] imagePath가 이미 '/workspace'를 포함하는 전체 경로인지 확인
let fullPath: string;
// params.path가 'workspace/...'로 시작하는 경우 (URL에서는 앞의 '/'가 생략됨)
if (imagePath.startsWith('/workspace/')) {
// 이미 '/workspace'로 시작하므로, 앞에 '/'만 붙여 절대 경로로 만듭니다.
fullPath = `${imagePath}`;
} else {
// 기존 로직: 상대 경로로 간주하고 IMAGE_BASE_DIRECTORY를 붙입니다.
fullPath = path.join(IMAGE_BASE_DIRECTORY, imagePath);
}
console.log(`[Image Server] Requested path: ${imagePath}, Resolved to full path: ${fullPath}`);
// 파일 존재 여부 확인
if (!fs.existsSync(fullPath)) { if (!fs.existsSync(fullPath)) {
console.error(`[Image Server] File not found at resolved path: ${fullPath}`);
throw error(404, 'Not Found'); throw error(404, 'Not Found');
} }
try { try {
const fileBuffer = fs.readFileSync(fullPath); const fileBuffer = fs.readFileSync(fullPath);
const fileExtension = path.extname(fullPath).toLowerCase(); const fileExtension = path.extname(fullPath).toLowerCase();
let mimeType = 'application/octet-stream'; let mimeType = 'application/octet-stream';
switch (fileExtension) { switch (fileExtension) {
case '.png': mimeType = 'image/png'; break; case '.png': mimeType = 'image/png'; break;
@ -24,6 +44,7 @@ export const GET: RequestHandler = ({ params }) => {
case '.bmp': mimeType = 'image/bmp'; break; case '.bmp': mimeType = 'image/bmp'; break;
case '.gif': mimeType = 'image/gif'; break; case '.gif': mimeType = 'image/gif'; break;
} }
return new Response(fileBuffer, { return new Response(fileBuffer, {
headers: { headers: {
'Content-Type': mimeType, 'Content-Type': mimeType,

View File

@ -0,0 +1,47 @@
import type { RequestHandler } from './$types';
import { json, error } from '@sveltejs/kit';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import pool from '$lib/server/database';
const SECRET = process.env.JWT_SECRET || 'changeme';
export const POST: RequestHandler = async ({ request }) => {
const { login_id, password } = await request.json();
if (!login_id || !password) {
throw error(400, '아이디와 비밀번호를 입력해주세요.');
}
// ✅ PostgreSQL에서 사용자 조회
const { rows } = await pool.query(
'SELECT * FROM "Worker" WHERE login_id = $1',
[login_id]
);
const worker = rows[0];
if (!worker) throw error(401, '존재하지 않는 아이디입니다.');
// ✅ 비밀번호 검증
const valid = await bcrypt.compare(password, worker.password_hash);
if (!valid) throw error(401, '비밀번호가 올바르지 않습니다.');
// ✅ JWT 발급
const token = jwt.sign(
{
id: worker.workerid,
name: worker.workername,
login_id: worker.login_id
},
SECRET,
{ expiresIn: '8h' }
);
return json({
token,
worker: {
id: worker.workerid,
name: worker.workername,
login_id: worker.login_id
}
});
};

View File

@ -0,0 +1,18 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { dev } from '$app/environment';
export const POST: RequestHandler = async ({ cookies }) => {
// 'session' 쿠키를 삭제합니다.
// 쿠키를 생성(set)할 때 사용했던 옵션들을 동일하게 명시해줘야 합니다.
cookies.delete('session', {
path: '/',
httpOnly: true,
secure: !dev, // 개발 환경(http)에서는 false, 프로덕션(https)에서는 true
sameSite: 'strict'
});
// 성공 JSON 응답을 보냅니다.
return json({ success: true });
};

View File

@ -0,0 +1,17 @@
import type { RequestHandler } from './$types';
import { json, error } from '@sveltejs/kit';
import jwt from 'jsonwebtoken';
const SECRET = process.env.JWT_SECRET || 'changeme';
export const GET: RequestHandler = async ({ request }) => {
const auth = request.headers.get('authorization');
if (!auth) throw error(401, '토큰 없음');
const token = auth.split(' ')[1];
try {
const user = jwt.verify(token, SECRET);
return json(user);
} catch {
throw error(401, '유효하지 않은 토큰');
}
};

View File

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

View File

@ -7,7 +7,7 @@ export const PUT: RequestHandler = async ({ params, request }) => {
const { id } = params; const { id } = params;
const { x, y, width, height } = await request.json(); const { x, y, width, height } = await request.json();
const tumerPosition = [x, y, x + width, y + height]; const tumerPosition = [Math.round(x), Math.round(y), Math.round(x + width), Math.round(y + height)];
try { try {
await pool.query( await pool.query(

View File

@ -30,7 +30,8 @@ export const POST: RequestHandler = async ({ params, request }) => {
const { refIndex } = params; const { refIndex } = params;
const { x, y, width, height } = await request.json(); const { x, y, width, height } = await request.json();
const tumerPosition = [x, y, x + width, y + height]; const tumerPosition = [Math.round(x), Math.round(y), Math.round(x + width), Math.round(y + height)];
try { try {
const result = await pool.query( const result = await pool.query(

View File

@ -0,0 +1,24 @@
import type { RequestHandler } from './$types';
import { json, error } from '@sveltejs/kit';
import bcrypt from 'bcrypt';
import pool from '$lib/server/database';
export const POST: RequestHandler = async ({ request }) => {
const { login_id, password, worker_name } = await request.json();
if (!login_id || !password || !worker_name)
throw error(400, '모든 필드를 입력해주세요.');
const salt = await bcrypt.genSalt(10);
const hash = await bcrypt.hash(password, salt);
try {
await pool.query(
'INSERT INTO "Worker" (WorkerName, login_id, password_hash) VALUES ($1, $2, $3)',
[worker_name, login_id, hash]
);
return json({ message: '작업자 등록 완료' });
} catch (err: any) {
throw error(500, '이미 존재하는 로그인 아이디거나 DB 오류입니다.');
}
};

View File

@ -0,0 +1,285 @@
// src/routes/api/save-crop-video/+server.ts
import type { RequestHandler } from './$types';
import { error } from '@sveltejs/kit';
import { spawn, exec } from 'child_process';
import { promisify } from 'util';
import * as fs from 'fs/promises';
import * as path from 'path';
import pool from '$lib/server/database'; // 1. DB 풀
import { activeProcesses } from '$lib/server/processes'; // 2. 프로세스 매니저
const execAsync = promisify(exec);
// --- 경로 설정 ---
// (주의) 이 경로는 SvelteKit 서버가 실행되는 '호스트' 기준입니다. (컨테이너 내부가 아님)
// '/home/ssdoctors/project/BigDataPolyp/tmp/process.mp4' (이전 로그 기준)
const TEMP_FILE_PATH = path.resolve(process.cwd(), 'tmp', 'process.mp4');
const ORIGINAL_DIR = '/workspace/image/video/original';
const CROP_DIR = '/workspace/image/video/crop';
// --- 헬퍼 함수 ---
/**
* SSE(Server-Sent Events) .
*/
function sendEvent(controller: ReadableStreamDefaultController, data: object) {
controller.enqueue(`data: ${JSON.stringify(data)}\n\n`);
}
/**
* FFmpeg의 (: 00:01:30.50) .
*/
function parseFFmpegTime(timeString: string): number {
const [hours, minutes, seconds] = timeString.split(':').map(parseFloat);
return hours * 3600 + minutes * 60 + seconds;
}
/**
* ffprobe를 .
*/
async function getDuration(filePath: string): Promise<number> {
try {
const command = `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${filePath}"`;
const { stdout } = await execAsync(command);
return parseFloat(stdout);
} catch (e) {
console.error('ffprobe duration error:', e);
throw new Error('동영상 길이를 가져오는 데 실패했습니다.');
}
}
const formatDurationForDB = (timeInSeconds: number): string => {
if (isNaN(timeInSeconds) || timeInSeconds < 0) return '00:00:00.000';
const pad = (num: number, size: number) => num.toString().padStart(size, '0');
const hours = Math.floor(timeInSeconds / 3600);
const minutes = Math.floor((timeInSeconds % 3600) / 60);
const seconds = Math.floor(timeInSeconds % 60);
const milliseconds = Math.round((timeInSeconds - Math.floor(timeInSeconds)) * 1000);
return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)}.${pad(milliseconds, 3)}`;
};
/**
* TumerMovie .
*/
async function registerInDB(filePath: string, movieType: 1 | 2, originalPath: string) {
const client = await pool.connect();
try {
// 1. [수정] 더 빠른 ffprobe 명령어 (duration과 fps 사용)
const ffprobeCommand = `ffprobe -v error -select_streams v:0 -show_entries stream=duration,avg_frame_rate -of default=noprint_wrappers=1:nokey=1 "${filePath}"`;
// stdout 예시:
// 30000/1001 (avg_frame_rate)
// 120.500000 (duration)
const { stdout } = await execAsync(ffprobeCommand);
const [fpsString, durationString] = stdout.trim().split('\n');
// '30000/1001' 같은 분수 파싱
const fpsParts = fpsString.split('/');
const avg_fps = parseFloat(fpsParts[0]) / (parseFloat(fpsParts[1] || '1'));
const duration = parseFloat(durationString);
const durationStartStr = formatDurationForDB(0);
const durationEndStr = formatDurationForDB(duration);
// 총 프레임 수 추정
const totalFrameCount = Math.round(duration * avg_fps);
if (isNaN(totalFrameCount)) {
throw new Error('TotalFrameCount 계산 실패');
}
const query = `
INSERT INTO "TumerMovie" ("LocationFile", "MovieType", "created_at", "original_path", "TotalFrameCount", "durationStart", "durationEnd")
VALUES ($1, $2, NOW(), $3, $4, $5, $6)
RETURNING "Index";
`;
const result = await client.query(query, [filePath, movieType, originalPath, totalFrameCount, durationStartStr, durationEndStr]);
console.log(`[DB] MovieType ${movieType} 저장 완료 (Index: ${result.rows[0].Index}) FrameCount: ${totalFrameCount} (추정)`);
} catch (e) {
console.error(`[DB] MovieType ${movieType} 저장 실패:`, e);
throw new Error(`DB 저장 실패 (Type: ${movieType})`);
} finally {
client.release();
}
}
/*
async function registerInDB(filePath: string, movieType: 1 | 2, originalPath: string) {
const client = await pool.connect();
try {
// 1. FFprobe로 TotalFrameCount 계산 및 TumerMovie에 INSERT
const ffprobeCommand = `ffprobe -v error -select_streams v:0 -count_frames -show_entries stream=nb_read_frames -of default=nokey=1:noprint_wrappers=1 "${filePath}"`;
const { stdout } = await execAsync(ffprobeCommand);
const totalFrameCount = parseInt(stdout.trim(), 10);
const query = `
INSERT INTO "TumerMovie" ("LocationFile", "MovieType", "created_at", "original_path", "TotalFrameCount")
VALUES ($1, $2, NOW(), $3, $4)
RETURNING "Index";
`;
const result = await client.query(query, [filePath, movieType, originalPath, totalFrameCount]);
console.log(`[DB] MovieType ${movieType} 저장 완료 (Index: ${result.rows[0].Index}) FrameCount: ${totalFrameCount}`);
} catch (e) {
console.error(`[DB] MovieType ${movieType} 저장 실패:`, e);
throw new Error(`DB 저장 실패 (Type: ${movieType})`);
} finally {
client.release();
}
}
*/
// --- API 핸들러 ---
export const GET: RequestHandler = ({ url }) => {
const cropParams = url.searchParams.get('crop');
const originalFileName = url.searchParams.get('fileName');
if (!cropParams || !originalFileName) {
throw error(400, 'crop 파라미터와 fileName이 필요합니다.');
}
let ffmpegProcess: ReturnType<typeof spawn> | null = null;
const processId = `crop-${Date.now()}`;
const stream = new ReadableStream({
async start(controller) {
sendEvent(controller, { processId });
try {
// 0. 대상 디렉토리 생성
await fs.mkdir(ORIGINAL_DIR, { recursive: true });
await fs.mkdir(CROP_DIR, { recursive: true });
// 1. 파일명 및 경로 정의
const baseName = path.parse(originalFileName).name;
const ext = path.parse(originalFileName).ext;
//const newOriginalFileName = `${baseName}_${processId}${ext}`;
// 현재 시각을 YYYYMMDD_HHmmss 형식으로 포맷
const now = new Date();
const yyyy = now.getFullYear();
const MM = String(now.getMonth() + 1).padStart(2, '0');
const dd = String(now.getDate()).padStart(2, '0');
const HH = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
const ss = String(now.getSeconds()).padStart(2, '0');
const timestamp = `${yyyy}${MM}${dd}_${HH}${mm}${ss}`;
// crop 파일 이름 변경
const newCropFileName = `${baseName}_crop-${timestamp}${ext}`;
const originalDestPath = path.join(ORIGINAL_DIR, originalFileName);
const cropDestPath = path.join(CROP_DIR, newCropFileName);
// 2. 동영상 총 길이 가져오기 (프로그레스 계산용)
const totalDuration = await getDuration(TEMP_FILE_PATH);
sendEvent(controller, { message: `동영상 총 길이: ${totalDuration.toFixed(2)}` });
// 3. [작업 1] 원본 파일 복사 및 DB 등록 (MovieType: 2)
sendEvent(controller, { message: '원본 파일 저장 중...' });
await fs.copyFile(TEMP_FILE_PATH, originalDestPath);
await registerInDB(originalDestPath, 2, originalDestPath);
sendEvent(controller, { message: '원본 파일 저장 완료.' });
// 4. [작업 2] Crop 파일 생성 (MovieType: 1)
sendEvent(controller, { message: 'Crop 동영상 생성 시작...' });
/*
const args = [
'-i', originalDestPath,
'-vf', `crop=${cropParams}`,
'-c:a', 'copy', // 오디오는 재인코딩 없이 복사
'-y', // 덮어쓰기
cropDestPath
];
*/
const args = [
'-i', originalDestPath,
'-vf', `crop=${cropParams}`,
'-r', '60',
'-qp', '10',
'-c:v', 'h264_nvenc',
'-c:a', 'copy',
'-y', cropDestPath,
'-progress', 'pipe:1'
];
ffmpegProcess = spawn('ffmpeg', args);
activeProcesses.set(processId, ffmpegProcess);
// FFmpeg 진행률 파싱
ffmpegProcess.stderr.on('data', (data: Buffer) => {
const line = data.toString();
// 예: time=00:00:10.54
const timeMatch = line.match(/time=(\d{2}:\d{2}:\d{2}\.\d{2})/);
if (timeMatch) {
const currentTime = parseFFmpegTime(timeMatch[1]);
let progress = (currentTime / totalDuration) * 100;
progress = Math.min(Math.max(progress, 0), 100); // 0~100% 범위 보장
sendEvent(controller, { progress: progress });
}
});
// FFmpeg 종료 이벤트
ffmpegProcess.on('close', async (code) => {
activeProcesses.delete(processId);
if (code === 0) {
// 성공
sendEvent(controller, { progress: 100 });
await registerInDB(cropDestPath, 1, originalDestPath);
sendEvent(controller, { status: 'completed' });
} else {
// 0이 아닌 코드는 오류 또는 취소
console.error(`FFmpeg exited with code ${code}`);
if (code === 255) { // SIGTERM에 의한 종료 코드 (OS마다 다를 수 있음)
sendEvent(controller, { error: '작업이 사용자에 의해 취소되었습니다.' });
} else {
sendEvent(controller, { error: `FFmpeg 처리 실패 (Code: ${code})` });
}
}
// 5. [작업 3] 임시 파일 삭제
await fs.unlink(TEMP_FILE_PATH).catch(e => console.error("임시 파일 삭제 실패:", e));
controller.close();
});
// FFmpeg 스폰 오류
ffmpegProcess.on('error', (err) => {
activeProcesses.delete(processId);
console.error('FFmpeg spawn error:', err);
sendEvent(controller, { error: `FFmpeg 실행 오류: ${err.message}` });
controller.close();
});
} catch (err) {
const errorMessage = (err as Error).message;
console.error('save-crop-video API error:', errorMessage);
sendEvent(controller, { error: errorMessage });
controller.close();
// 오류 발생 시에도 임시 파일 삭제 시도
await fs.unlink(TEMP_FILE_PATH).catch(e => console.error("오류 후 임시 파일 삭제 실패:", e));
}
},
cancel() {
// 클라이언트가 EventSource.close()를 호출하거나 연결이 끊겼을 때
if (ffmpegProcess) {
console.log(`[${processId}] 클라이언트 연결 끊김. 프로세스 종료 중...`);
ffmpegProcess.kill('SIGTERM');
activeProcesses.delete(processId);
}
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
}
});
};

View File

@ -1,3 +1,4 @@
<<<<<<< HEAD
import { json, type RequestHandler } from '@sveltejs/kit'; import { json, type RequestHandler } from '@sveltejs/kit';
import fs from 'fs/promises'; import fs from 'fs/promises';
import path from 'path'; import path from 'path';
@ -36,4 +37,133 @@ export const POST: RequestHandler = async ({ request }) => {
console.error('파일 업로드 처리 중 오류:', error); console.error('파일 업로드 처리 중 오류:', error);
return json({ success: false, error: '서버에서 파일 처리 중 오류가 발생했습니다.' }, { status: 500 }); return json({ success: false, error: '서버에서 파일 처리 중 오류가 발생했습니다.' }, { status: 500 });
} }
=======
// src/routes/api/upload-cut/+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'; // DB 풀 가져오기
const execAsync = promisify(exec);
export const POST: import('@sveltejs/kit').RequestHandler = async ({ request }) => {
const formData = await request.formData();
const videoFile = formData.get('video') as File | null;
// originalFileName은 현재 로직에서는 직접 사용되지 않지만,
// 클라이언트에서 보내주므로 일단 받아둡니다. 필요시 로깅 등에 활용할 수 있습니다.
const originalFileName = formData.get('originalFileName') as string | null;
if (!videoFile) {
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 movieBaseDir = '/workspace/image/movie';
const movieUploadDir = path.join(movieBaseDir, dateFolder);
await fs.mkdir(movieUploadDir, { recursive: true });
// videoFile.name은 클라이언트에서 생성한 '원본이름_cut-...' 형태의 파일명입니다.
const cutMovieFileName = videoFile.name;
const movieFilePath = path.join(movieUploadDir, cutMovieFileName);
const buffer = Buffer.from(await videoFile.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'; // 고정값 또는 환경 변수 사용
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, cutMovieFileName, totalFrameCount, objectType]);
const tumerMovieIndex = movieInsertRes.rows[0].Index;
console.log(`TumerMovie 저장 완료 (잘라낸 영상), Index: ${tumerMovieIndex}`);
// --- 3. FFmpeg로 모든 프레임 추출 ---
const videoFileNameWithoutExt = cutMovieFileName.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(`[${cutMovieFileName}] ${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);
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(`[${cutMovieFileName}] 프레임 파일 재구성 완료`);
// --- 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(`[${cutMovieFileName}] ${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(); // 클라이언트 연결 해제
}
>>>>>>> 0bab142 (add login/logout)
}; };

View File

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

View File

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

View File

@ -0,0 +1,36 @@
// src/routes/api/video-lists/+server.ts
import { json, error } from '@sveltejs/kit';
import pool from '$lib/server/database';
export const GET: import('./$types').RequestHandler = async () => {
console.log('[API] video-lists: 목록 조회 요청 수신');
const client = await pool.connect();
try {
// ⭐ [수정] SELECT 구문에 "WorkerID"를 추가합니다.
const allVideosRes = await client.query(
'SELECT "Index", "LocationFile", "MovieType", "original_path", "WorkerID" FROM public."TumerMovie" ORDER BY "Index" DESC'
);
const allVideos = allVideosRes.rows;
// [수정] 올바른 MovieType 값으로 데이터를 정확히 분류합니다.
const originals = allVideos.filter((v) => v.MovieType === 2); // 원본은 MovieType 2
const crops = allVideos.filter((v) => v.MovieType === 1); // Crop은 MovieType 1
const segments = allVideos.filter((v) => v.MovieType === 0); // 구간은 MovieType 0
console.log(
`[API] video-lists: 원본 ${originals.length}개, Crop ${crops.length}개, 구간 ${segments.length}개 조회 완료.`
);
return json({
originals,
crops,
segments // 이제 'segments' 배열의 객체에 "WorkerID"가 포함됩니다.
});
} catch (err: any) {
console.error('[API] video-lists: DB 조회 중 오류 발생:', err);
throw error(500, '데이터베이스에서 동영상 목록을 가져오는 데 실패했습니다.');
} finally {
client.release();
}
};

View File

@ -1,3 +1,4 @@
<<<<<<< HEAD
import { type RequestHandler } from '@sveltejs/kit'; import { type RequestHandler } from '@sveltejs/kit';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
@ -25,4 +26,63 @@ export const GET: RequestHandler = async ({ setHeaders }) => {
} catch (error) { } catch (error) {
return new Response('영상을 찾을 수 없습니다.', { status: 404 }); return new Response('영상을 찾을 수 없습니다.', { status: 404 });
} }
=======
// ✅ 수정된 /api/video-preview/+server.ts (SvelteKit 2.x / Kit 1.x 호환)
import { error } from '@sveltejs/kit';
import * as fs from 'fs';
import * as path from 'path';
import { Readable } from 'stream';
// process.mp4 파일이 있는 실제 경로로 수정하세요
const TEMP_BASE_DIR = 'tmp'; // 예시 경로
const videoPath = path.join(TEMP_BASE_DIR, 'process.mp4');
export const GET: import('@sveltejs/kit').RequestHandler = ({ request }) => {
let stats: fs.Stats;
try {
stats = fs.statSync(videoPath);
} catch (e) {
throw error(404, 'Video file not found');
}
const fileSize = stats.size;
const range = request.headers.get('Range');
const responseHeaders = {
'Content-Type': 'video/mp4',
'Accept-Ranges': 'bytes'
};
if (range) {
// Range 요청 처리 (Firefox가 요구하는 부분)
const parts = range.replace(/bytes=/, "").split("-");
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
if (start >= fileSize) {
return new Response(null, {
status: 416, // Range Not Satisfiable
headers: { 'Content-Range': `bytes */${fileSize}` }
});
}
const chunkSize = (end - start) + 1;
const fileStream = fs.createReadStream(videoPath, { start, end });
Object.assign(responseHeaders, {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Content-Length': chunkSize.toString(),
});
return new Response(Readable.toWeb(fileStream) as ReadableStream, { status: 206, headers: responseHeaders }); // 206 Partial Content
} else {
// 전체 파일 요청 (Chrome이 fallback하는 부분)
Object.assign(responseHeaders, {
'Content-Length': fileSize.toString(),
});
const fileStream = fs.createReadStream(videoPath);
return new Response(Readable.toWeb(fileStream) as ReadableStream, { status: 200, headers: responseHeaders }); // 200 OK
}
>>>>>>> 0bab142 (add login/logout)
}; };

View File

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

View File

@ -0,0 +1,94 @@
import fs from 'fs';
import path from 'path';
import { error, type RequestHandler } from '@sveltejs/kit';
import pool from '$lib/server/database';
import { lookup as getMimeType } from 'mime-types'; // ✅ ESM 호환 안전한 방식
export const GET: RequestHandler = async ({ params, request }) => {
console.log(`\n--- [video-stream] 요청 수신: ${new Date().toISOString()} ---`);
const Index = params.Index;
console.log(`[1. 파라미터] 요청된 동영상 Index: ${Index}`);
if (!Index || isNaN(Number(Index))) {
throw error(400, '유효한 동영상 Index가 필요합니다.');
}
const client = await pool.connect();
let filePath: string;
try {
console.log('[2. DB] 파일 경로 조회 시작...');
const query = `
SELECT "LocationFile"
FROM "TumerMovie"
WHERE "Index" = $1;
`;
const result = await client.query(query, [Index]);
if (result.rows.length === 0) {
throw error(404, `해당 Index(${Index})의 동영상을 찾을 수 없습니다.`);
}
filePath = result.rows[0].LocationFile;
console.log(`[2. DB] 조회된 파일 경로: ${filePath}`);
} catch (dbError) {
console.error('[DB 오류]', dbError);
throw error(500, '데이터베이스 조회 중 오류 발생');
} finally {
client.release();
}
try {
const stat = fs.statSync(filePath);
const fileSize = stat.size;
const contentType = getMimeType(filePath) || 'application/octet-stream'; // ✅ 안전한 MIME 판별
console.log(`[3. 파일 시스템] 파일 크기: ${fileSize}, Content-Type: ${contentType}`);
const range = request.headers.get('range');
if (!range) {
console.log('[4. Range 없음 → 전체 전송]');
const fullStream = fs.createReadStream(filePath);
return new Response(fullStream as any, {
status: 200,
headers: {
'Content-Type': contentType,
'Content-Length': fileSize.toString(),
'Accept-Ranges': 'bytes'
}
});
}
console.log(`[4. Range 요청 수신]: ${range}`);
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
if (isNaN(start) || isNaN(end) || start >= fileSize || end >= fileSize) {
console.error('[Range 오류] 잘못된 범위 요청:', range);
throw error(416, '잘못된 Range 요청');
}
const chunkSize = end - start + 1;
console.log(`[5. 스트리밍 구간] ${start} - ${end} (${chunkSize} bytes)`);
const fileStream = fs.createReadStream(filePath, { start, end });
return new Response(fileStream as any, {
status: 206,
headers: {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunkSize.toString(),
'Content-Type': contentType
}
});
} catch (fileError: any) {
if (fileError.code === 'ENOENT') {
console.error('[파일 오류] 파일 없음:', filePath);
throw error(404, '파일을 찾을 수 없습니다.');
}
console.error('[파일 시스템 오류]', fileError);
throw error(500, '파일 스트리밍 중 오류 발생');
}
};

View File

@ -0,0 +1,95 @@
import type { RequestHandler } from './$types';
import { error } from '@sveltejs/kit';
import pool from '$lib/server/database';
import fs from 'fs';
import path from 'path';
export const GET: RequestHandler = async ({ params }) => {
// [로그] 요청 수신 확인
console.log(`\n--- [video-stream] 요청 수신: ${new Date().toISOString()} ---`);
// [수정] params.id -> params.Index로 변경
const Index = params.Index;
console.log(`[1. 파라미터] 요청된 동영상 Index: ${Index}`);
if (!Index || isNaN(Number(Index))) {
console.error('[오류] Index가 유효하지 않습니다.');
throw error(400, '유효한 동영상 Index가 필요합니다.');
}
const client = await pool.connect();
let filePath: string; // DB에 저장된 컨테이너 경로
try {
// 1. DB에서 파일 경로 조회
console.log('[2. DB] 데이터베이스에서 파일 경로 조회를 시작합니다...');
const query = `
SELECT "LocationFile"
FROM "TumerMovie"
WHERE "Index" = $1 AND "MovieType" = 1;
`;
console.log(`[2. DB] 실행할 쿼리: SELECT "LocationFile" FROM "TumerMovie" WHERE "Index" = ${Index}`);
// [수정] id -> Index
const result = await client.query(query, [Index]);
if (result.rows.length === 0) {
// [수정] id -> Index
console.warn(`[2. DB] [Index: ${Index}]에 해당하는 동영상을 찾지 못했습니다.`);
throw error(404, `해당 Index(${Index})의 동영상을 찾을 수 없습니다.`);
}
filePath = result.rows[0].LocationFile;
console.log(`[2. DB] 조회된 컨테이너 경로: ${filePath}`);
} catch (dbError) {
console.error('[2. DB] [오류] 데이터베이스 조회 중 심각한 오류 발생:', dbError);
throw error(500, '데이터베이스 조회 중 오류 발생');
} finally {
client.release();
console.log('[2. DB] 데이터베이스 연결이 해제되었습니다.');
}
// [수정] 3. 경로 변환 로직 전체 삭제
try {
// 4. 파일 정보(stat) 확인 및 스트림 생성
// [수정] hostFilePath 대신 DB에서 가져온 filePath를 직접 사용합니다.
console.log(`[4. 파일 시스템] 컨테이너 내부 경로에서 파일 정보를 확인합니다: ${filePath}`);
const stat = fs.statSync(filePath);
const fileSize = stat.size;
const fileExtension = path.extname(filePath).toLowerCase();
console.log(`[4. 파일 시스템] 파일 크기: ${fileSize} bytes`);
let contentType = 'video/mp4'; // 기본값
if (fileExtension === '.webm') contentType = 'video/webm';
if (fileExtension === '.ogv') contentType = 'video/ogg';
console.log(`[4. 파일 시스템] Content-Type: ${contentType}`);
const fileStream = fs.createReadStream(filePath);
console.log('[5. 스트리밍] 파일 스트림을 생성하고 클라이언트로 전송을 시작합니다...');
// 5. 스트림 응답 반환
return new Response(fileStream as any, {
status: 200,
headers: {
'Content-Type': contentType,
'Content-Length': fileSize.toString(),
'Accept-Ranges': 'bytes'
}
});
} catch (fileError: any) {
// [오류] 파일 시스템 에러 처리
if (fileError.code === 'ENOENT') {
console.error(`[4. 파일 시스템] [오류] 파일을 찾을 수 없습니다 (ENOENT). 경로: ${filePath}`);
throw error(404, '파일 시스템에서 동영상을 찾을 수 없습니다.');
}
if (fileError.code === 'EACCES') {
console.error(`[4. 파일 시스템] [오류] 파일 접근 권한이 없습니다 (EACCES). 경로: ${filePath}`);
throw error(500, `파일에 접근할 권한이 없습니다 (EACCES): ${filePath}`);
}
console.error('[오류] 동영상 스트리밍 중 알 수 없는 파일 오류 발생:', fileError);
throw error(500, '동영상 스트리밍 중 오류 발생');
}
};

View File

@ -0,0 +1,24 @@
// src/routes/api/workers/+server.ts
import { json, error } from '@sveltejs/kit';
import pool from '$lib/server/database';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
console.log('[API] /api/workers: 작업자 목록 조회 요청 수신');
const client = await pool.connect();
try {
// Svelte 컴포넌트가 'id'와 'name'을 기대하므로 AS로 별칭을 지정합니다.
const result = await client.query(
'SELECT "workerid" AS id, "workername" AS name FROM public."Worker" ORDER BY "workerid" ASC'
);
console.log(`[API] /api/workers: ${result.rows.length}명의 작업자 조회 완료.`);
return json(result.rows); // [ { id: 1, name: '손상혁' }, { id: 2, name: '유훈동' } ]
} catch (err: any) {
console.error('[API] /api/workers: DB 조회 중 오류:', err);
throw error(500, '데이터베이스에서 작업자 목록을 가져오는 데 실패했습니다.');
} finally {
client.release();
}
};

View File

@ -1,3 +1,4 @@
// src/routes/page.svelte.spec.ts
import { page } from '@vitest/browser/context'; import { page } from '@vitest/browser/context';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { render } from 'vitest-browser-svelte'; import { render } from 'vitest-browser-svelte';

View File

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
<<<<<<< HEAD
// --- 상태 변수 선언 --- // --- 상태 변수 선언 ---
let videoURL: string = ''; let videoURL: string = '';
let videoElement: HTMLVideoElement; let videoElement: HTMLVideoElement;
@ -521,4 +522,668 @@
</div> </div>
{/if} {/if}
</div> </div>
</main> </main>
=======
// --- 상태 변수 선언 ---
let videoURL: string = '';
let videoElement: HTMLVideoElement;
let originalFileName: string = '';
let message: string = '동영상 파일을 선택하세요.';
// --- 시간 변수 (편집기용) ---
let startTime: number = 0;
let endTime: number = 0;
let startTimeStr: string = '00:00:00.000';
let endTimeStr: string = '00:00:00.000';
let stopAtTime: number | null = null; // 미리보기 자동 정지를 위한 변수
// --- 작업 진행 상태 변수 ---
let isUploadingOriginal: boolean = false;
let uploadProgress: number = 0;
let isDetectingCrop: boolean = false;
let isCutting: boolean = false;
let isCuttingSegments: boolean = false;
let isUploadingSegments: boolean = false;
let cropProgress: number = 0;
let currentProcessId: string | null = null;
let eventSource: EventSource | null = null;
// --- 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;
// --- 시간 포맷 변환 헬퍼 함수 ---
/** 초를 HH:MM:SS.sss 형식의 문자열로 변환합니다. */
const formatTime = (timeInSeconds: number): string => {
if (isNaN(timeInSeconds) || timeInSeconds < 0) return '00:00:00.000';
const pad = (num: number, size: number) => num.toString().padStart(size, '0');
const hours = Math.floor(timeInSeconds / 3600);
const minutes = Math.floor((timeInSeconds % 3600) / 60);
const seconds = Math.floor(timeInSeconds % 60);
const milliseconds = Math.round((timeInSeconds - Math.floor(timeInSeconds)) * 1000);
return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)}.${pad(milliseconds, 3)}`;
};
/** HH:MM:SS.sss 형식의 문자열을 초 단위 숫자로 변환합니다. */
const parseTime = (timeString: string): number => {
const regex = /^(\d{2}):(\d{2}):(\d{2})\.(\d{3})$/;
const match = timeString.match(regex);
if (!match) return 0;
const [, hours, minutes, seconds, milliseconds] = match.map(Number);
return hours * 3600 + minutes * 60 + seconds + milliseconds / 1000;
};
// --- 반응형 변수 및 로직 ---
$: allProcessed = segments.length > 0 && segments.every((s) => s.resultURL);
$: isLoading = isUploadingOriginal || isDetectingCrop || isCutting || isCuttingSegments || isUploadingSegments;
$: detectedCropParams = `${cropWidth}:${cropHeight}:${cropX}:${cropY}`;
$: segmentsReadyToUpload = segments.filter((s) => s.resultURL && !s.isUploaded).length > 0;
// 사용자가 입력 필드(startTimeStr, endTimeStr)를 수정하면
// 초 단위 시간(startTime, endTime)이 자동으로 업데이트됩니다.
$: startTime = parseTime(startTimeStr);
$: endTime = parseTime(endTimeStr);
// --- 핵심 기능 함수 ---
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 = () => {
if (!(cropWidth > 0 && cropHeight > 0) || isCutting) {
message = 'Crop 영역의 너비와 높이는 0보다 커야 합니다.';
return;
}
isCutting = true;
cropProgress = 0;
message = '서버에서 전체 동영상을 Crop하는 중...';
eventSource = new EventSource(
`/api/crop-progress?crop=${encodeURIComponent(detectedCropParams)}`
);
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.processId) {
currentProcessId = data.processId;
}
if (data.error) {
console.error('Crop Error:', data.error);
message = `오류 발생: ${data.error}`;
isCutting = false;
currentProcessId = null;
eventSource?.close();
eventSource = null;
return;
}
if (data.progress) {
cropProgress = data.progress;
message = `동영상 Crop 중... ${cropProgress}%`;
}
if (data.status === 'completed') {
message = '✅ Crop 적용이 완료되었습니다. 변경된 미리보기를 확인하세요.';
isCutting = false;
currentProcessId = null;
videoURL = `/api/video-preview?t=${Date.now()}`;
eventSource?.close();
eventSource = null;
}
};
eventSource.onerror = (error) => {
console.error('EventSource failed:', error);
if (!message.includes('취소')) {
message = '오류: 서버와 연결이 끊어졌습니다.';
}
isCutting = false;
currentProcessId = null;
eventSource?.close();
eventSource = null;
};
};
const handleCancelCrop = async () => {
if (!currentProcessId || !isCutting) return;
message = 'Crop 작업을 취소하는 중...';
try {
const response = await fetch('/api/cancel-process', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ processId: currentProcessId })
});
const result = await response.json();
if (result.success) {
message = '작업이 성공적으로 취소되었습니다.';
} else {
message = `취소 실패: ${result.error || '알 수 없는 오류'}`;
}
} catch (error) {
console.error('Failed to send cancellation request:', error);
message = '취소 요청 중 네트워크 오류가 발생했습니다.';
} finally {
isCutting = false;
currentProcessId = null;
eventSource?.close();
eventSource = null;
}
};
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;
}
isCuttingSegments = 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 {
isCuttingSegments = 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) {
// 숫자 시간(초) 대신, 포맷팅된 문자열 시간을 업데이트합니다.
// 그러면 반응형 로직(`$:`)에 의해 숫자 시간도 자동으로 변경됩니다.
startTimeStr = formatTime(videoElement.currentTime);
}
};
const setEndTime = () => {
if (videoElement) {
endTimeStr = formatTime(videoElement.currentTime);
}
};
const addSegment = () => {
// startTime과 endTime은 반응형 로직에 의해 항상 최신 상태입니다.
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();
}
});
};
/** 구간을 클릭하면 해당 구간을 재생합니다. */
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;
}
};
</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} />
<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={isCutting ? handleCancelCrop : handleExecuteCrop}
disabled={(isLoading && !isCutting) || (!isCutting && !(cropWidth > 0 && cropHeight > 0))}
class="mt-4 w-full text-center cursor-pointer rounded-md border px-4 py-2 text-sm font-medium shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
class:bg-white={!isCutting}
class:text-gray-700={!isCutting}
class:hover:bg-gray-50={!isCutting}
class:border-gray-300={!isCutting}
class:bg-red-600={isCutting}
class:text-white={isCutting}
class:hover:bg-red-700={isCutting}
class:border-transparent={isCutting}
>
{#if isCutting}
🔴 취소하기
{:else}
✅ Crop 적용하기
{/if}
</button>
{#if isCutting}
<div class="mt-2">
<progress class="w-full" max="100" value={cropProgress} />
<p class="text-right font-mono text-gray-700">{cropProgress.toFixed(0)}%</p>
</div>
{/if}
{/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-2 mb-4">
<div>
<label for="startTime" class="block text-sm font-medium text-gray-700">시작 시간 (HH:MM:SS.ms)</label>
<input
type="text"
id="startTime"
bind:value={startTimeStr}
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 font-mono"
placeholder="00:00:00.000"
/>
</div>
<div>
<label for="endTime" class="block text-sm font-medium text-gray-700">종료 시간 (HH:MM:SS.ms)</label>
<input
type="text"
id="endTime"
bind:value={endTimeStr}
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 font-mono"
placeholder="00:00:00.000"
/>
</div>
</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>
>>>>>>> 0bab142 (add login/logout)

View File

@ -0,0 +1,120 @@
<script lang="ts">
import { onMount } from 'svelte';
interface ProcessInfo {
id: string;
status: string;
progress: number;
started: string;
message?: string | null;
}
let processes: ProcessInfo[] = [];
let loading = true;
let error = '';
async function loadProcesses() {
try {
const res = await fetch('/api/crop-progress/list');
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
processes = data.items;
} catch (err) {
error = (err as Error).message;
} finally {
loading = false;
}
}
async function cancelProcess(id: string) {
if (!confirm(`Cancel process ${id}?`)) return;
await fetch(`/api/crop-progress/${id}`, { method: 'DELETE' });
await loadProcesses();
}
onMount(loadProcesses);
</script>
<!-- 전체 화면 그리드 -->
<div class="flex h-screen bg-gray-100">
<!-- 사이드바 -->
<aside class="w-64 bg-slate-800 text-white flex flex-col">
<div class="px-4 py-4 text-lg font-bold border-b border-slate-700">
BigData Server
</div>
<nav class="flex-1 p-2">
<a
href="/video-crop/list"
class="flex items-center gap-2 p-2 rounded hover:bg-slate-700 transition">
<span>🎬</span>
<span>FFmpeg Crop Progress List</span>
</a>
</nav>
</aside>
<!-- 콘텐츠 영역 -->
<main class="flex-1 p-6 overflow-auto">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">FFmpeg Crop Progress List</h1>
<button
on:click={loadProcesses}
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded">
새로고침
</button>
</div>
{#if loading}
<p>Loading process list...</p>
{:else if error}
<p class="text-red-600">Error: {error}</p>
{:else if processes.length === 0}
<p>현재 실행 중인 작업이 없습니다.</p>
{:else}
<table class="min-w-full border border-gray-300 text-sm bg-white rounded-lg shadow">
<thead class="bg-gray-100">
<tr>
<th class="px-3 py-2 text-left">ID</th>
<th class="px-3 py-2 text-left">Status</th>
<th class="px-3 py-2 text-center">Progress</th>
<th class="px-3 py-2 text-left">Started</th>
<th class="px-3 py-2 text-left">Message</th>
<th class="px-3 py-2 text-center">Actions</th>
</tr>
</thead>
<tbody>
{#each processes as proc}
<tr class="border-t hover:bg-gray-50">
<td class="px-3 py-2">{proc.id}</td>
<td class="px-3 py-2">
<span
class:!text-green-600={proc.status === 'completed'}
class:!text-red-600={proc.status === 'error' || proc.status === 'cancelled'}
class:!text-blue-600={proc.status === 'running'}>
{proc.status}
</span>
</td>
<td class="px-3 py-2 text-center">{proc.progress}%</td>
<td class="px-3 py-2">{new Date(proc.started).toLocaleString()}</td>
<td class="px-3 py-2">{proc.message ?? '-'}</td>
<td class="px-3 py-2 text-center">
{#if proc.status === 'running'}
<button
on:click={() => cancelProcess(proc.id)}
class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600 text-xs">
Cancel
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</main>
</div>
<style>
table {
border-collapse: collapse;
}
</style>

View File

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

View File

@ -0,0 +1,723 @@
<script lang="ts">
// --- 상태 변수 선언 ---
let videoURL: string = '';
let videoElement: HTMLVideoElement;
let originalFileName: string = '';
let message: string = '동영상 파일을 선택하세요.';
// --- 시간 변수 (편집기용) ---
let startTime: number = 0;
let endTime: number = 0;
let startTimeStr: string = '00:00:00.000';
let endTimeStr: string = '00:00:00.000';
let stopAtTime: number | null = null; // 미리보기 자동 정지를 위한 변수
// --- 작업 진행 상태 변수 ---
let isUploadingOriginal: boolean = false;
let uploadProgress: number = 0;
let isDetectingCrop: boolean = false;
let isCutting: boolean = false;
let isCuttingSegments: boolean = false;
let isUploadingSegments: boolean = false;
let cropProgress: number = 0;
let currentProcessId: string | null = null; // Crop 취소용 ID
// [수정됨] EventSource를 두 개로 분리
let eventSource: EventSource | null = null; // 구간 자르기(Segments) 용
let cropEventSource: EventSource | null = null; // 전체 Crop 용
// --- Crop 및 구간 정보 ---
let cropX: number = 0;
let cropY: number = 0;
let cropWidth: number = 0;
let cropHeight: number = 0;
// [수정됨] TimeSegment 인터페이스에 isCutting과 progress 추가
interface TimeSegment {
id: number;
start: number;
end: number;
resultURL?: string;
isUploading?: boolean;
isUploaded?: boolean;
isCutting?: boolean; // <-- 추가
progress?: number; // <-- 추가
}
let segments: TimeSegment[] = [];
let nextSegmentId = 0;
// --- 시간 포맷 변환 헬퍼 함수 ---
const formatTime = (timeInSeconds: number): string => {
if (isNaN(timeInSeconds) || timeInSeconds < 0) return '00:00:00.000';
const pad = (num: number, size: number) => num.toString().padStart(size, '0');
const hours = Math.floor(timeInSeconds / 3600);
const minutes = Math.floor((timeInSeconds % 3600) / 60);
const seconds = Math.floor(timeInSeconds % 60);
const milliseconds = Math.round((timeInSeconds - Math.floor(timeInSeconds)) * 1000);
return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)}.${pad(milliseconds, 3)}`;
};
const parseTime = (timeString: string): number => {
const regex = /^(\d{2}):(\d{2}):(\d{2})\.(\d{3})$/;
const match = timeString.match(regex);
if (!match) return 0;
const [, hours, minutes, seconds, milliseconds] = match.map(Number);
return hours * 3600 + minutes * 60 + seconds + milliseconds / 1000;
};
// --- 반응형 변수 및 로직 ---
$: allProcessed = segments.length > 0 && segments.every((s) => s.resultURL);
$: isLoading = isUploadingOriginal || isDetectingCrop || isCutting || isCuttingSegments || isUploadingSegments;
$: detectedCropParams = `${cropWidth}:${cropHeight}:${cropX}:${cropY}`;
$: segmentsReadyToUpload = segments.filter((s) => s.resultURL && !s.isUploaded).length > 0;
$: startTime = parseTime(startTimeStr);
$: endTime = parseTime(endTimeStr);
// --- 핵심 기능 함수 ---
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);
});
};
// [수정됨] cropEventSource 변수 사용
const handleExecuteCrop = () => {
if (!(cropWidth > 0 && cropHeight > 0) || isCutting) {
message = 'Crop 영역의 너비와 높이는 0보다 커야 합니다.';
return;
}
isCutting = true;
cropProgress = 0;
message = '서버에서 전체 동영상을 Crop하는 중...';
cropEventSource = new EventSource( // <-- cropEventSource 사용
`/api/crop-progress?crop=${encodeURIComponent(detectedCropParams)}`
);
cropEventSource.onmessage = (event) => { // <-- cropEventSource 사용
const data = JSON.parse(event.data);
if (data.processId) {
currentProcessId = data.processId;
}
if (data.error) {
console.error('Crop Error:', data.error);
message = `오류 발생: ${data.error}`;
isCutting = false;
currentProcessId = null;
cropEventSource?.close(); // <-- cropEventSource 사용
cropEventSource = null;
return;
}
if (data.progress) {
cropProgress = data.progress;
message = `동영상 Crop 중... ${cropProgress.toFixed(0)}%`;
}
if (data.status === 'completed') {
setTimeout(() => {
message = '✅ Crop 적용이 완료되었습니다. 변경된 미리보기를 확인하세요.';
isCutting = false;
currentProcessId = null;
videoURL = `/api/video-preview?t=${Date.now()}`; // URL 갱신
cropEventSource?.close();
cropEventSource = null;
}, 500); // 0.2초 딜레이
/*
message = '✅ Crop 적용이 완료되었습니다. 변경된 미리보기를 확인하세요.';
isCutting = false;
currentProcessId = null;
videoURL = `/api/video-preview?t=${Date.now()}`; // 미리보기 새로고침
cropEventSource?.close(); // <-- cropEventSource 사용
cropEventSource = null;
*/
}
};
cropEventSource.onerror = (error) => { // <-- cropEventSource 사용
console.error('EventSource failed:', error);
if (!message.includes('취소')) {
message = '오류: 서버와 연결이 끊어졌습니다.';
}
isCutting = false;
currentProcessId = null;
cropEventSource?.close(); // <-- cropEventSource 사용
cropEventSource = null;
};
};
// [수정됨] cropEventSource 변수 사용
const handleCancelCrop = async () => {
if (!currentProcessId || !isCutting) return;
message = 'Crop 작업을 취소하는 중...';
try {
const response = await fetch('/api/cancel-process', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ processId: currentProcessId })
});
const result = await response.json();
if (result.success) {
message = '작업이 성공적으로 취소되었습니다.';
} else {
message = `취소 실패: ${result.error || '알 수 없는 오류'}`;
}
} catch (error) {
console.error('Failed to send cancellation request:', error);
message = '취소 요청 중 네트워크 오류가 발생했습니다.';
} finally {
isCutting = false;
currentProcessId = null;
cropEventSource?.close(); // <-- cropEventSource 사용
cropEventSource = null;
}
};
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;
}
}
};
// [수정됨] cutAllSegments를 SSE 프로그래스 버전으로 교체
const cutAllSegments = async () => {
if (segments.length === 0) {
message = '먼저 구간을 추가해주세요.';
return;
}
isCuttingSegments = true;
segments = segments.map((s) => ({ ...s, isCutting: true, progress: 0, resultURL: undefined }));
message = '서버에서 동영상 잘라내기 작업을 시작합니다...';
const segmentsData = JSON.stringify(segments.map((s) => ({ id: s.id, start: s.start, end: s.end })));
const cropParams = cropWidth > 0 && cropHeight > 0 ? detectedCropParams : '';
const url = `/api/cut-segments-progress?segments=${encodeURIComponent(
segmentsData
)}&crop=${encodeURIComponent(cropParams)}`;
eventSource = new EventSource(url); // <-- eventSource (구간용) 사용
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'progress') {
segments = segments.map((s) =>
s.id === data.segmentId ? { ...s, progress: data.progress } : s
);
message = `[${data.segmentId}번 구간] 자르는 중... ${data.progress.toFixed(0)}%`;
}
if (data.type === 'done') {
segments = segments.map((s) =>
s.id === data.segmentId
? { ...s, isCutting: false, progress: 100, resultURL: data.url }
: s
);
}
if (data.type === 'all_complete') {
message = '✅ 모든 구간 잘라내기 완료!';
isCuttingSegments = false;
eventSource.close();
eventSource = null;
}
if (data.type === 'error') {
message = `오류 발생: ${data.error}`;
console.error('Cutting Error:', data.error);
isCuttingSegments = false;
segments = segments.map((s) => ({ ...s, isCutting: false, progress: 0 }));
eventSource.close();
eventSource = null;
}
};
eventSource.onerror = (error) => {
console.error('EventSource failed:', error);
message = '오류: 서버와 연결이 끊어졌습니다.';
isCuttingSegments = false;
segments = segments.map((s) => ({ ...s, isCutting: false, progress: 0 }));
if (eventSource) {
eventSource.close();
eventSource = null;
}
};
};
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) {
startTimeStr = formatTime(videoElement.currentTime);
}
};
const setEndTime = () => {
if (videoElement) {
endTimeStr = formatTime(videoElement.currentTime);
}
};
// [수정됨] addSegment에 isCutting, progress 초기화 추가
const addSegment = () => {
if (startTime >= endTime) {
alert('시작 시간은 종료 시간보다 빨라야 합니다.');
return;
}
segments = [
...segments,
{
id: nextSegmentId++,
start: startTime,
end: endTime,
isUploaded: false,
isUploading: false,
isCutting: false, // <-- 추가
progress: 0 // <-- 추가
}
];
};
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();
}
});
};
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;
}
};
</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} />
<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={isCutting ? handleCancelCrop : handleExecuteCrop}
disabled={(isLoading && !isCutting) ||
(!isCutting && !(cropWidth > 0 && cropHeight > 0))}
class="mt-4 w-full text-center cursor-pointer rounded-md border px-4 py-2 text-sm font-medium shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
class:bg-white={!isCutting}
class:text-gray-700={!isCutting}
class:hover:bg-gray-50={!isCutting}
class:border-gray-300={!isCutting}
class:bg-red-600={isCutting}
class:text-white={isCutting}
class:hover:bg-red-700={isCutting}
class:border-transparent={isCutting}
>
{#if isCutting}
🔴 취소하기
{:else}
✅ Crop 적용하기
{/if}
</button>
{#if isCutting}
<div class="mt-2">
<progress class="w-full" max="100" value={cropProgress} />
<p class="text-right font-mono text-gray-700">{cropProgress.toFixed(0)}%</p>
</div>
{/if}
{/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-2 mb-4">
<div>
<label for="startTime" class="block text-sm font-medium text-gray-700"
>시작 시간 (HH:MM:SS.ms)</label
>
<input
type="text"
id="startTime"
bind:value={startTimeStr}
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 font-mono"
placeholder="00:00:00.000"
/>
</div>
<div>
<label for="endTime" class="block text-sm font-medium text-gray-700"
>종료 시간 (HH:MM:SS.ms)</label
>
<input
type="text"
id="endTime"
bind:value={endTimeStr}
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 font-mono"
placeholder="00:00:00.000"
/>
</div>
</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 transition-colors"
class:bg-green-50={segment.isUploaded}
class:bg-blue-50={segment.isUploading}
class:bg-indigo-50={segment.isCutting}
>
<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="클릭하여 구간 미리보기"
>
{formatTime(segment.start)} ~ {formatTime(segment.end)}
</p>
{#if segment.isCutting}
<div class="mt-2">
<progress
class="w-full h-2 rounded-full overflow-hidden"
max="100"
value={segment.progress || 0}
></progress>
<p class="text-xs text-right font-mono text-gray-600">
{(segment.progress || 0).toFixed(0)}%
</p>
</div>
{:else if segment.resultURL}
<div class="text-sm mt-1 flex items-center gap-4">
<a
href={segment.resultURL}
download={`${
originalFileName.split('.').slice(0, -1).join('.')
}_cut-${segment.id}.mp4`}
class="text-indigo-600 hover:underline">다운로드</a
>
{#if segment.isUploading}
<span class="text-gray-500 animate-pulse">업로드 중...</span>
{:else if segment.isUploaded}
<span class="font-medium text-green-600">✓ 업로드 완료</span>
{:else}
<button
on:click={() => uploadSegment(segment.id)}
class="text-blue-600 hover:underline"
disabled={isLoading}>업로드</button
>
{/if}
</div>
{/if}
</div>
<button
on:click={() => removeSegment(segment.id)}
class="text-red-500 hover:text-red-700 font-bold px-2 ml-2 self-start"
>X</button
>
</div>
{:else}
<p class="text-gray-500 text-center py-4">추가된 구간이 없습니다.</p>
{/each}
</div>
<div class="mt-auto grid grid-cols-1 md:grid-cols-3 gap-2">
<button
on:click={cutAllSegments}
disabled={isLoading || segments.length === 0}
class="w-full rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 disabled:opacity-50"
>
✂️ 전체 잘라내기
</button>
<button
on:click={uploadAllSegments}
disabled={isLoading || !segmentsReadyToUpload}
class="w-full rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 disabled:opacity-50"
>
📤 전체 업로드
</button>
<button
on:click={downloadAllSegments}
disabled={isLoading || !allProcessed}
class="w-full rounded-md border border-transparent bg-green-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-green-700 disabled:opacity-50"
>
📦 전체 다운로드
</button>
</div>
</section>
</div>
{/if}
</div>
</main>

View File

@ -0,0 +1,120 @@
<script lang="ts">
import { onMount } from 'svelte';
interface ProcessInfo {
id: string;
status: string;
progress: number;
started: string;
message?: string | null;
}
let processes: ProcessInfo[] = [];
let loading = true;
let error = '';
async function loadProcesses() {
try {
const res = await fetch('/api/crop-progress/list');
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
processes = data.items;
} catch (err) {
error = (err as Error).message;
} finally {
loading = false;
}
}
async function cancelProcess(id: string) {
if (!confirm(`Cancel process ${id}?`)) return;
await fetch(`/api/crop-progress/${id}`, { method: 'DELETE' });
await loadProcesses();
}
onMount(loadProcesses);
</script>
<!-- 전체 화면 그리드 -->
<div class="flex h-screen bg-gray-100">
<!-- 사이드바 -->
<aside class="w-64 bg-slate-800 text-white flex flex-col">
<div class="px-4 py-4 text-lg font-bold border-b border-slate-700">
BigData Server
</div>
<nav class="flex-1 p-2">
<a
href="/video-crop/list"
class="flex items-center gap-2 p-2 rounded hover:bg-slate-700 transition">
<span>🎬</span>
<span>FFmpeg Crop Progress List</span>
</a>
</nav>
</aside>
<!-- 콘텐츠 영역 -->
<main class="flex-1 p-6 overflow-auto">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">FFmpeg Crop Progress List</h1>
<button
on:click={loadProcesses}
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded">
새로고침
</button>
</div>
{#if loading}
<p>Loading process list...</p>
{:else if error}
<p class="text-red-600">Error: {error}</p>
{:else if processes.length === 0}
<p>현재 실행 중인 작업이 없습니다.</p>
{:else}
<table class="min-w-full border border-gray-300 text-sm bg-white rounded-lg shadow">
<thead class="bg-gray-100">
<tr>
<th class="px-3 py-2 text-left">ID</th>
<th class="px-3 py-2 text-left">Status</th>
<th class="px-3 py-2 text-center">Progress</th>
<th class="px-3 py-2 text-left">Started</th>
<th class="px-3 py-2 text-left">Message</th>
<th class="px-3 py-2 text-center">Actions</th>
</tr>
</thead>
<tbody>
{#each processes as proc}
<tr class="border-t hover:bg-gray-50">
<td class="px-3 py-2">{proc.id}</td>
<td class="px-3 py-2">
<span
class:!text-green-600={proc.status === 'completed'}
class:!text-red-600={proc.status === 'error' || proc.status === 'cancelled'}
class:!text-blue-600={proc.status === 'running'}>
{proc.status}
</span>
</td>
<td class="px-3 py-2 text-center">{proc.progress}%</td>
<td class="px-3 py-2">{new Date(proc.started).toLocaleString()}</td>
<td class="px-3 py-2">{proc.message ?? '-'}</td>
<td class="px-3 py-2 text-center">
{#if proc.status === 'running'}
<button
on:click={() => cancelProcess(proc.id)}
class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600 text-xs">
Cancel
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</main>
</div>
<style>
table {
border-collapse: collapse;
}
</style>

BIN
static/ffmpeg-core.wasm Executable file

Binary file not shown.