diff --git a/.env.example b/.env.example new file mode 100755 index 0000000..6d1c178 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +// ======================================================================= +// .env (프로젝트 최상위 폴더에 생성하세요) +// 중요: 이 파일은 Git에 포함시키지 마세요. +// 실제 데이터베이스 접속 정보로 채워주세요. +// ======================================================================= +DB_HOST=ssdoctors.com +DB_PORT=15433 +DB_USER=spacs +DB_PASSWORD=scaps +DB_DATABASE=polyp \ No newline at end of file diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 diff --git a/.npmrc b/.npmrc new file mode 100755 index 0000000..b6f27f1 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/.prettierignore b/.prettierignore new file mode 100755 index 0000000..7d74fe2 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,9 @@ +# Package Managers +package-lock.json +pnpm-lock.yaml +yarn.lock +bun.lock +bun.lockb + +# Miscellaneous +/static/ diff --git a/.prettierrc b/.prettierrc new file mode 100755 index 0000000..8103a0b --- /dev/null +++ b/.prettierrc @@ -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" +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100755 index 0000000..3a7c1a1 --- /dev/null +++ b/.vscode/launch.json @@ -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}" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100755 index 0000000..645487d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "files.associations": { + "string": "cpp", + "cstdlib": "cpp" + } +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100755 index 0000000..fc1456d --- /dev/null +++ b/.vscode/tasks.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 8e45cf6..b3dfae6 100755 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 7ed5f7f..6255909 100755 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/hooks.server.ts b/src/hooks.server.ts index be1bcc6..c9403cb 100755 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -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; -}; \ No newline at end of file +}; +======= +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) diff --git a/src/lib/server/processes.ts b/src/lib/server/processes.ts new file mode 100644 index 0000000..de2f042 --- /dev/null +++ b/src/lib/server/processes.ts @@ -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(); +export const processInfo = new Map(); + +// 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); diff --git a/src/lib/stores/user.ts b/src/lib/stores/user.ts new file mode 100644 index 0000000..230e0ce --- /dev/null +++ b/src/lib/stores/user.ts @@ -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); diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..bc18e4b --- /dev/null +++ b/src/lib/utils.ts @@ -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 +}; + +// 여기에 다른 공통 유틸리티 함수들을 추가할 수 있습니다. \ No newline at end of file diff --git a/src/routes/(protected)/(admin)/+layout.server.ts b/src/routes/(protected)/(admin)/+layout.server.ts new file mode 100644 index 0000000..3cbefb5 --- /dev/null +++ b/src/routes/(protected)/(admin)/+layout.server.ts @@ -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 }; +}; \ No newline at end of file diff --git a/src/routes/(protected)/(admin)/image-generate/+page.svelte b/src/routes/(protected)/(admin)/image-generate/+page.svelte new file mode 100644 index 0000000..da4544e --- /dev/null +++ b/src/routes/(protected)/(admin)/image-generate/+page.svelte @@ -0,0 +1,677 @@ + + + + +
+
+

+ 🎬 2단계: 동영상 구간 편집 +

+
+ +

+ {message} +

+ +
+
+
+

동영상 미리보기

