add login/logout
This commit is contained in:
parent
8e7e06c4f1
commit
868dde7d94
10
.env.example
Executable file
10
.env.example
Executable 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
0
.gitignore
vendored
Normal file → Executable file
9
.prettierignore
Executable file
9
.prettierignore
Executable 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
16
.prettierrc
Executable 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
13
.vscode/launch.json
vendored
Executable 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
6
.vscode/settings.json
vendored
Executable file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"files.associations": {
|
||||
"string": "cpp",
|
||||
"cstdlib": "cpp"
|
||||
}
|
||||
}
|
||||
14
.vscode/tasks.json
vendored
Executable file
14
.vscode/tasks.json
vendored
Executable 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
197
package-lock.json
generated
@ -11,7 +11,15 @@
|
||||
"@ffmpeg/core": "^0.12.10",
|
||||
"@ffmpeg/ffmpeg": "^0.12.15",
|
||||
"@ffmpeg/util": "^0.12.2",
|
||||
<<<<<<< HEAD
|
||||
"canvas": "^3.2.0",
|
||||
=======
|
||||
"bcrypt": "^6.0.0",
|
||||
"canvas": "^3.2.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mime": "^4.1.0",
|
||||
"mime-types": "^3.0.1",
|
||||
>>>>>>> 0bab142 (add login/logout)
|
||||
"pg": "^8.16.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -2439,6 +2447,32 @@
|
||||
],
|
||||
"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": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
@ -2498,6 +2532,15 @@
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
>>>>>>> 0bab142 (add login/logout)
|
||||
"node_modules/cac": {
|
||||
"version": "6.7.14",
|
||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||
@ -2786,6 +2829,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
>>>>>>> 0bab142 (add login/logout)
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
@ -3558,6 +3613,49 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@ -3871,6 +3969,45 @@
|
||||
"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": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
@ -3878,6 +4015,12 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||
@ -3942,6 +4085,45 @@
|
||||
"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": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
@ -4039,7 +4221,6 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
@ -4092,6 +4273,20 @@
|
||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
"node_modules/node-gyp-build": {
|
||||
"version": "4.8.4",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||
"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": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
|
||||
@ -51,7 +51,15 @@
|
||||
"@ffmpeg/core": "^0.12.10",
|
||||
"@ffmpeg/ffmpeg": "^0.12.15",
|
||||
"@ffmpeg/util": "^0.12.2",
|
||||
<<<<<<< HEAD
|
||||
"canvas": "^3.2.0",
|
||||
=======
|
||||
"bcrypt": "^6.0.0",
|
||||
"canvas": "^3.2.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mime": "^4.1.0",
|
||||
"mime-types": "^3.0.1",
|
||||
>>>>>>> 0bab142 (add login/logout)
|
||||
"pg": "^8.16.3"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
// src/hooks.server.ts
|
||||
<<<<<<< HEAD
|
||||
|
||||
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-Embedder-Policy', 'require-corp');
|
||||
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)
|
||||
|
||||
24
src/lib/server/processes.ts
Normal file
24
src/lib/server/processes.ts
Normal 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
4
src/lib/stores/user.ts
Normal 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
11
src/lib/utils.ts
Normal 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
|
||||
};
|
||||
|
||||
// 여기에 다른 공통 유틸리티 함수들을 추가할 수 있습니다.
|
||||
19
src/routes/(protected)/(admin)/+layout.server.ts
Normal file
19
src/routes/(protected)/(admin)/+layout.server.ts
Normal 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 };
|
||||
};
|
||||
677
src/routes/(protected)/(admin)/image-generate/+page.svelte
Normal file
677
src/routes/(protected)/(admin)/image-generate/+page.svelte
Normal 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>
|
||||
18
src/routes/(protected)/(admin)/register/+page.server.ts
Normal file
18
src/routes/(protected)/(admin)/register/+page.server.ts
Normal 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 {};
|
||||
};
|
||||
81
src/routes/(protected)/(admin)/register/+page.svelte
Normal file
81
src/routes/(protected)/(admin)/register/+page.svelte
Normal 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>
|
||||
48
src/routes/(protected)/(admin)/register/+server.ts
Normal file
48
src/routes/(protected)/(admin)/register/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
515
src/routes/(protected)/(admin)/video-crop/+page.svelte
Normal file
515
src/routes/(protected)/(admin)/video-crop/+page.svelte
Normal 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>
|
||||
378
src/routes/(protected)/(admin)/video-manage/+page.svelte
Normal file
378
src/routes/(protected)/(admin)/video-manage/+page.svelte
Normal 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>
|
||||
|
||||
19
src/routes/(protected)/+layout.server.ts
Normal file
19
src/routes/(protected)/+layout.server.ts
Normal 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 };
|
||||
};
|
||||
931
src/routes/(protected)/bigdata-generate/+page.svelte
Executable file
931
src/routes/(protected)/bigdata-generate/+page.svelte
Executable 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>
|
||||
10
src/routes/(protected)/dashboard/+page.svelte
Normal file
10
src/routes/(protected)/dashboard/+page.svelte
Normal 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>
|
||||
55
src/routes/(public)/+page.svelte
Normal file
55
src/routes/(public)/+page.svelte
Normal 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}
|
||||
96
src/routes/(public)/login/+page.svelte
Normal file
96
src/routes/(public)/login/+page.svelte
Normal 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>
|
||||
|
||||
58
src/routes/(public)/login/+server.ts
Normal file
58
src/routes/(public)/login/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
65
src/routes/+layout.server.ts
Normal file
65
src/routes/+layout.server.ts
Normal 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: [] };
|
||||
}
|
||||
};
|
||||
@ -1,7 +1,45 @@
|
||||
<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>
|
||||
|
||||
<<<<<<< HEAD
|
||||
<div class="flex flex-col h-svh font-sans bg-gray-100">
|
||||
<header class="flex-shrink-0 bg-gray-800 text-white shadow-md z-10">
|
||||
<div class="container mx-auto px-4">
|
||||
@ -12,4 +50,48 @@
|
||||
<main class="flex-grow flex overflow-hidden min-h-0">
|
||||
<slot />
|
||||
</main>
|
||||
=======
|
||||
<div class="flex flex-col h-screen">
|
||||
<header
|
||||
class="w-full bg-slate-800 text-white p-3 flex justify-between items-center shadow-md z-10"
|
||||
>
|
||||
<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>
|
||||
41
src/routes/api/assign-worker/+server.ts
Normal file
41
src/routes/api/assign-worker/+server.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
32
src/routes/api/cancel-process/+server.ts
Normal file
32
src/routes/api/cancel-process/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
134
src/routes/api/crop-progress/+server.ts
Normal file
134
src/routes/api/crop-progress/+server.ts
Normal 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'
|
||||
}
|
||||
});
|
||||
};
|
||||
34
src/routes/api/crop-progress/[id]/+server.ts
Normal file
34
src/routes/api/crop-progress/[id]/+server.ts
Normal 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' }
|
||||
});
|
||||
}
|
||||
};
|
||||
17
src/routes/api/crop-progress/list/+server.ts
Normal file
17
src/routes/api/crop-progress/list/+server.ts
Normal 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' }
|
||||
});
|
||||
};
|
||||
178
src/routes/api/crop-progress_v1/+server.ts
Normal file
178
src/routes/api/crop-progress_v1/+server.ts
Normal 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'
|
||||
}
|
||||
});
|
||||
};
|
||||
383
src/routes/api/cut-segments-progress/+server.ts
Normal file
383
src/routes/api/cut-segments-progress/+server.ts
Normal 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'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
103
src/routes/api/delete-cut-video/+server.ts
Normal file
103
src/routes/api/delete-cut-video/+server.ts
Normal 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는 삭제되었을 수 있습니다)`);
|
||||
}
|
||||
};
|
||||
117
src/routes/api/delete-video-recursive/+server.ts
Normal file
117
src/routes/api/delete-video-recursive/+server.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
|
||||
102
src/routes/api/delete-video/+server.ts
Normal file
102
src/routes/api/delete-video/+server.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
31
src/routes/api/get-cropped-videos/+server.ts
Normal file
31
src/routes/api/get-cropped-videos/+server.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
@ -7,16 +7,36 @@ const IMAGE_BASE_DIRECTORY = '/workspace';
|
||||
|
||||
export const GET: RequestHandler = ({ params }) => {
|
||||
const imagePath = params.path;
|
||||
|
||||
// 보안 검사: 상위 디렉토리 접근 방지
|
||||
if (imagePath.includes('..')) {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
const fullPath = path.join(IMAGE_BASE_DIRECTORY, imagePath);
|
||||
|
||||
// [수정] 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)) {
|
||||
console.error(`[Image Server] File not found at resolved path: ${fullPath}`);
|
||||
throw error(404, 'Not Found');
|
||||
}
|
||||
|
||||
try {
|
||||
const fileBuffer = fs.readFileSync(fullPath);
|
||||
const fileExtension = path.extname(fullPath).toLowerCase();
|
||||
|
||||
let mimeType = 'application/octet-stream';
|
||||
switch (fileExtension) {
|
||||
case '.png': mimeType = 'image/png'; break;
|
||||
@ -24,6 +44,7 @@ export const GET: RequestHandler = ({ params }) => {
|
||||
case '.bmp': mimeType = 'image/bmp'; break;
|
||||
case '.gif': mimeType = 'image/gif'; break;
|
||||
}
|
||||
|
||||
return new Response(fileBuffer, {
|
||||
headers: {
|
||||
'Content-Type': mimeType,
|
||||
|
||||
47
src/routes/api/login/+server.ts
Normal file
47
src/routes/api/login/+server.ts
Normal 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
|
||||
}
|
||||
});
|
||||
};
|
||||
18
src/routes/api/logout/+server.ts
Normal file
18
src/routes/api/logout/+server.ts
Normal 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 });
|
||||
};
|
||||
|
||||
17
src/routes/api/me/+server.ts
Normal file
17
src/routes/api/me/+server.ts
Normal 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, '유효하지 않은 토큰');
|
||||
}
|
||||
};
|
||||
@ -1,8 +1,15 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
<<<<<<< HEAD
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { exec } from 'child_process';
|
||||
import util from 'util';
|
||||
=======
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { exec } from 'child_process';
|
||||
import * as util from 'util';
|
||||
>>>>>>> 0bab142 (add login/logout)
|
||||
import { createCanvas, loadImage } from 'canvas';
|
||||
|
||||
const execPromise = util.promisify(exec);
|
||||
|
||||
@ -7,7 +7,7 @@ export const PUT: RequestHandler = async ({ params, request }) => {
|
||||
const { id } = params;
|
||||
const { x, y, width, height } = await request.json();
|
||||
|
||||
const tumerPosition = [x, y, x + width, y + height];
|
||||
const tumerPosition = [Math.round(x), Math.round(y), Math.round(x + width), Math.round(y + height)];
|
||||
|
||||
try {
|
||||
await pool.query(
|
||||
|
||||
@ -30,7 +30,8 @@ export const POST: RequestHandler = async ({ params, request }) => {
|
||||
const { refIndex } = params;
|
||||
const { x, y, width, height } = await request.json();
|
||||
|
||||
const tumerPosition = [x, y, x + width, y + height];
|
||||
const tumerPosition = [Math.round(x), Math.round(y), Math.round(x + width), Math.round(y + height)];
|
||||
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
|
||||
24
src/routes/api/register/+server.ts
Normal file
24
src/routes/api/register/+server.ts
Normal 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 오류입니다.');
|
||||
}
|
||||
};
|
||||
285
src/routes/api/save-crop-video/+server.ts
Normal file
285
src/routes/api/save-crop-video/+server.ts
Normal 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'
|
||||
}
|
||||
});
|
||||
};
|
||||
@ -1,3 +1,4 @@
|
||||
<<<<<<< HEAD
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
@ -36,4 +37,133 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
console.error('파일 업로드 처리 중 오류:', error);
|
||||
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)
|
||||
};
|
||||
39
src/routes/api/upload-cut/server.ts.ori
Normal file
39
src/routes/api/upload-cut/server.ts.ori
Normal 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 });
|
||||
}
|
||||
};
|
||||
@ -1,8 +1,13 @@
|
||||
// src/routes/api/upload-original/+server.ts
|
||||
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
<<<<<<< HEAD
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
=======
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
>>>>>>> 0bab142 (add login/logout)
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
try {
|
||||
|
||||
36
src/routes/api/video-lists/+server.ts
Normal file
36
src/routes/api/video-lists/+server.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
@ -1,3 +1,4 @@
|
||||
<<<<<<< HEAD
|
||||
import { type RequestHandler } from '@sveltejs/kit';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
@ -25,4 +26,63 @@ export const GET: RequestHandler = async ({ setHeaders }) => {
|
||||
} catch (error) {
|
||||
return new Response('영상을 찾을 수 없습니다.', { status: 404 });
|
||||
}
|
||||
=======
|
||||
// ✅ 수정된 /api/video-preview/+server.ts (SvelteKit 2.x / Kit 1.x 호환)
|
||||
import { error } from '@sveltejs/kit';
|
||||
import * as fs from 'fs';
|
||||
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)
|
||||
};
|
||||
28
src/routes/api/video-preview/server_ori.ts
Normal file
28
src/routes/api/video-preview/server_ori.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { type RequestHandler } from '@sveltejs/kit';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const stat = promisify(fs.stat);
|
||||
|
||||
export const GET: RequestHandler = async ({ setHeaders }) => {
|
||||
const videoPath = path.join(process.cwd(), 'tmp', 'process.mp4');
|
||||
|
||||
try {
|
||||
const stats = await stat(videoPath);
|
||||
const videoSize = stats.size;
|
||||
|
||||
// 브라우저가 영상을 스트리밍할 수 있도록 헤더 설정
|
||||
setHeaders({
|
||||
'Content-Type': 'video/mp4',
|
||||
'Content-Length': videoSize.toString(),
|
||||
'Accept-Ranges': 'bytes'
|
||||
});
|
||||
|
||||
const videoStream = fs.createReadStream(videoPath);
|
||||
return new Response(videoStream);
|
||||
|
||||
} catch (error) {
|
||||
return new Response('영상을 찾을 수 없습니다.', { status: 404 });
|
||||
}
|
||||
};
|
||||
94
src/routes/api/video-stream/[Index]/+server.ts
Normal file
94
src/routes/api/video-stream/[Index]/+server.ts
Normal 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, '파일 스트리밍 중 오류 발생');
|
||||
}
|
||||
};
|
||||
95
src/routes/api/video-stream_v1/[Index]/+server.ts
Normal file
95
src/routes/api/video-stream_v1/[Index]/+server.ts
Normal 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, '동영상 스트리밍 중 오류 발생');
|
||||
}
|
||||
};
|
||||
24
src/routes/api/workers/+server.ts
Normal file
24
src/routes/api/workers/+server.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
@ -1,3 +1,4 @@
|
||||
// src/routes/page.svelte.spec.ts
|
||||
import { page } from '@vitest/browser/context';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
<<<<<<< HEAD
|
||||
// --- 상태 변수 선언 ---
|
||||
let videoURL: string = '';
|
||||
let videoElement: HTMLVideoElement;
|
||||
@ -521,4 +522,668 @@
|
||||
</div>
|
||||
{/if}
|
||||
</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)
|
||||
|
||||
120
src/routes/video-cut/list/+page.svelte
Normal file
120
src/routes/video-cut/list/+page.svelte
Normal 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>
|
||||
524
src/routes/video-cut_v2/+page.svelte
Executable file
524
src/routes/video-cut_v2/+page.svelte
Executable file
@ -0,0 +1,524 @@
|
||||
<script lang="ts">
|
||||
// --- 상태 변수 선언 ---
|
||||
let videoURL: string = '';
|
||||
let videoElement: HTMLVideoElement;
|
||||
let originalFileName: string = '';
|
||||
let message: string = '동영상 파일을 선택하세요.';
|
||||
let startTime: number = 0;
|
||||
let endTime: number = 0;
|
||||
let stopAtTime: number | null = null; // 미리보기 자동 정지를 위한 변수
|
||||
|
||||
// --- 작업 진행 상태 변수 ---
|
||||
let isUploadingOriginal: boolean = false;
|
||||
let uploadProgress: number = 0;
|
||||
let isDetectingCrop: boolean = false;
|
||||
let isCutting: boolean = false;
|
||||
let isUploadingSegments: boolean = false;
|
||||
|
||||
// --- Crop 및 구간 정보 ---
|
||||
let cropX: number = 0;
|
||||
let cropY: number = 0;
|
||||
let cropWidth: number = 0;
|
||||
let cropHeight: number = 0;
|
||||
|
||||
interface TimeSegment {
|
||||
id: number;
|
||||
start: number;
|
||||
end: number;
|
||||
resultURL?: string;
|
||||
isUploading?: boolean;
|
||||
isUploaded?: boolean;
|
||||
}
|
||||
let segments: TimeSegment[] = [];
|
||||
let nextSegmentId = 0;
|
||||
|
||||
// --- 반응형 변수 ---
|
||||
$: allProcessed = segments.length > 0 && segments.every((s) => s.resultURL);
|
||||
$: isLoading = isUploadingOriginal || isDetectingCrop || isCutting || isUploadingSegments;
|
||||
$: detectedCropParams = `${cropWidth}:${cropHeight}:${cropX}:${cropY}`;
|
||||
$: segmentsReadyToUpload = segments.filter((s) => s.resultURL && !s.isUploaded).length > 0;
|
||||
|
||||
// --- 핵심 기능 함수 ---
|
||||
const uploadOriginalVideo = async (file: File): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
isUploadingOriginal = true;
|
||||
uploadProgress = 0;
|
||||
message = '원본 동영상을 서버로 업로드하는 중...';
|
||||
const formData = new FormData();
|
||||
formData.append('video', file);
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) {
|
||||
uploadProgress = (event.loaded / event.total) * 100;
|
||||
}
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
isUploadingOriginal = false;
|
||||
resolve();
|
||||
} else {
|
||||
isUploadingOriginal = false;
|
||||
message = '원본 파일 업로드 중 오류가 발생했습니다.';
|
||||
reject(new Error(xhr.statusText));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
isUploadingOriginal = false;
|
||||
message = '원본 파일 업로드 중 네트워크 오류가 발생했습니다.';
|
||||
reject(new Error('네트워크 오류'));
|
||||
};
|
||||
xhr.open('POST', '/api/upload-original', true);
|
||||
xhr.send(formData);
|
||||
});
|
||||
};
|
||||
|
||||
const handleExecuteCrop = async () => {
|
||||
if (!(cropWidth > 0 && cropHeight > 0)) {
|
||||
message = 'Crop 영역의 너비와 높이는 0보다 커야 합니다.';
|
||||
return;
|
||||
}
|
||||
isCutting = true;
|
||||
message = '서버에서 전체 동영상을 Crop하는 중...';
|
||||
try {
|
||||
const response = await fetch('/api/process-video', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'crop-full-video', cropParams: detectedCropParams })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorResult = await response.json();
|
||||
throw new Error(errorResult.error || 'Crop 작업에 실패했습니다.');
|
||||
}
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
videoURL = `/api/video-preview?t=${Date.now()}`;
|
||||
message = '✅ Crop 적용이 완료되었습니다. 변경된 미리보기를 확인하세요.';
|
||||
} else {
|
||||
throw new Error(result.message || '서버에서 Crop 작업을 완료하지 못했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Crop 적용 중 오류:', error);
|
||||
message = `Crop 적용 중 오류가 발생했습니다: ${
|
||||
error instanceof Error ? error.message : '알 수 없는 오류'
|
||||
}`;
|
||||
} finally {
|
||||
isCutting = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = async (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
|
||||
if (file) {
|
||||
videoURL = URL.createObjectURL(file);
|
||||
originalFileName = file.name;
|
||||
segments = [];
|
||||
cropX = 0;
|
||||
cropY = 0;
|
||||
cropWidth = 0;
|
||||
cropHeight = 0;
|
||||
try {
|
||||
await uploadOriginalVideo(file);
|
||||
message = '서버에서 최적의 Crop 영역을 계산하는 중...';
|
||||
isDetectingCrop = true;
|
||||
const response = await fetch('/api/process-video', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'detect-crop' })
|
||||
});
|
||||
if (!response.ok) throw new Error('Crop 영역 계산 요청 실패');
|
||||
const result = await response.json();
|
||||
if (result.success && result.cropParams) {
|
||||
const [width, height, x, y] = result.cropParams.split(':').map(Number);
|
||||
cropWidth = width;
|
||||
cropHeight = height;
|
||||
cropX = x;
|
||||
cropY = y;
|
||||
message = '자동 Crop 영역 계산 완료!';
|
||||
} else {
|
||||
message = '자동 Crop 영역을 찾지 못했습니다. 수동으로 잘라내기를 진행합니다.';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('처리 중 오류:', error);
|
||||
message = '오류가 발생했습니다. 콘솔을 확인해주세요.';
|
||||
} finally {
|
||||
isDetectingCrop = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cutAllSegments = async () => {
|
||||
if (segments.length === 0) {
|
||||
message = '먼저 구간을 추가해주세요.';
|
||||
return;
|
||||
}
|
||||
isCutting = true;
|
||||
message = '서버에서 동영상 잘라내기 작업 중...';
|
||||
try {
|
||||
const response = await fetch('/api/process-video', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'cut-segments',
|
||||
segments: segments,
|
||||
cropParams: cropWidth > 0 && cropHeight > 0 ? detectedCropParams : ''
|
||||
})
|
||||
});
|
||||
if (!response.ok) throw new Error('잘라내기 작업 실패');
|
||||
const result = await response.json();
|
||||
if (result.success && result.outputUrls) {
|
||||
let updatedSegments = [...segments];
|
||||
for (const output of result.outputUrls) {
|
||||
updatedSegments = updatedSegments.map((s) =>
|
||||
s.id === output.id ? { ...s, resultURL: output.url } : s
|
||||
);
|
||||
}
|
||||
segments = updatedSegments;
|
||||
message = '모든 구간 처리 완료!';
|
||||
} else {
|
||||
throw new Error(result.error || '알 수 없는 서버 오류');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('잘라내기 중 오류:', error);
|
||||
message = '잘라내기 중 오류가 발생했습니다.';
|
||||
} finally {
|
||||
isCutting = false;
|
||||
}
|
||||
};
|
||||
|
||||
const uploadSegment = async (segmentId: number) => {
|
||||
const segment = segments.find((s) => s.id === segmentId);
|
||||
if (!segment || !segment.resultURL || segment.isUploading) return;
|
||||
segments = segments.map((s) => (s.id === segmentId ? { ...s, isUploading: true } : s));
|
||||
message = `[${segment.id}번] 구간 업로드 중...`;
|
||||
try {
|
||||
const response = await fetch(segment.resultURL);
|
||||
const videoBlob = await response.blob();
|
||||
const formData = new FormData();
|
||||
const baseName = originalFileName.split('.').slice(0, -1).join('.') || originalFileName;
|
||||
const fileName = `${baseName}_cut-${segment.id}-${segment.start.toFixed(0)}s-${segment.end.toFixed(0)}s.mp4`;
|
||||
formData.append('video', videoBlob, fileName);
|
||||
formData.append('originalFileName', originalFileName);
|
||||
const uploadResponse = await fetch('/api/upload-cut', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error('서버 업로드 실패');
|
||||
}
|
||||
segments = segments.map((s) =>
|
||||
s.id === segmentId ? { ...s, isUploading: false, isUploaded: true } : s
|
||||
);
|
||||
message = `[${segment.id}번] 구간 업로드 성공!`;
|
||||
} catch (error) {
|
||||
console.error(`Segment ${segmentId} 업로드 오류:`, error);
|
||||
message = `[${segment.id}번] 구간 업로드 중 오류 발생.`;
|
||||
segments = segments.map((s) => (s.id === segmentId ? { ...s, isUploading: false } : s));
|
||||
}
|
||||
};
|
||||
|
||||
const uploadAllSegments = async () => {
|
||||
const segmentsToUpload = segments.filter((s) => s.resultURL && !s.isUploaded && !s.isUploading);
|
||||
if (segmentsToUpload.length === 0) {
|
||||
message = '업로드할 새로운 동영상이 없습니다.';
|
||||
return;
|
||||
}
|
||||
isUploadingSegments = true;
|
||||
message = `총 ${segmentsToUpload.length}개의 동영상 업로드를 시작합니다...`;
|
||||
for (const segment of segmentsToUpload) {
|
||||
await uploadSegment(segment.id);
|
||||
}
|
||||
isUploadingSegments = false;
|
||||
message = '모든 구간에 대한 업로드 작업이 완료되었습니다.';
|
||||
};
|
||||
|
||||
// --- UI 헬퍼 함수들 ---
|
||||
const setStartTime = () => {
|
||||
if (videoElement) startTime = videoElement.currentTime;
|
||||
};
|
||||
const setEndTime = () => {
|
||||
if (videoElement) endTime = videoElement.currentTime;
|
||||
};
|
||||
const addSegment = () => {
|
||||
if (startTime >= endTime) {
|
||||
alert('시작 시간은 종료 시간보다 빨라야 합니다.');
|
||||
return;
|
||||
}
|
||||
segments = [
|
||||
...segments,
|
||||
{ id: nextSegmentId++, start: startTime, end: endTime, isUploaded: false, isUploading: false }
|
||||
];
|
||||
};
|
||||
const removeSegment = (id: number) => {
|
||||
segments = segments.filter((segment) => segment.id !== id);
|
||||
};
|
||||
const downloadAllSegments = () => {
|
||||
if (!allProcessed) return;
|
||||
segments.forEach((segment) => {
|
||||
if (segment.resultURL) {
|
||||
const link = document.createElement('a');
|
||||
link.href = segment.resultURL;
|
||||
const baseName = originalFileName.split('.').slice(0, -1).join('.') || originalFileName;
|
||||
const fileName = `${baseName}_cut-${segment.id}-${segment.start.toFixed(
|
||||
0
|
||||
)}s-${segment.end.toFixed(0)}s.mp4`;
|
||||
link.download = fileName;
|
||||
link.click();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// ### START: 추가된 함수 (구간 미리보기 및 자동 정지) ###
|
||||
/** 구간을 클릭하면 해당 구간을 재생합니다. */
|
||||
const previewSegment = (segment: TimeSegment) => {
|
||||
if (!videoElement) return;
|
||||
|
||||
// 다른 구간 재생 중이면 정지 후 시작
|
||||
videoElement.pause();
|
||||
|
||||
// 정지할 시간 설정
|
||||
stopAtTime = segment.end;
|
||||
// 시작 시간으로 이동
|
||||
videoElement.currentTime = segment.start;
|
||||
// 재생
|
||||
videoElement.play();
|
||||
};
|
||||
|
||||
/** 비디오 재생 시간을 감지하여 자동 정지시킵니다. */
|
||||
const handleTimeUpdate = () => {
|
||||
if (videoElement && stopAtTime !== null && videoElement.currentTime >= stopAtTime) {
|
||||
videoElement.pause();
|
||||
stopAtTime = null; // 자동 정지 완료 후 초기화
|
||||
}
|
||||
};
|
||||
// ### END: 추가된 함수 ###
|
||||
</script>
|
||||
|
||||
<main class="mx-auto max-w-screen-2xl p-4 sm:p-6 lg:p-8">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl">
|
||||
🎬 내시경 BigData 동영상 편집기 (Server-Side)
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<p class="p-4 mb-6 bg-blue-50 text-blue-700 border border-blue-200 rounded-lg text-center">
|
||||
{message}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-6 items-start">
|
||||
{#if videoURL}
|
||||
<div class="w-full lg:w-1/2">
|
||||
<section class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-xl p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-800 mb-4">동영상 미리보기</h2>
|
||||
{#key videoURL}
|
||||
<video
|
||||
bind:this={videoElement}
|
||||
class="w-full rounded-lg"
|
||||
controls
|
||||
autoplay
|
||||
on:timeupdate={handleTimeUpdate}
|
||||
>
|
||||
<source src={videoURL} type="video/mp4" />
|
||||
브라우저가 비디오 태그를 지원하지 않습니다.
|
||||
</video>
|
||||
{/key}
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="w-full lg:w-1/4 flex flex-col gap-6">
|
||||
<section class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-xl p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-800 mb-4">1. 동영상 파일 선택</h2>
|
||||
<label
|
||||
class="w-full text-center cursor-pointer rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50"
|
||||
>
|
||||
동영상 파일 불러오기
|
||||
<input
|
||||
type="file"
|
||||
accept="video/*"
|
||||
on:change={handleFileSelect}
|
||||
class="sr-only"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="mt-4 space-y-2 text-sm">
|
||||
{#if isUploadingOriginal}
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 mb-1">서버로 업로드 중...</p>
|
||||
<progress class="w-full" max="100" value={uploadProgress}></progress>
|
||||
<p class="text-right font-mono text-gray-700">{uploadProgress.toFixed(0)}%</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if isDetectingCrop}
|
||||
<p class="text-gray-500 animate-pulse">자동 Crop 영역 계산 중...</p>
|
||||
{:else if cropWidth > 0 && !isUploadingOriginal}
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 mb-2">자동 Crop 영역 (수정 가능):</p>
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
<div>
|
||||
<label for="cropX" class="block text-xs font-medium text-gray-500">X 시작점</label>
|
||||
<input
|
||||
type="number"
|
||||
id="cropX"
|
||||
bind:value={cropX}
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm p-1.5"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="cropY" class="block text-xs font-medium text-gray-500">Y 시작점</label>
|
||||
<input
|
||||
type="number"
|
||||
id="cropY"
|
||||
bind:value={cropY}
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm p-1.5"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="cropWidth" class="block text-xs font-medium text-gray-500">너비</label>
|
||||
<input
|
||||
type="number"
|
||||
id="cropWidth"
|
||||
bind:value={cropWidth}
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm p-1.5"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="cropHeight" class="block text-xs font-medium text-gray-500"
|
||||
>높이</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="cropHeight"
|
||||
bind:value={cropHeight}
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm p-1.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
on:click={handleExecuteCrop}
|
||||
disabled={isLoading || !(cropWidth > 0 && cropHeight > 0)}
|
||||
class="mt-4 w-full text-center cursor-pointer rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Crop 적용하기
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if videoURL}
|
||||
<section class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-xl p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">2. 구간 추가</h3>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<button
|
||||
on:click={setStartTime}
|
||||
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50"
|
||||
>시작 시간 설정</button
|
||||
>
|
||||
<button
|
||||
on:click={setEndTime}
|
||||
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50"
|
||||
>종료 시간 설정</button
|
||||
>
|
||||
</div>
|
||||
<div class="space-y-1 text-sm mb-4">
|
||||
<p><strong class="font-medium text-gray-900">시작:</strong> {startTime.toFixed(2)}초</p>
|
||||
<p><strong class="font-medium text-gray-900">종료:</strong> {endTime.toFixed(2)}초</p>
|
||||
</div>
|
||||
<button
|
||||
on:click={addSegment}
|
||||
class="w-full rounded-md border border-transparent bg-green-600 px-4 py-2 text-base font-medium text-white shadow-sm hover:bg-green-700"
|
||||
>
|
||||
➕ 구간 추가하기
|
||||
</button>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if videoURL}
|
||||
<div class="w-full lg:w-1/4">
|
||||
<section
|
||||
class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-xl p-6 flex flex-col h-full"
|
||||
>
|
||||
<h2 class="text-xl font-semibold text-gray-800 mb-4">
|
||||
3. 자르기 목록 ({segments.length}개)
|
||||
</h2>
|
||||
<div class="space-y-4 mb-6 flex-1 overflow-y-auto pr-2">
|
||||
{#each segments as segment (segment.id)}
|
||||
<div
|
||||
class="p-3 border rounded-lg flex items-center justify-between"
|
||||
class:bg-green-50={segment.isUploaded}
|
||||
class:bg-blue-50={segment.isUploading}
|
||||
>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<p
|
||||
class="font-medium text-gray-800 truncate cursor-pointer hover:text-indigo-600"
|
||||
on:click={() => previewSegment(segment)}
|
||||
title="클릭하여 구간 미리보기"
|
||||
>
|
||||
{segment.start.toFixed(2)}초 ~ {segment.end.toFixed(2)}초
|
||||
</p>
|
||||
{#if segment.resultURL}
|
||||
<div class="text-sm mt-1 flex items-center gap-4">
|
||||
<a
|
||||
href={segment.resultURL}
|
||||
download={`${originalFileName.split('.').slice(0, -1).join('.')}_cut-${
|
||||
segment.id
|
||||
}.mp4`}
|
||||
class="text-indigo-600 hover:underline">다운로드</a
|
||||
>
|
||||
|
||||
{#if segment.isUploading}
|
||||
<span class="text-gray-500 animate-pulse">업로드 중...</span>
|
||||
{:else if segment.isUploaded}
|
||||
<span class="font-medium text-green-600">✓ 업로드 완료</span>
|
||||
{:else}
|
||||
<button
|
||||
on:click={() => uploadSegment(segment.id)}
|
||||
class="text-blue-600 hover:underline"
|
||||
disabled={isLoading}>업로드</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
on:click={() => removeSegment(segment.id)}
|
||||
class="text-red-500 hover:text-red-700 font-bold px-2 ml-2 self-start">X</button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-gray-500 text-center py-4">추가된 구간이 없습니다.</p>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mt-auto grid grid-cols-1 md:grid-cols-3 gap-2">
|
||||
<button
|
||||
on:click={cutAllSegments}
|
||||
disabled={isLoading || segments.length === 0}
|
||||
class="w-full rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
✂️ 전체 잘라내기
|
||||
</button>
|
||||
<button
|
||||
on:click={uploadAllSegments}
|
||||
disabled={isLoading || !segmentsReadyToUpload}
|
||||
class="w-full rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
📤 전체 업로드
|
||||
</button>
|
||||
<button
|
||||
on:click={downloadAllSegments}
|
||||
disabled={isLoading || !allProcessed}
|
||||
class="w-full rounded-md border border-transparent bg-green-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
📦 전체 다운로드
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
723
src/routes/video-editor/+page.svelte
Executable file
723
src/routes/video-editor/+page.svelte
Executable 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>
|
||||
120
src/routes/video-editor/list/+page.svelte
Normal file
120
src/routes/video-editor/list/+page.svelte
Normal 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
BIN
static/ffmpeg-core.wasm
Executable file
Binary file not shown.
Loading…
Reference in New Issue
Block a user