+ + {#if videoURL} + {#key videoURL} + + {/key} + {:else} +
+

동영상을 선택하세요.

+
+ {/if} +
+ +
+ +
+
+

1. 동영상 파일 선택

+ + + +
+ + {#if videoURL} +
+

2. 구간 추가

+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+ {/if} +
+ + {#if videoURL} +
+
+

+ 3. 자르기 목록 ({segments.length}개) +

+
+ {#each segments as segment (segment.id)} +
+
+

previewSegment(segment)} + title="클릭하여 구간 미리보기" + > + {formatTime(segment.start)} ~ {formatTime(segment.end)} +

+ + {#if segment.isCutting} +
+

+ {getStageMessage(segment)} +

+ +
+ +

+ {(segment.progress || 0).toFixed(0)}% +

+
+ + {#if (segment.progress || 0) >= 100} +
+ +

+ {(segment.progressStage2 || 0).toFixed(0)}% +

+
+ {/if} +
+ {:else if segment.resultURL} +
+ 다운로드 + + {#if segment.isUploading} + 업로드 중... + {:else if segment.isUploaded} + ✓ 업로드 완료 + {:else} + + {/if} +
+ {/if} +
+ +
+ {:else} +

추가된 구간이 없습니다.

+ {/each} +
+
+ + + +
+
+
+ {/if} +
+
\ No newline at end of file diff --git a/src/routes/(protected)/(admin)/register/+page.server.ts b/src/routes/(protected)/(admin)/register/+page.server.ts new file mode 100644 index 0000000..b8b2765 --- /dev/null +++ b/src/routes/(protected)/(admin)/register/+page.server.ts @@ -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 {}; +}; \ No newline at end of file diff --git a/src/routes/(protected)/(admin)/register/+page.svelte b/src/routes/(protected)/(admin)/register/+page.svelte new file mode 100644 index 0000000..78e44b1 --- /dev/null +++ b/src/routes/(protected)/(admin)/register/+page.svelte @@ -0,0 +1,81 @@ + + + +
+

작업자 등록

+ +
+ + + + + + + +
+ + {#if message} +

{message}

+ {/if} +
+ + diff --git a/src/routes/(protected)/(admin)/register/+server.ts b/src/routes/(protected)/(admin)/register/+server.ts new file mode 100644 index 0000000..83abf10 --- /dev/null +++ b/src/routes/(protected)/(admin)/register/+server.ts @@ -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 }); + } +}; diff --git a/src/routes/(protected)/(admin)/video-crop/+page.svelte b/src/routes/(protected)/(admin)/video-crop/+page.svelte new file mode 100644 index 0000000..70211d7 --- /dev/null +++ b/src/routes/(protected)/(admin)/video-crop/+page.svelte @@ -0,0 +1,515 @@ + + +
+
+

+ 🎬 1단계: 동영상 업로드 및 Crop +

+
+ +

+ {message} +

+ +
+
+

1. 동영상 파일 선택

+ + +
+ {#if isUploadingOriginal} +
+

서버로 업로드 중...

+ +

{uploadProgress.toFixed(0)}%

+
+ {/if} + {#if isDetectingCrop} +

자동 Crop 영역 계산 중...

+ {:else if cropWidth > 0 || videoURL} +
+

Crop 영역 (수정 가능):

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + {#if isSavingCrop} +
+ +

{cropProgress.toFixed(0)}%

+
+ {/if} + {/if} +
+
+ +
+

2. 동영상 미리보기

+ {#if videoURL} + {#key videoURL} + + {/key} + {:else} +
+

동영상을 선택하세요.

+
+ {/if} +
+ +
+

+ 3. Crop 동영상 리스트 ({croppedVideos.length}개) +

+
+ {#if isLoadingList && croppedVideos.length === 0} +

리스트 로딩 중...

+ {:else if croppedVideos.length === 0} +

저장된 Crop 동영상이 없습니다.

+ {:else} + {#each croppedVideos as video (video.Index)} +
+
+

+ {video.movie_file} +

+

+ {new Date(video.created_at).toLocaleString('ko-KR')} +

+
+
+ + 선택 + + +
+
+ {/each} + {/if} +
+
+ +
+
+
+
\ No newline at end of file diff --git a/src/routes/(protected)/(admin)/video-manage/+page.svelte b/src/routes/(protected)/(admin)/video-manage/+page.svelte new file mode 100644 index 0000000..445ed26 --- /dev/null +++ b/src/routes/(protected)/(admin)/video-manage/+page.svelte @@ -0,0 +1,378 @@ + + + + 3단계: 동영상 관리 + + +
+
+

+ 🎬 3단계: 동영상 관리 +

+
+ + {#if message} +

+ {message} +

+ {/if} + +
+ +
+

+ Video Preview + {#if selectedVideoForPlayer} + (ID: {selectedVideoForPlayer.Index}) + {/if} +

+ {#if videoURL} + {#key videoURL} + + {/key} + {:else} +
+

Select a video from the list to play.

+
+ {/if} +
+ + +
+
+

+ Original List ({originals.length}) +

+
    + {#each originals as video (video.Index)} +
  • + handleOriginalSelect(video)} + title={video.LocationFile} + > + {path.pop(video.LocationFile)} (ID: {video.Index}) + + +
  • + {:else} +
  • No original videos found.
  • + {/each} +
+
+ +
+

+ Crop List ({filteredCrops.length}) +

+
    + {#if selectedOriginal} + {#each filteredCrops as video (video.Index)} +
  • + handleCropSelect(video)} + title={video.LocationFile} + > + {path.pop(video.LocationFile)} (ID: {video.Index}) + + +
  • + {:else} +
  • No cropped videos for the selected original.
  • + {/each} + {:else} +
  • Select an original video first.
  • + {/if} +
+
+
+ + +
+

+ Saved Segments List ({filteredSegments.length}) +

+
    + {#if selectedCrop} + {#each filteredSegments as video (video.Index)} +
  • + playVideo(video)} + title={video.LocationFile} + > + {path.pop(video.LocationFile)} (ID: {video.Index}) + + + + +
  • + {:else} +
  • No saved segments for the selected crop.
  • + {/each} + {:else} +
  • Select a cropped video first.
  • + {/if} +
+
+
+
+ diff --git a/src/routes/(protected)/+layout.server.ts b/src/routes/(protected)/+layout.server.ts new file mode 100644 index 0000000..dac32fe --- /dev/null +++ b/src/routes/(protected)/+layout.server.ts @@ -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 }; +}; \ No newline at end of file diff --git a/src/routes/+page.server.ts b/src/routes/(protected)/bigdata-generate/+page.server.ts similarity index 100% rename from src/routes/+page.server.ts rename to src/routes/(protected)/bigdata-generate/+page.server.ts diff --git a/src/routes/(protected)/bigdata-generate/+page.svelte b/src/routes/(protected)/bigdata-generate/+page.svelte new file mode 100755 index 0000000..37d37c4 --- /dev/null +++ b/src/routes/(protected)/bigdata-generate/+page.svelte @@ -0,0 +1,931 @@ + + + + + + +
+ +
+ +
+

Movie Index

+ +
+ +
+

Data Number

+ +
+
+ + +
+
+
+ {#each crudActions as action} + + {/each} +
+ {#each flowActions as action} + + {/each} + + +
+ + + +
+
+
+ + + +
+
+
+ {#if !selectedDataNumberInfo} +

Select a data number to view the image.

+ {:else} +
+ {#if !imageHasError} + {`Data imageHasError = true} + /> + {/if} + + + { if (e.key === 'Escape') deselectRect(e); }} + role="region" + aria-label="Image annotation area"> + + {#if !imageHasError} + {#if showPrevRects} + {#each prevRectsForDisplay as rect (rect.id)} + + {/each} + {/if} + {#if showCurrentRects} + {#each rectangles as rect (rect.id)} + {@const isSelected = selectedRectIds.includes(rect.id)} + 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)} + handleInteractionStart(e, rect, 'resize', handle)} + /> + {/each} + {/if} + {/each} + {/if} + {/if} + + +
+ {/if} +
+
+
\ No newline at end of file diff --git a/src/routes/(protected)/dashboard/+page.svelte b/src/routes/(protected)/dashboard/+page.svelte new file mode 100644 index 0000000..78ba063 --- /dev/null +++ b/src/routes/(protected)/dashboard/+page.svelte @@ -0,0 +1,10 @@ + + +
+

+ 안녕하세요, {data.user.name} 님 👋 +

+

작업자 ID: {data.user.login_id}

+
diff --git a/src/routes/(public)/+page.svelte b/src/routes/(public)/+page.svelte new file mode 100644 index 0000000..f94d826 --- /dev/null +++ b/src/routes/(public)/+page.svelte @@ -0,0 +1,55 @@ + + +{#if !data.user} +
+
+
+ + + + +
+ +

BigData Server

+

데이터를 분석하고 시각화하는 최고의 플랫폼

+ + + 시작하기 + +
+
+{/if} \ No newline at end of file diff --git a/src/routes/(public)/login/+page.svelte b/src/routes/(public)/login/+page.svelte new file mode 100644 index 0000000..a10dafa --- /dev/null +++ b/src/routes/(public)/login/+page.svelte @@ -0,0 +1,96 @@ + + + +
+
+

로그인

+ +
+
+ + +
+ +
+ + +
+ + +
+ + {#if message} +

+ {message} +

+ {/if} + + +
+
+ diff --git a/src/routes/(public)/login/+server.ts b/src/routes/(public)/login/+server.ts new file mode 100644 index 0000000..89ef176 --- /dev/null +++ b/src/routes/(public)/login/+server.ts @@ -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 }); + } +}; diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts new file mode 100644 index 0000000..c12dc10 --- /dev/null +++ b/src/routes/+layout.server.ts @@ -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 = { + // '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: [] }; + } +}; \ No newline at end of file diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 6d76c43..a59c156 100755 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,7 +1,45 @@ +<<<<<<< HEAD
@@ -12,4 +50,48 @@
+======= +
+
+
+

BigData Server

+ + {#if data.user} + + {/if} +
+ +
+ {#if data.user} + [{data.user.role}] + {data.user.name} + + {:else} + 로그인 + {/if} +
+
+ +
+ +
+>>>>>>> 0bab142 (add login/logout)
\ No newline at end of file diff --git a/src/routes/api/assign-worker/+server.ts b/src/routes/api/assign-worker/+server.ts new file mode 100644 index 0000000..621f0d1 --- /dev/null +++ b/src/routes/api/assign-worker/+server.ts @@ -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(); + } +}; \ No newline at end of file diff --git a/src/routes/api/cancel-process/+server.ts b/src/routes/api/cancel-process/+server.ts new file mode 100644 index 0000000..0c1d06b --- /dev/null +++ b/src/routes/api/cancel-process/+server.ts @@ -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 }); + } +}; diff --git a/src/routes/api/crop-progress/+server.ts b/src/routes/api/crop-progress/+server.ts new file mode 100644 index 0000000..e19bb7a --- /dev/null +++ b/src/routes/api/crop-progress/+server.ts @@ -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 { + 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' + } + }); +}; diff --git a/src/routes/api/crop-progress/[id]/+server.ts b/src/routes/api/crop-progress/[id]/+server.ts new file mode 100644 index 0000000..3490371 --- /dev/null +++ b/src/routes/api/crop-progress/[id]/+server.ts @@ -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' } + }); + } +}; diff --git a/src/routes/api/crop-progress/list/+server.ts b/src/routes/api/crop-progress/list/+server.ts new file mode 100644 index 0000000..b0a23a8 --- /dev/null +++ b/src/routes/api/crop-progress/list/+server.ts @@ -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' } + }); +}; diff --git a/src/routes/api/crop-progress_v1/+server.ts b/src/routes/api/crop-progress_v1/+server.ts new file mode 100644 index 0000000..3d70d80 --- /dev/null +++ b/src/routes/api/crop-progress_v1/+server.ts @@ -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 => { + 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' + } + }); +}; diff --git a/src/routes/api/cut-segments-progress/+server.ts b/src/routes/api/cut-segments-progress/+server.ts new file mode 100644 index 0000000..f5887f5 --- /dev/null +++ b/src/routes/api/cut-segments-progress/+server.ts @@ -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 => { + 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((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 => { + 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' + } + }); +}; + diff --git a/src/routes/api/delete-cut-video/+server.ts b/src/routes/api/delete-cut-video/+server.ts new file mode 100644 index 0000000..97c1c93 --- /dev/null +++ b/src/routes/api/delete-cut-video/+server.ts @@ -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는 삭제되었을 수 있습니다)`); + } +}; \ No newline at end of file diff --git a/src/routes/api/delete-video-recursive/+server.ts b/src/routes/api/delete-video-recursive/+server.ts new file mode 100644 index 0000000..b7a4f2b --- /dev/null +++ b/src/routes/api/delete-video-recursive/+server.ts @@ -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): Promise => { + // [수정] 이미 방문한 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()); + + 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(); + } +}; + diff --git a/src/routes/api/delete-video/+server.ts b/src/routes/api/delete-video/+server.ts new file mode 100644 index 0000000..e5ea514 --- /dev/null +++ b/src/routes/api/delete-video/+server.ts @@ -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(); + } +}; \ No newline at end of file diff --git a/src/routes/api/get-cropped-videos/+server.ts b/src/routes/api/get-cropped-videos/+server.ts new file mode 100644 index 0000000..a1d0018 --- /dev/null +++ b/src/routes/api/get-cropped-videos/+server.ts @@ -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(); + } +}; \ No newline at end of file diff --git a/src/routes/api/images/[...path]/+server.ts b/src/routes/api/images/[...path]/+server.ts index ee97ada..4e6c954 100755 --- a/src/routes/api/images/[...path]/+server.ts +++ b/src/routes/api/images/[...path]/+server.ts @@ -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, diff --git a/src/routes/api/login/+server.ts b/src/routes/api/login/+server.ts new file mode 100644 index 0000000..f77eb53 --- /dev/null +++ b/src/routes/api/login/+server.ts @@ -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 + } + }); +}; diff --git a/src/routes/api/logout/+server.ts b/src/routes/api/logout/+server.ts new file mode 100644 index 0000000..0220f3b --- /dev/null +++ b/src/routes/api/logout/+server.ts @@ -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 }); +}; + diff --git a/src/routes/api/me/+server.ts b/src/routes/api/me/+server.ts new file mode 100644 index 0000000..5739621 --- /dev/null +++ b/src/routes/api/me/+server.ts @@ -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, '유효하지 않은 토큰'); + } +}; diff --git a/src/routes/api/process-video/+server.ts b/src/routes/api/process-video/+server.ts index 2e81596..8fac884 100755 --- a/src/routes/api/process-video/+server.ts +++ b/src/routes/api/process-video/+server.ts @@ -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); diff --git a/src/routes/api/rectangles/by-id/[id]/+server.ts b/src/routes/api/rectangles/by-id/[id]/+server.ts index e596a0a..6f529f9 100755 --- a/src/routes/api/rectangles/by-id/[id]/+server.ts +++ b/src/routes/api/rectangles/by-id/[id]/+server.ts @@ -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( diff --git a/src/routes/api/rectangles/by-ref/[refIndex]/+server.ts b/src/routes/api/rectangles/by-ref/[refIndex]/+server.ts index 0c25f27..fc8712c 100755 --- a/src/routes/api/rectangles/by-ref/[refIndex]/+server.ts +++ b/src/routes/api/rectangles/by-ref/[refIndex]/+server.ts @@ -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( diff --git a/src/routes/api/register/+server.ts b/src/routes/api/register/+server.ts new file mode 100644 index 0000000..1ca0c6b --- /dev/null +++ b/src/routes/api/register/+server.ts @@ -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 오류입니다.'); + } +}; diff --git a/src/routes/api/save-crop-video/+server.ts b/src/routes/api/save-crop-video/+server.ts new file mode 100644 index 0000000..7dd53fe --- /dev/null +++ b/src/routes/api/save-crop-video/+server.ts @@ -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 { + 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 | 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' + } + }); +}; \ No newline at end of file diff --git a/src/routes/api/upload-cut/+server.ts b/src/routes/api/upload-cut/+server.ts index 3e9c23f..0d984ea 100644 --- a/src/routes/api/upload-cut/+server.ts +++ b/src/routes/api/upload-cut/+server.ts @@ -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) }; \ No newline at end of file diff --git a/src/routes/api/upload-cut/server.ts.ori b/src/routes/api/upload-cut/server.ts.ori new file mode 100644 index 0000000..33e8033 --- /dev/null +++ b/src/routes/api/upload-cut/server.ts.ori @@ -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 }); + } +}; \ No newline at end of file diff --git a/src/routes/api/upload-original/+server.ts b/src/routes/api/upload-original/+server.ts index d80f668..cd7d36b 100755 --- a/src/routes/api/upload-original/+server.ts +++ b/src/routes/api/upload-original/+server.ts @@ -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 { diff --git a/src/routes/api/video-lists/+server.ts b/src/routes/api/video-lists/+server.ts new file mode 100644 index 0000000..3be3633 --- /dev/null +++ b/src/routes/api/video-lists/+server.ts @@ -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(); + } +}; \ No newline at end of file diff --git a/src/routes/api/video-preview/+server.ts b/src/routes/api/video-preview/+server.ts index 06576d5..90d36d3 100644 --- a/src/routes/api/video-preview/+server.ts +++ b/src/routes/api/video-preview/+server.ts @@ -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) }; \ No newline at end of file diff --git a/src/routes/api/video-preview/server_ori.ts b/src/routes/api/video-preview/server_ori.ts new file mode 100644 index 0000000..06576d5 --- /dev/null +++ b/src/routes/api/video-preview/server_ori.ts @@ -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 }); + } +}; \ No newline at end of file diff --git a/src/routes/api/video-stream/[Index]/+server.ts b/src/routes/api/video-stream/[Index]/+server.ts new file mode 100644 index 0000000..6148eea --- /dev/null +++ b/src/routes/api/video-stream/[Index]/+server.ts @@ -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, '파일 스트리밍 중 오류 발생'); + } +}; diff --git a/src/routes/api/video-stream_v1/[Index]/+server.ts b/src/routes/api/video-stream_v1/[Index]/+server.ts new file mode 100644 index 0000000..1a01b6e --- /dev/null +++ b/src/routes/api/video-stream_v1/[Index]/+server.ts @@ -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, '동영상 스트리밍 중 오류 발생'); + } +}; diff --git a/src/routes/api/workers/+server.ts b/src/routes/api/workers/+server.ts new file mode 100644 index 0000000..64fa06d --- /dev/null +++ b/src/routes/api/workers/+server.ts @@ -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(); + } +}; \ No newline at end of file diff --git a/src/routes/page.svelte.spec.ts b/src/routes/page.svelte.spec.ts index 3c6adf3..2c50b1e 100755 --- a/src/routes/page.svelte.spec.ts +++ b/src/routes/page.svelte.spec.ts @@ -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'; diff --git a/src/routes/video-cut/+page.svelte b/src/routes/video-cut/+page.svelte index ff534ee..b94dab4 100755 --- a/src/routes/video-cut/+page.svelte +++ b/src/routes/video-cut/+page.svelte @@ -1,4 +1,5 @@ + +
+
+

+ 🎬 내시경 BigData 동영상 편집기 (Server-Side) +

+
+ +

+ {message} +

+ + +
+ {#if videoURL} +
+
+

동영상 미리보기

+ {#key videoURL} + + {/key} +
+
+ {/if} + +
+
+

1. 동영상 파일 선택

+ + +
+ {#if isUploadingOriginal} +
+

서버로 업로드 중...

+ +

{uploadProgress.toFixed(0)}%

+
+ {/if} + {#if isDetectingCrop} +

자동 Crop 영역 계산 중...

+ {:else if cropWidth > 0 && !isUploadingOriginal} +
+

자동 Crop 영역 (수정 가능):

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + {#if isCutting} +
+ +

{cropProgress.toFixed(0)}%

+
+ {/if} + {/if} +
+
+ + {#if videoURL} +
+

2. 구간 추가

+
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + + +
+ {/if} +
+ + {#if videoURL} +
+
+

+ 3. 자르기 목록 ({segments.length}개) +

+
+ {#each segments as segment (segment.id)} +
+
+

previewSegment(segment)} + title="클릭하여 구간 미리보기" + > + {segment.start.toFixed(2)}초 ~ {segment.end.toFixed(2)}초 +

+ {#if segment.resultURL} +
+ 다운로드 + + {#if segment.isUploading} + 업로드 중... + {:else if segment.isUploaded} + ✓ 업로드 완료 + {:else} + + {/if} +
+ {/if} +
+ +
+ {:else} +

추가된 구간이 없습니다.

+ {/each} +
+
+ + + +
+
+
+ {/if} +
+
+>>>>>>> 0bab142 (add login/logout) diff --git a/src/routes/video-cut/list/+page.svelte b/src/routes/video-cut/list/+page.svelte new file mode 100644 index 0000000..f53e3d2 --- /dev/null +++ b/src/routes/video-cut/list/+page.svelte @@ -0,0 +1,120 @@ + + + +
+ + + + +
+
+

FFmpeg Crop Progress List

+ +
+ + {#if loading} +

Loading process list...

+ {:else if error} +

Error: {error}

+ {:else if processes.length === 0} +

현재 실행 중인 작업이 없습니다.

+ {:else} + + + + + + + + + + + + + {#each processes as proc} + + + + + + + + + {/each} + +
IDStatusProgressStartedMessageActions
{proc.id} + + {proc.status} + + {proc.progress}%{new Date(proc.started).toLocaleString()}{proc.message ?? '-'} + {#if proc.status === 'running'} + + {/if} +
+ {/if} +
+
+ + diff --git a/src/routes/video-cut_v2/+page.svelte b/src/routes/video-cut_v2/+page.svelte new file mode 100755 index 0000000..ff534ee --- /dev/null +++ b/src/routes/video-cut_v2/+page.svelte @@ -0,0 +1,524 @@ + + +
+
+

+ 🎬 내시경 BigData 동영상 편집기 (Server-Side) +

+
+ +

+ {message} +

+ +
+ {#if videoURL} +
+
+

동영상 미리보기

+ {#key videoURL} + + {/key} +
+
+ {/if} + +
+
+

1. 동영상 파일 선택

+ + +
+ {#if isUploadingOriginal} +
+

서버로 업로드 중...

+ +

{uploadProgress.toFixed(0)}%

+
+ {/if} + {#if isDetectingCrop} +

자동 Crop 영역 계산 중...

+ {:else if cropWidth > 0 && !isUploadingOriginal} +
+

자동 Crop 영역 (수정 가능):

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + {/if} +
+
+ + {#if videoURL} +
+

2. 구간 추가

+
+ + +
+
+

시작: {startTime.toFixed(2)}초

+

종료: {endTime.toFixed(2)}초

+
+ +
+ {/if} +
+ + {#if videoURL} +
+
+

+ 3. 자르기 목록 ({segments.length}개) +

+
+ {#each segments as segment (segment.id)} +
+
+

previewSegment(segment)} + title="클릭하여 구간 미리보기" + > + {segment.start.toFixed(2)}초 ~ {segment.end.toFixed(2)}초 +

+ {#if segment.resultURL} +
+ 다운로드 + + {#if segment.isUploading} + 업로드 중... + {:else if segment.isUploaded} + ✓ 업로드 완료 + {:else} + + {/if} +
+ {/if} +
+ +
+ {:else} +

추가된 구간이 없습니다.

+ {/each} +
+
+ + + +
+
+
+ {/if} +
+
\ No newline at end of file diff --git a/src/routes/video-editor/+page.svelte b/src/routes/video-editor/+page.svelte new file mode 100755 index 0000000..3800d31 --- /dev/null +++ b/src/routes/video-editor/+page.svelte @@ -0,0 +1,723 @@ + + +
+
+

+ 🎬 내시경 BigData 동영상 편집기 (Server-Side) +

+
+ +

+ {message} +

+ +
+ {#if videoURL} +
+
+

동영상 미리보기

+ {#key videoURL} + + {/key} +
+
+ {/if} + +
+
+

1. 동영상 파일 선택

+ + +
+ {#if isUploadingOriginal} +
+

서버로 업로드 중...

+ +

{uploadProgress.toFixed(0)}%

+
+ {/if} + {#if isDetectingCrop} +

자동 Crop 영역 계산 중...

+ {:else if cropWidth > 0 && !isUploadingOriginal} +
+

자동 Crop 영역 (수정 가능):

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + {#if isCutting} +
+ +

{cropProgress.toFixed(0)}%

+
+ {/if} + {/if} +
+
+ + {#if videoURL} +
+

2. 구간 추가

+
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+ {/if} +
+ + {#if videoURL} +
+
+

+ 3. 자르기 목록 ({segments.length}개) +

+
+ {#each segments as segment (segment.id)} +
+
+

previewSegment(segment)} + title="클릭하여 구간 미리보기" + > + {formatTime(segment.start)} ~ {formatTime(segment.end)} +

+ + {#if segment.isCutting} +
+ +

+ {(segment.progress || 0).toFixed(0)}% +

+
+ {:else if segment.resultURL} +
+ 다운로드 + + {#if segment.isUploading} + 업로드 중... + {:else if segment.isUploaded} + ✓ 업로드 완료 + {:else} + + {/if} +
+ {/if} +
+ +
+ {:else} +

추가된 구간이 없습니다.

+ {/each} +
+
+ + + +
+
+
+ {/if} +
+
\ No newline at end of file diff --git a/src/routes/video-editor/list/+page.svelte b/src/routes/video-editor/list/+page.svelte new file mode 100644 index 0000000..f53e3d2 --- /dev/null +++ b/src/routes/video-editor/list/+page.svelte @@ -0,0 +1,120 @@ + + + +
+ + + + +
+
+

FFmpeg Crop Progress List

+ +
+ + {#if loading} +

Loading process list...

+ {:else if error} +

Error: {error}

+ {:else if processes.length === 0} +

현재 실행 중인 작업이 없습니다.

+ {:else} + + + + + + + + + + + + + {#each processes as proc} + + + + + + + + + {/each} + +
IDStatusProgressStartedMessageActions
{proc.id} + + {proc.status} + + {proc.progress}%{new Date(proc.started).toLocaleString()}{proc.message ?? '-'} + {#if proc.status === 'running'} + + {/if} +
+ {/if} +
+
+ + diff --git a/static/ffmpeg-core.wasm b/static/ffmpeg-core.wasm new file mode 100755 index 0000000..246b0fe Binary files /dev/null and b/static/ffmpeg-core.wasm differ