fix: resolve TypeScript compilation errors
Fixes: - VideoPlayer.vue: restore allTags, filteredMarks, marksByTag (removed by mistake) - PeopleView.vue: fix frameTime scope in playFaceDetailVideo catch block - Both errors caused white screen on build failure Root cause: 1. visibleTags duplicate declaration (old + new logic) 2. frameTime undefined in catch block (scope issue) Solution: 1. Keep allTags/filteredMarks/marksByTag (needed in template) 2. Keep visibleTags (new logic for processor toggles) 3. Move frameTime calculation to correct scope in catch block
This commit is contained in:
50
DEPLOYMENT_TEST_REPORT.md
Normal file
50
DEPLOYMENT_TEST_REPORT.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Admin + Client 部署到 201 测试报告
|
||||||
|
|
||||||
|
## 部署时间:2026-06-26 16:25
|
||||||
|
|
||||||
|
## 已部署文件
|
||||||
|
|
||||||
|
| 文件 | 状态 | 大小 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| **LoginView.vue** | ✅ 已复制 | 3761 bytes | 登录界面 ⭐⭐⭐⭐⭐ |
|
||||||
|
| **ClientView.vue** | ✅ 已复制 | 17240 bytes | 文件管理界面 ⭐⭐⭐⭐⭐ |
|
||||||
|
| **router/index.ts** | ✅ 已更新 | - | 添加 /login 路由 |
|
||||||
|
| **App.vue** | ⏳ 待更新 | - | 需添加路由守卫 |
|
||||||
|
|
||||||
|
## 路由配置 ✅
|
||||||
|
|
||||||
|
- → LoginView.vue ✅
|
||||||
|
- → AdminView.vue ✅
|
||||||
|
- → ClientView.vue ✅
|
||||||
|
|
||||||
|
## Tauri Commands ✅
|
||||||
|
|
||||||
|
已存在:
|
||||||
|
- ✅
|
||||||
|
- ✅
|
||||||
|
- ✅
|
||||||
|
- ✅
|
||||||
|
|
||||||
|
## 测试结果 ⚠️
|
||||||
|
|
||||||
|
### Vite dev server
|
||||||
|
- 状态:运行中
|
||||||
|
- 问题:node 命令找不到
|
||||||
|
- 解决方案:需要使用绝对路径
|
||||||
|
|
||||||
|
### Proxy (port 8888)
|
||||||
|
- 状态:运行中
|
||||||
|
- 问题:前端路由返回 "Not found"
|
||||||
|
- 原因:Vite dev server 未正确启动
|
||||||
|
|
||||||
|
## 下一步 ⭐⭐⭐⭐⭐
|
||||||
|
|
||||||
|
1. **修复 Vite 启动**:使用绝对路径
|
||||||
|
2. **添加 App.vue 路由守卫**:检查 token 并跳转
|
||||||
|
3. **测试完整功能**:登录 + Client 文件管理
|
||||||
|
4. **验证 Tauri commands**:文件列表/上传/下载
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**最后更新**: 2026-06-26 16:25
|
||||||
|
**状态**: 文件已部署,等待 Vite 重启 ⭐⭐⭐⭐
|
||||||
BIN
data/users/demo.sqlite
Normal file
BIN
data/users/demo.sqlite
Normal file
Binary file not shown.
BIN
data/users/demo.sqlite-shm
Normal file
BIN
data/users/demo.sqlite-shm
Normal file
Binary file not shown.
1
dist/assets/index-B7dM6B6s.css
vendored
1
dist/assets/index-B7dM6B6s.css
vendored
File diff suppressed because one or more lines are too long
25
dist/assets/index-BXXbgmgw.js
vendored
25
dist/assets/index-BXXbgmgw.js
vendored
File diff suppressed because one or more lines are too long
4
dist/index.html
vendored
4
dist/index.html
vendored
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Momentry Studio</title>
|
<title>Momentry Studio</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BXXbgmgw.js"></script>
|
<script type="module" crossorigin src="/assets/index-XYmskk93.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-B7dM6B6s.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-D8U_cUr3.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
109
node_modules/.package-lock.json
generated
vendored
109
node_modules/.package-lock.json
generated
vendored
@@ -67,6 +67,50 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@intlify/core-base": {
|
||||||
|
"version": "9.14.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.14.5.tgz",
|
||||||
|
"integrity": "sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/message-compiler": "9.14.5",
|
||||||
|
"@intlify/shared": "9.14.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@intlify/message-compiler": {
|
||||||
|
"version": "9.14.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.14.5.tgz",
|
||||||
|
"integrity": "sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/shared": "9.14.5",
|
||||||
|
"source-map-js": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@intlify/shared": {
|
||||||
|
"version": "9.14.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.14.5.tgz",
|
||||||
|
"integrity": "sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/sourcemap-codec": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
@@ -104,6 +148,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/web-bluetooth": {
|
||||||
|
"version": "0.0.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
||||||
|
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-vue": {
|
"node_modules/@vitejs/plugin-vue": {
|
||||||
"version": "5.2.4",
|
"version": "5.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
|
||||||
@@ -289,6 +339,44 @@
|
|||||||
"integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==",
|
"integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@vueuse/core": {
|
||||||
|
"version": "14.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz",
|
||||||
|
"integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/web-bluetooth": "^0.0.21",
|
||||||
|
"@vueuse/metadata": "14.3.0",
|
||||||
|
"@vueuse/shared": "14.3.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vueuse/metadata": {
|
||||||
|
"version": "14.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz",
|
||||||
|
"integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vueuse/shared": {
|
||||||
|
"version": "14.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz",
|
||||||
|
"integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/alien-signals": {
|
"node_modules/alien-signals": {
|
||||||
"version": "1.0.13",
|
"version": "1.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz",
|
||||||
@@ -721,6 +809,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-i18n": {
|
||||||
|
"version": "9.14.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.14.5.tgz",
|
||||||
|
"integrity": "sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==",
|
||||||
|
"deprecated": "v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/core-base": "9.14.5",
|
||||||
|
"@intlify/shared": "9.14.5",
|
||||||
|
"@vue/devtools-api": "^6.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vue-router": {
|
"node_modules/vue-router": {
|
||||||
"version": "4.6.4",
|
"version": "4.6.4",
|
||||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||||
|
|||||||
29
node_modules/.vite/deps/_metadata.json
generated
vendored
29
node_modules/.vite/deps/_metadata.json
generated
vendored
@@ -1,29 +1,44 @@
|
|||||||
{
|
{
|
||||||
"hash": "4fedd820",
|
"hash": "813ccdf0",
|
||||||
"configHash": "912a7b44",
|
"configHash": "5b9e4b03",
|
||||||
"lockfileHash": "8417729c",
|
"lockfileHash": "ac269d47",
|
||||||
"browserHash": "686c2332",
|
"browserHash": "2eabb9c1",
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"@tauri-apps/api/core": {
|
"@tauri-apps/api/core": {
|
||||||
"src": "../../@tauri-apps/api/core.js",
|
"src": "../../@tauri-apps/api/core.js",
|
||||||
"file": "@tauri-apps_api_core.js",
|
"file": "@tauri-apps_api_core.js",
|
||||||
"fileHash": "e45d7d78",
|
"fileHash": "3969e853",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"vue": {
|
"vue": {
|
||||||
"src": "../../vue/dist/vue.runtime.esm-bundler.js",
|
"src": "../../vue/dist/vue.runtime.esm-bundler.js",
|
||||||
"file": "vue.js",
|
"file": "vue.js",
|
||||||
"fileHash": "7f9396bc",
|
"fileHash": "b283492a",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"vue-router": {
|
"vue-router": {
|
||||||
"src": "../../vue-router/dist/vue-router.mjs",
|
"src": "../../vue-router/dist/vue-router.mjs",
|
||||||
"file": "vue-router.js",
|
"file": "vue-router.js",
|
||||||
"fileHash": "d8343f48",
|
"fileHash": "0528c0df",
|
||||||
|
"needsInterop": false
|
||||||
|
},
|
||||||
|
"vue-i18n": {
|
||||||
|
"src": "../../vue-i18n/dist/vue-i18n.mjs",
|
||||||
|
"file": "vue-i18n.js",
|
||||||
|
"fileHash": "b2bd439f",
|
||||||
|
"needsInterop": false
|
||||||
|
},
|
||||||
|
"@vueuse/core": {
|
||||||
|
"src": "../../@vueuse/core/dist/index.js",
|
||||||
|
"file": "@vueuse_core.js",
|
||||||
|
"fileHash": "db16229f",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chunks": {
|
"chunks": {
|
||||||
|
"chunk-YFT6OQ5R": {
|
||||||
|
"file": "chunk-YFT6OQ5R.js"
|
||||||
|
},
|
||||||
"chunk-5G6OTI3J": {
|
"chunk-5G6OTI3J": {
|
||||||
"file": "chunk-5G6OTI3J.js"
|
"file": "chunk-5G6OTI3J.js"
|
||||||
}
|
}
|
||||||
|
|||||||
161
node_modules/.vite/deps/vue-router.js
generated
vendored
161
node_modules/.vite/deps/vue-router.js
generated
vendored
@@ -1,3 +1,6 @@
|
|||||||
|
import {
|
||||||
|
setupDevtoolsPlugin
|
||||||
|
} from "./chunk-YFT6OQ5R.js";
|
||||||
import {
|
import {
|
||||||
computed,
|
computed,
|
||||||
defineComponent,
|
defineComponent,
|
||||||
@@ -18,164 +21,6 @@ import {
|
|||||||
watchEffect
|
watchEffect
|
||||||
} from "./chunk-5G6OTI3J.js";
|
} from "./chunk-5G6OTI3J.js";
|
||||||
|
|
||||||
// node_modules/@vue/devtools-api/lib/esm/env.js
|
|
||||||
function getDevtoolsGlobalHook() {
|
|
||||||
return getTarget().__VUE_DEVTOOLS_GLOBAL_HOOK__;
|
|
||||||
}
|
|
||||||
function getTarget() {
|
|
||||||
return typeof navigator !== "undefined" && typeof window !== "undefined" ? window : typeof globalThis !== "undefined" ? globalThis : {};
|
|
||||||
}
|
|
||||||
var isProxyAvailable = typeof Proxy === "function";
|
|
||||||
|
|
||||||
// node_modules/@vue/devtools-api/lib/esm/const.js
|
|
||||||
var HOOK_SETUP = "devtools-plugin:setup";
|
|
||||||
var HOOK_PLUGIN_SETTINGS_SET = "plugin:settings:set";
|
|
||||||
|
|
||||||
// node_modules/@vue/devtools-api/lib/esm/time.js
|
|
||||||
var supported;
|
|
||||||
var perf;
|
|
||||||
function isPerformanceSupported() {
|
|
||||||
var _a;
|
|
||||||
if (supported !== void 0) {
|
|
||||||
return supported;
|
|
||||||
}
|
|
||||||
if (typeof window !== "undefined" && window.performance) {
|
|
||||||
supported = true;
|
|
||||||
perf = window.performance;
|
|
||||||
} else if (typeof globalThis !== "undefined" && ((_a = globalThis.perf_hooks) === null || _a === void 0 ? void 0 : _a.performance)) {
|
|
||||||
supported = true;
|
|
||||||
perf = globalThis.perf_hooks.performance;
|
|
||||||
} else {
|
|
||||||
supported = false;
|
|
||||||
}
|
|
||||||
return supported;
|
|
||||||
}
|
|
||||||
function now() {
|
|
||||||
return isPerformanceSupported() ? perf.now() : Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
// node_modules/@vue/devtools-api/lib/esm/proxy.js
|
|
||||||
var ApiProxy = class {
|
|
||||||
constructor(plugin, hook) {
|
|
||||||
this.target = null;
|
|
||||||
this.targetQueue = [];
|
|
||||||
this.onQueue = [];
|
|
||||||
this.plugin = plugin;
|
|
||||||
this.hook = hook;
|
|
||||||
const defaultSettings = {};
|
|
||||||
if (plugin.settings) {
|
|
||||||
for (const id in plugin.settings) {
|
|
||||||
const item = plugin.settings[id];
|
|
||||||
defaultSettings[id] = item.defaultValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const localSettingsSaveId = `__vue-devtools-plugin-settings__${plugin.id}`;
|
|
||||||
let currentSettings = Object.assign({}, defaultSettings);
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(localSettingsSaveId);
|
|
||||||
const data = JSON.parse(raw);
|
|
||||||
Object.assign(currentSettings, data);
|
|
||||||
} catch (e) {
|
|
||||||
}
|
|
||||||
this.fallbacks = {
|
|
||||||
getSettings() {
|
|
||||||
return currentSettings;
|
|
||||||
},
|
|
||||||
setSettings(value) {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(localSettingsSaveId, JSON.stringify(value));
|
|
||||||
} catch (e) {
|
|
||||||
}
|
|
||||||
currentSettings = value;
|
|
||||||
},
|
|
||||||
now() {
|
|
||||||
return now();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (hook) {
|
|
||||||
hook.on(HOOK_PLUGIN_SETTINGS_SET, (pluginId, value) => {
|
|
||||||
if (pluginId === this.plugin.id) {
|
|
||||||
this.fallbacks.setSettings(value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.proxiedOn = new Proxy({}, {
|
|
||||||
get: (_target, prop) => {
|
|
||||||
if (this.target) {
|
|
||||||
return this.target.on[prop];
|
|
||||||
} else {
|
|
||||||
return (...args) => {
|
|
||||||
this.onQueue.push({
|
|
||||||
method: prop,
|
|
||||||
args
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.proxiedTarget = new Proxy({}, {
|
|
||||||
get: (_target, prop) => {
|
|
||||||
if (this.target) {
|
|
||||||
return this.target[prop];
|
|
||||||
} else if (prop === "on") {
|
|
||||||
return this.proxiedOn;
|
|
||||||
} else if (Object.keys(this.fallbacks).includes(prop)) {
|
|
||||||
return (...args) => {
|
|
||||||
this.targetQueue.push({
|
|
||||||
method: prop,
|
|
||||||
args,
|
|
||||||
resolve: () => {
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return this.fallbacks[prop](...args);
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return (...args) => {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
this.targetQueue.push({
|
|
||||||
method: prop,
|
|
||||||
args,
|
|
||||||
resolve
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
async setRealTarget(target) {
|
|
||||||
this.target = target;
|
|
||||||
for (const item of this.onQueue) {
|
|
||||||
this.target.on[item.method](...item.args);
|
|
||||||
}
|
|
||||||
for (const item of this.targetQueue) {
|
|
||||||
item.resolve(await this.target[item.method](...item.args));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// node_modules/@vue/devtools-api/lib/esm/index.js
|
|
||||||
function setupDevtoolsPlugin(pluginDescriptor, setupFn) {
|
|
||||||
const descriptor = pluginDescriptor;
|
|
||||||
const target = getTarget();
|
|
||||||
const hook = getDevtoolsGlobalHook();
|
|
||||||
const enableProxy = isProxyAvailable && descriptor.enableEarlyProxy;
|
|
||||||
if (hook && (target.__VUE_DEVTOOLS_PLUGIN_API_AVAILABLE__ || !enableProxy)) {
|
|
||||||
hook.emit(HOOK_SETUP, pluginDescriptor, setupFn);
|
|
||||||
} else {
|
|
||||||
const proxy = enableProxy ? new ApiProxy(descriptor, hook) : null;
|
|
||||||
const list = target.__VUE_DEVTOOLS_PLUGINS__ = target.__VUE_DEVTOOLS_PLUGINS__ || [];
|
|
||||||
list.push({
|
|
||||||
pluginDescriptor: descriptor,
|
|
||||||
setupFn,
|
|
||||||
proxy
|
|
||||||
});
|
|
||||||
if (proxy) {
|
|
||||||
setupFn(proxy.proxiedTarget);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// node_modules/vue-router/dist/devtools-EWN81iOl.mjs
|
// node_modules/vue-router/dist/devtools-EWN81iOl.mjs
|
||||||
var isBrowser = typeof document !== "undefined";
|
var isBrowser = typeof document !== "undefined";
|
||||||
function isRouteComponent(component) {
|
function isRouteComponent(component) {
|
||||||
|
|||||||
6
node_modules/.vite/deps/vue-router.js.map
generated
vendored
6
node_modules/.vite/deps/vue-router.js.map
generated
vendored
File diff suppressed because one or more lines are too long
73
setup-gitea-ci.sh
Executable file
73
setup-gitea-ci.sh
Executable file
@@ -0,0 +1,73 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
GITEA_URL="http://localhost:3000"
|
||||||
|
REPO_NAME="momentry_studio"
|
||||||
|
|
||||||
|
echo "========================================="
|
||||||
|
echo " 設定 Gitea CI/CD"
|
||||||
|
echo "========================================="
|
||||||
|
|
||||||
|
# 檢查 Token
|
||||||
|
if [ -z "$LOCAL_GITEA_TOKEN" ]; then
|
||||||
|
echo "❌ 請先設定 LOCAL_GITEA_TOKEN"
|
||||||
|
echo " export LOCAL_GITEA_TOKEN=\"your-token\""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Token 已設定"
|
||||||
|
|
||||||
|
# 1. 建立倉庫
|
||||||
|
echo ""
|
||||||
|
echo "📁 Step 1: 建立 Gitea 倉庫..."
|
||||||
|
REPO_EXISTS=$(curl -s -H "Authorization: token $LOCAL_GITEA_TOKEN" "$GITEA_URL/api/v1/repos/admin/$REPO_NAME" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('id',''))" 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
if [ -z "$REPO_EXISTS" ]; then
|
||||||
|
curl -s -X POST "$GITEA_URL/api/v1/user/repos" \
|
||||||
|
-H "Authorization: token $LOCAL_GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"name\":\"$REPO_NAME\",\"private\":false,\"auto_init\":false}"
|
||||||
|
echo "✅ 倉庫已建立"
|
||||||
|
else
|
||||||
|
echo "倉庫已存在"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. 設定 Git 遠端
|
||||||
|
echo ""
|
||||||
|
echo "🔗 Step 2: 設定 Git 遠端..."
|
||||||
|
git remote remove gitea 2>/dev/null || true
|
||||||
|
git remote add gitea "$GITEA_URL/admin/$REPO_NAME.git"
|
||||||
|
echo "✅ 遠端已設定"
|
||||||
|
|
||||||
|
# 3. 推送程式碼
|
||||||
|
echo ""
|
||||||
|
echo "📤 Step 3: 推送程式碼..."
|
||||||
|
git push -u gitea main -f 2>&1 | tail -3
|
||||||
|
echo "✅ 程式碼已推送"
|
||||||
|
|
||||||
|
# 4. 建立 Webhook
|
||||||
|
echo ""
|
||||||
|
echo "🔔 Step 4: 建立 Webhook..."
|
||||||
|
curl -s -X POST "$GITEA_URL/api/v1/repos/admin/$REPO_NAME/hooks" \
|
||||||
|
-H "Authorization: token $LOCAL_GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"type\": \"gitea\",
|
||||||
|
\"active\": true,
|
||||||
|
\"config\": {
|
||||||
|
\"content_type\": \"json\",
|
||||||
|
\"url\": \"http://localhost:8080/webhook\",
|
||||||
|
\"is_insecure_ssl\": false
|
||||||
|
},
|
||||||
|
\"events\": [\"push\"]
|
||||||
|
}" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Webhook ID: {d.get(\"id\",\"?\")}' 2>/dev/null || echo 'Webhook 建立失敗')" || echo "Webhook 需要 CI Server"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========================================="
|
||||||
|
echo " ✅ Gitea CI/CD 設定完成"
|
||||||
|
echo "========================================="
|
||||||
|
echo ""
|
||||||
|
echo "倉庫網址: $GITEA_URL/admin/$REPO_NAME"
|
||||||
|
echo ""
|
||||||
|
echo "手動觸發 CI/CD:"
|
||||||
|
echo " ./ci-cd.sh"
|
||||||
12
src-tauri/src/bin/gen_password.rs
Normal file
12
src-tauri/src/bin/gen_password.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
use argon2::{Algorithm, Argon2, Params, PasswordHasher, Version};
|
||||||
|
use argon2::password_hash::{SaltString, rand_core::OsRng};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let password = std::env::args().nth(1).unwrap_or_else(|| "demo123".to_string());
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::default());
|
||||||
|
match argon2.hash_password(password.as_bytes(), &salt) {
|
||||||
|
Ok(hash) => println!("{}", hash),
|
||||||
|
Err(e) => eprintln!("Error: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
1177
src-tauri/src/main.rs.bak3
Normal file
1177
src-tauri/src/main.rs.bak3
Normal file
File diff suppressed because it is too large
Load Diff
1177
src-tauri/src/main.rs.base
Normal file
1177
src-tauri/src/main.rs.base
Normal file
File diff suppressed because it is too large
Load Diff
1177
src-tauri/src/main.rs.clean
Normal file
1177
src-tauri/src/main.rs.clean
Normal file
File diff suppressed because it is too large
Load Diff
1180
src-tauri/src/main.rs.work
Normal file
1180
src-tauri/src/main.rs.work
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because one or more lines are too long
99
src/App.vue.backup_final
Normal file
99
src/App.vue.backup_final
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<template>
|
||||||
|
<div id="app" class="ms-app" :data-current-login="'guest'">
|
||||||
|
<aside class="ms-side">
|
||||||
|
<div class="gs-logo">Workspace</div>
|
||||||
|
<nav class="gs-nav">
|
||||||
|
<router-link to="/search" class="gs-nav-item" active-class="active">
|
||||||
|
<img class="gs-nav-icon" src="/icons/Search.png" alt="">
|
||||||
|
<span>Search</span>
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/library" class="gs-nav-item" active-class="active">
|
||||||
|
<img class="gs-nav-icon" src="/icons/Library.png" alt="">
|
||||||
|
<span>Library</span>
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/people" class="gs-nav-item" active-class="active">
|
||||||
|
<img class="gs-nav-icon" src="/icons/People.png" alt="">
|
||||||
|
<span>People</span>
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/admin" class="gs-nav-item" active-class="active">
|
||||||
|
<svg class="gs-nav-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||||
|
</svg>
|
||||||
|
<span>Admin</span>
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/client" class="gs-nav-item" active-class="active">
|
||||||
|
<svg class="gs-nav-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
<span>Client</span>
|
||||||
|
</router-link>
|
||||||
|
</nav>
|
||||||
|
<div class="gs-divider"></div>
|
||||||
|
<div class="gs-footer">
|
||||||
|
<div class="gs-theme-switcher">
|
||||||
|
<button class="gs-theme-btn active">☀️</button>
|
||||||
|
<button class="gs-theme-btn">🌙</button>
|
||||||
|
<button class="gs-theme-btn">🌗</button>
|
||||||
|
</div>
|
||||||
|
<div class="gs-account"><span class="gs-account-name">Demo</span></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main class="ms-main">
|
||||||
|
<div class="ms-content"><router-view /></div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
|
const appZoom = ref(100)
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if ((e.metaKey || e.ctrlKey)) {
|
||||||
|
if (e.key === '=' || e.key === '+') {
|
||||||
|
e.preventDefault()
|
||||||
|
appZoom.value = Math.min(appZoom.value + 10, 300)
|
||||||
|
document.documentElement.style.zoom = appZoom.value + '%'
|
||||||
|
} else if (e.key === '-') {
|
||||||
|
e.preventDefault()
|
||||||
|
appZoom.value = Math.max(appZoom.value - 10, 50)
|
||||||
|
document.documentElement.style.zoom = appZoom.value + '%'
|
||||||
|
} else if (e.key === '0') {
|
||||||
|
e.preventDefault()
|
||||||
|
appZoom.value = 100
|
||||||
|
document.documentElement.style.zoom = '100%'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => document.addEventListener('keydown', handleKeydown))
|
||||||
|
onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@import './assets/wordpress-exact.css';
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: 'DM Sans', 'Noto Sans TC', -apple-system, BlinkMacSystemFont, sans-serif; background: #fff; color: #202124; }
|
||||||
|
#app { display: flex; max-width: 1400px; margin: 0 auto; padding: 28px; gap: 28px; min-height: 100vh; box-sizing: border-box; align-items: stretch; }
|
||||||
|
.ms-side { width: 240px; min-width: 240px; flex-shrink: 0; background: #fff; border-radius: 18px; padding: 18px 14px; box-shadow: 0 16px 38px rgba(0,0,0,0.10), 0 2px 8px rgba(0,0,0,0.06); display: flex; flex-direction: column; position: relative; height: calc(100vh - 125px); align-self: flex-start; overflow: visible; }
|
||||||
|
.gs-logo { font-size: 15px; font-weight: 600; color: #202124; padding: 8px 12px 16px 12px; flex-shrink: 0; }
|
||||||
|
.gs-nav { display: flex; flex-direction: column; gap: 4px; flex-shrink: 0; }
|
||||||
|
.gs-nav-item { display: flex; align-items: center; gap: 12px; height: 48px; padding: 0 12px; border-radius: 10px; text-decoration: none; color: #3c4043; font-weight: 600; font-size: 18px; line-height: 1; background: transparent; box-sizing: border-box; }
|
||||||
|
.gs-nav-item:hover { background: var(--ms-accent-soft); color: var(--ms-accent-text); }
|
||||||
|
.gs-nav-item.active { background: var(--ms-accent-soft) !important; color: var(--ms-accent-text) !important; font-weight: 600; }
|
||||||
|
.gs-nav-icon { width: 24px; height: 24px; object-fit: contain; flex-shrink: 0; }
|
||||||
|
.gs-divider { height: 1px; background: #eee; margin: 14px 8px; flex-shrink: 0; }
|
||||||
|
.gs-footer { margin-top: auto; padding: 10px 12px; border-radius: 12px; display: flex; align-items: center; gap: 10px; background: #f8f9fa; flex-shrink: 0; }
|
||||||
|
.gs-theme-switcher { display: flex; gap: 5px; }
|
||||||
|
.gs-theme-btn { width: 14px; height: 14px; border-radius: 50%; border: 1px solid rgba(0,0,0,.08); background: #f3f3f3; cursor: pointer; padding: 0; }
|
||||||
|
.gs-theme-btn:hover { transform: scale(1.08); }
|
||||||
|
.gs-theme-btn.active { box-shadow: 0 0 0 2px #fff, 0 0 0 3px rgba(0,0,0,.16); }
|
||||||
|
.gs-account-name { font-size: 12px; color: #5f6368; }
|
||||||
|
.ms-main { flex: 1; min-width: 0; background: #fff; border-radius: 18px; padding: 36px 42px; overflow-y: auto; overflow-x: hidden; }
|
||||||
|
.ms-content { max-width: 1200px; }
|
||||||
|
</style>
|
||||||
33
src/api/params.ts
Normal file
33
src/api/params.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export const PARAM_MAP: Record<string, string> = {
|
||||||
|
pageSize: 'page_size',
|
||||||
|
perPage: 'per_page',
|
||||||
|
fileUuid: 'file_uuid',
|
||||||
|
startTime: 'start_time',
|
||||||
|
endTime: 'end_time',
|
||||||
|
startFrame: 'start_frame',
|
||||||
|
endFrame: 'end_frame',
|
||||||
|
bboxX: 'bbox_x',
|
||||||
|
bboxY: 'bbox_y',
|
||||||
|
bboxW: 'bbox_w',
|
||||||
|
bboxH: 'bbox_h',
|
||||||
|
conversationId: 'conversation_id',
|
||||||
|
historyId: 'history_id',
|
||||||
|
mergeId: 'merge_id',
|
||||||
|
traceId: 'trace_id',
|
||||||
|
faceId: 'face_id',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toSnakeCase(key: string): string {
|
||||||
|
if (PARAM_MAP[key]) return PARAM_MAP[key]
|
||||||
|
return key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapParams(params: Record<string, any>): Record<string, any> {
|
||||||
|
const result: Record<string, any> = {}
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
result[toSnakeCase(key)] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
26
src/api/types.ts
Normal file
26
src/api/types.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
export interface PoseKeypoint {
|
||||||
|
name: string
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
confidence?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PoseClass = 'standing' | 'sitting' | 'kneeling' | 'lying' | 'prone' | 'unknown'
|
||||||
|
|
||||||
|
export interface PoseResponse {
|
||||||
|
frame: number
|
||||||
|
keypoints: PoseKeypoint[]
|
||||||
|
pose_class: PoseClass
|
||||||
|
confidence?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DominantColor {
|
||||||
|
rgb: [number, number, number]
|
||||||
|
percentage?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppearanceResponse {
|
||||||
|
frame: number
|
||||||
|
dominant_colors: DominantColor[]
|
||||||
|
hsv_histogram?: number[][][]
|
||||||
|
}
|
||||||
216
src/components/AgentTip.vue
Normal file
216
src/components/AgentTip.vue
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
<template>
|
||||||
|
<transition name="slide-up">
|
||||||
|
<div class="agent-tip" v-if="showAgentTip">
|
||||||
|
<div class="agent-avatar">🤖</div>
|
||||||
|
<div class="agent-message">{{ agentTip }}</div>
|
||||||
|
<button class="agent-close" @click="showAgentTip = false">×</button>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<transition name="fade">
|
||||||
|
<div class="discovery-tip" v-if="discoveryTip">
|
||||||
|
<span class="discovery-icon">✨</span>
|
||||||
|
<span class="discovery-message">{{ discoveryTip }}</span>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { showAgentTip, agentTip, discoveryTip } from '@/stores/agentStore'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.agent-tip {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 400px;
|
||||||
|
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.3);
|
||||||
|
z-index: 9999;
|
||||||
|
animation: slideInUp 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-avatar {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-message {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-close:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discovery-tip {
|
||||||
|
position: fixed;
|
||||||
|
top: 80px;
|
||||||
|
right: 20px;
|
||||||
|
background: #f0f9ff;
|
||||||
|
border: 1px solid #0ea5e9;
|
||||||
|
color: #0c4a6e;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
z-index: 9998;
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discovery-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-guide {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10000;
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-content {
|
||||||
|
background: white;
|
||||||
|
padding: 32px 40px;
|
||||||
|
border-radius: 16px;
|
||||||
|
max-width: 500px;
|
||||||
|
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-content h2 {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-content > p {
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-content-text h4 {
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-content-text p {
|
||||||
|
margin: 0;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-btn {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-up-enter-active {
|
||||||
|
animation: slideInUp 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-up-leave-active {
|
||||||
|
animation: slideOutDown 0.3s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-active, .fade-leave-active {
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from, .fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideOutDown {
|
||||||
|
from {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -419,7 +419,6 @@ function getTagColor(tag: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allTags = computed(() => [...new Set(marks.value.map(m => m.tag))])
|
const allTags = computed(() => [...new Set(marks.value.map(m => m.tag))])
|
||||||
const visibleTags = computed(() => tagFilter.value ? [tagFilter.value] : allTags.value)
|
|
||||||
|
|
||||||
const filteredMarks = computed(() => {
|
const filteredMarks = computed(() => {
|
||||||
if (!tagFilter.value) return marks.value
|
if (!tagFilter.value) return marks.value
|
||||||
|
|||||||
12
src/composables/useTheme.ts
Normal file
12
src/composables/useTheme.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useDark, useToggle } from '@vueuse/core'
|
||||||
|
|
||||||
|
export const isDark = useDark({
|
||||||
|
selector: 'html',
|
||||||
|
attribute: 'data-theme',
|
||||||
|
valueDark: 'dark',
|
||||||
|
valueLight: 'light',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const toggleDark = () => {
|
||||||
|
isDark.value = !isDark.value
|
||||||
|
}
|
||||||
35
src/composables/useZIndex.ts
Normal file
35
src/composables/useZIndex.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
let baseZIndex = 10000
|
||||||
|
const stack = ref<string[]>([])
|
||||||
|
|
||||||
|
export function useZIndex() {
|
||||||
|
const push = (id: string): number => {
|
||||||
|
if (!stack.value.includes(id)) {
|
||||||
|
stack.value.push(id)
|
||||||
|
}
|
||||||
|
return baseZIndex + stack.value.indexOf(id) * 10
|
||||||
|
}
|
||||||
|
|
||||||
|
const pop = (id: string) => {
|
||||||
|
const idx = stack.value.indexOf(id)
|
||||||
|
if (idx >= 0) {
|
||||||
|
stack.value.splice(idx, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = computed(() => baseZIndex + stack.value.length * 10)
|
||||||
|
|
||||||
|
return { push, pop, current }
|
||||||
|
}
|
||||||
|
|
||||||
|
let globalZIndex = 10000
|
||||||
|
|
||||||
|
export function getNextZIndex(): number {
|
||||||
|
globalZIndex += 10
|
||||||
|
return globalZIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetZIndex() {
|
||||||
|
globalZIndex = 10000
|
||||||
|
}
|
||||||
273
src/locales/en.json
Normal file
273
src/locales/en.json
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"loading": "Loading...",
|
||||||
|
"error": "Error",
|
||||||
|
"success": "Success",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"delete": "Delete",
|
||||||
|
"save": "Save",
|
||||||
|
"edit": "Edit",
|
||||||
|
"search": "Search",
|
||||||
|
"back": "Back",
|
||||||
|
"close": "Close",
|
||||||
|
"play": "Play",
|
||||||
|
"logout": "Logout",
|
||||||
|
"login": "Login"
|
||||||
|
},
|
||||||
|
"library": {
|
||||||
|
"sort": {
|
||||||
|
"title": "Sort By",
|
||||||
|
"time_desc": "Time (Newest First)",
|
||||||
|
"time_asc": "Time (Oldest First)",
|
||||||
|
"size_desc": "Size (Largest First)",
|
||||||
|
"size_asc": "Size (Smallest First)",
|
||||||
|
"name_asc": "Name (A to Z)",
|
||||||
|
"name_desc": "Name (Z to A)"
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"title": "Filter By",
|
||||||
|
"unregistered": "Unregistered",
|
||||||
|
"registered": "Registered",
|
||||||
|
"pending": "Pending",
|
||||||
|
"completed": "Completed",
|
||||||
|
"videos_only": "Videos Only",
|
||||||
|
"photos_only": "Photos Only"
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"title": "Size",
|
||||||
|
"min_mb": "Min MB",
|
||||||
|
"max_mb": "Max MB"
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"title": "Video Duration",
|
||||||
|
"min_minutes": "Min Minutes",
|
||||||
|
"max_minutes": "Max Minutes"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"files": "{count} files",
|
||||||
|
"registered": "Registered",
|
||||||
|
"unregistered": "Unregistered",
|
||||||
|
"pending": "Pending",
|
||||||
|
"processing": "Processing",
|
||||||
|
"completed": "Completed"
|
||||||
|
},
|
||||||
|
"thumbnail": {
|
||||||
|
"register_to_preview": "Register to see preview"
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"register_file": "Register File",
|
||||||
|
"process_file": "Process File",
|
||||||
|
"reprocess": "Reprocess",
|
||||||
|
"run_identity_agent": "Run Identity Agent",
|
||||||
|
"unregister": "Unregister",
|
||||||
|
"play_video": "Play Video",
|
||||||
|
"processor_selection": "Processor Selection",
|
||||||
|
"overall_progress": "Overall Progress",
|
||||||
|
"missing_file_path": "Missing file path",
|
||||||
|
"file_not_registered": "File not registered",
|
||||||
|
"select_processor": "Please select at least one processor",
|
||||||
|
"file_must_be_completed": "File must be completed first"
|
||||||
|
},
|
||||||
|
"pipeline": {
|
||||||
|
"processors": "Processors",
|
||||||
|
"rule1_ingestion": "Rule1 Ingestion",
|
||||||
|
"face_tracing": "Face Tracing",
|
||||||
|
"tkg_nodes": "TKG Nodes",
|
||||||
|
"tkg_edges": "TKG Edges",
|
||||||
|
"rule2_ingestion": "Rule2 Ingestion"
|
||||||
|
},
|
||||||
|
"phase": {
|
||||||
|
"processor_output": "Rule1: Processor Output",
|
||||||
|
"postgresql_ingestion": "Rule1: PostgreSQL Ingestion",
|
||||||
|
"qdrant_ingestion": "Rule1: Qdrant Ingestion"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"registering": "Registering...",
|
||||||
|
"registered": "Registered: {name}",
|
||||||
|
"registration_failed": "Registration failed: {message}",
|
||||||
|
"registration_error": "Registration error: {error}",
|
||||||
|
"processing": "Processing...",
|
||||||
|
"processing_started": "Processing started: {name}",
|
||||||
|
"processing_failed": "Processing failed: {message}",
|
||||||
|
"processing_error": "Processing error: {error}",
|
||||||
|
"identity_agent_started": "Identity Agent started: {name}",
|
||||||
|
"identity_agent_failed": "Identity Agent failed: {message}",
|
||||||
|
"identity_agent_error": "Identity Agent error: {error}",
|
||||||
|
"removing": "Removing...",
|
||||||
|
"removed": "Removed: {name}",
|
||||||
|
"removal_failed": "Removal failed: {message}",
|
||||||
|
"removal_error": "Removal error: {error}",
|
||||||
|
"clear_filters": "Clear Filters"
|
||||||
|
},
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"refresh_status": "Refresh Status"
|
||||||
|
},
|
||||||
|
"people": {
|
||||||
|
"toolbar": {
|
||||||
|
"all_files": "All Files",
|
||||||
|
"view_all_people": "View All People",
|
||||||
|
"view_starred_people": "View Starred People",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"pending_people": "People",
|
||||||
|
"skipped_people": "Skipped People",
|
||||||
|
"pending_faces": "Faces",
|
||||||
|
"face_group": "Face Group"
|
||||||
|
},
|
||||||
|
"section": {
|
||||||
|
"known_people": "Known People",
|
||||||
|
"pending_people": "People",
|
||||||
|
"skipped_people": "Skipped People",
|
||||||
|
"pending_faces": "Faces",
|
||||||
|
"face_group": "Face Group"
|
||||||
|
},
|
||||||
|
"sort": {
|
||||||
|
"title": "Sort By",
|
||||||
|
"recent": "Recently Viewed",
|
||||||
|
"file_name": "File Name",
|
||||||
|
"name_az": "Name (A to Z)",
|
||||||
|
"name_za": "Name (Z to A)",
|
||||||
|
"time_desc": "Time (Newest First)",
|
||||||
|
"time_asc": "Time (Oldest First)"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"known_people": "Search known people",
|
||||||
|
"pending_people": "Search pending people",
|
||||||
|
"person_name": "Search person name..."
|
||||||
|
},
|
||||||
|
"action_history": {
|
||||||
|
"title": "Action History · {name}",
|
||||||
|
"undo_all": "Undo All",
|
||||||
|
"redo_all": "Redo All",
|
||||||
|
"delete_person": "Delete This Person",
|
||||||
|
"no_history": "No action history"
|
||||||
|
},
|
||||||
|
"expand": {
|
||||||
|
"collapse": "Collapse",
|
||||||
|
"expand": "Expand",
|
||||||
|
"view_faces": "View Faces",
|
||||||
|
"appears_in": "Appears in:",
|
||||||
|
"faces": "{count} faces",
|
||||||
|
"set_as_profile": "Set as Profile",
|
||||||
|
"no_face_data": "No face data"
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"star_person": "★ Mark as Important",
|
||||||
|
"unstar_person": "☆ Remove Important",
|
||||||
|
"details": "📋 Details",
|
||||||
|
"confirm_person": "✓ Confirm Person",
|
||||||
|
"pending": "Pending",
|
||||||
|
"edit_name": "✎ Edit Name",
|
||||||
|
"merge_person": "⇄ Already Have This Person",
|
||||||
|
"skip_person": "✕ Skip This Person",
|
||||||
|
"undo": "↩ Undo",
|
||||||
|
"redo": "↪ Redo",
|
||||||
|
"delete_person": "🗑 Delete This Person"
|
||||||
|
},
|
||||||
|
"face_context": {
|
||||||
|
"details": "📋 Details",
|
||||||
|
"assign_existing": "⇄ Assign to Existing Person",
|
||||||
|
"skip_face": "✕ Skip This Face"
|
||||||
|
},
|
||||||
|
"assign": {
|
||||||
|
"title": "Assign to Existing Person",
|
||||||
|
"search_placeholder": "Search person name...",
|
||||||
|
"no_results": "No matching people found",
|
||||||
|
"confirm_assign": "Confirm Assignment"
|
||||||
|
},
|
||||||
|
"face_detail": {
|
||||||
|
"title": "Pending Face Details",
|
||||||
|
"file": "File:",
|
||||||
|
"trace_id": "Trace ID:",
|
||||||
|
"frame_number": "Frame Number:",
|
||||||
|
"confidence": "Confidence:",
|
||||||
|
"face_id": "Face ID:",
|
||||||
|
"play_segment": "Play Video Segment",
|
||||||
|
"new_identity": "Create as New Person",
|
||||||
|
"enter_name": "Enter person name...",
|
||||||
|
"add": "Add",
|
||||||
|
"close": "Close"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"back": "Back",
|
||||||
|
"edit": "Edit",
|
||||||
|
"role": "Role",
|
||||||
|
"description": "Description",
|
||||||
|
"enter_name": "Enter name",
|
||||||
|
"enter_alias": "Enter alias and press Enter",
|
||||||
|
"add_alias": "+ Alias",
|
||||||
|
"collapse": "Collapse",
|
||||||
|
"role_name": "Role name",
|
||||||
|
"custom_description": "Custom description",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"save_changes": "✓ Save Changes",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"add_same_person": "Add same person",
|
||||||
|
"play": "▶ Play",
|
||||||
|
"unbind": "✕ Unbind",
|
||||||
|
"segments": "{count} segments",
|
||||||
|
"traces": "{count} traces",
|
||||||
|
"all_segments": "All segments",
|
||||||
|
"mode_trace": "Trace",
|
||||||
|
"mode_segment": "Segment",
|
||||||
|
"mode_continuous": "Continuous",
|
||||||
|
"status": "Status:",
|
||||||
|
"confirm": "✓ Confirm",
|
||||||
|
"skip": "Skip",
|
||||||
|
"bind_face": "+ Bind Face",
|
||||||
|
"merge_other": "⇄ Merge to Other Person",
|
||||||
|
"delete_person": "Delete This Person",
|
||||||
|
"bind_face_title": "Bind Face",
|
||||||
|
"merge_title": "⇄ Merge to Other Person",
|
||||||
|
"unbind_trace": "Unbind trace T{trace_id} (face_id: {face_id})?",
|
||||||
|
"no_face_id": "This trace has no face_id, cannot unbind",
|
||||||
|
"unbind_failed": "Unbind failed: {error}",
|
||||||
|
"merge_confirm": "Merge \"{source}\" into \"{target}\"?",
|
||||||
|
"not_this_person": "Not This Person",
|
||||||
|
"delete_confirm": "Delete \"{name}\"?",
|
||||||
|
"delete_confirm_message": "Delete \"{name}\"? This action cannot be undone.",
|
||||||
|
"delete_failed": "Delete failed: {error}",
|
||||||
|
"create_identity_failed": "Failed to create person: API did not return identity_uuid",
|
||||||
|
"create_identity_error": "Failed to create person: {error}",
|
||||||
|
"assign_failed": "Assignment failed: {error}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agent": {
|
||||||
|
"welcome": {
|
||||||
|
"title": "👋 Welcome to Momentry Studio",
|
||||||
|
"intro": "I'll help you make better use of the system. Let me introduce the main features:",
|
||||||
|
"smart_search": {
|
||||||
|
"title": "Smart Search",
|
||||||
|
"desc": "Keyword, semantic, and people search modes"
|
||||||
|
},
|
||||||
|
"people_management": {
|
||||||
|
"title": "People Management",
|
||||||
|
"desc": "Identify people in videos, build people database"
|
||||||
|
},
|
||||||
|
"file_management": {
|
||||||
|
"title": "File Management",
|
||||||
|
"desc": "Manage and process your video and photo files"
|
||||||
|
},
|
||||||
|
"got_it": "Got it, start using"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Momentry Studio",
|
||||||
|
"subtitle": "Login to continue",
|
||||||
|
"username": "Username",
|
||||||
|
"password": "Password",
|
||||||
|
"username_placeholder": "Enter username",
|
||||||
|
"password_placeholder": "Enter password",
|
||||||
|
"login_button": "Login",
|
||||||
|
"logging_in": "Logging in...",
|
||||||
|
"login_success": "Login successful!",
|
||||||
|
"default_credentials": "Default credentials: demo / demo123"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"search": "Search",
|
||||||
|
"library": "Library",
|
||||||
|
"people": "People",
|
||||||
|
"admin": "Admin",
|
||||||
|
"client": "Client"
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/locales/index.ts
Normal file
77
src/locales/index.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import en from './en.json'
|
||||||
|
import zhTW from './zh-TW.json'
|
||||||
|
|
||||||
|
export type MessageSchema = typeof en
|
||||||
|
|
||||||
|
const supportedLanguages = {
|
||||||
|
'en': en,
|
||||||
|
'zh-TW': zhTW,
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectInitialLanguage(): string {
|
||||||
|
const saved = localStorage.getItem('app_language')
|
||||||
|
if (saved && saved in supportedLanguages) {
|
||||||
|
return saved
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saved === 'auto') {
|
||||||
|
return detectOSLanguage()
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'en'
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectOSLanguage(): string {
|
||||||
|
let osLang = 'en'
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ((window as any).__TAURI__) {
|
||||||
|
const locale = (window as any).__TAURI__?.os?.getLocale?.()
|
||||||
|
if (locale) osLang = locale
|
||||||
|
} else {
|
||||||
|
osLang = navigator.language || 'en'
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Language detection failed:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (osLang.startsWith('zh-TW') || osLang.startsWith('zh-Hant')) {
|
||||||
|
return 'zh-TW'
|
||||||
|
} else if (osLang.startsWith('zh-CN') || osLang.startsWith('zh-Hans')) {
|
||||||
|
return 'zh-CN'
|
||||||
|
} else if (osLang.startsWith('ja')) {
|
||||||
|
return 'ja'
|
||||||
|
} else if (osLang.startsWith('ko')) {
|
||||||
|
return 'ko'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'en'
|
||||||
|
}
|
||||||
|
|
||||||
|
const i18n = createI18n<[MessageSchema], 'en' | 'zh-TW'>({
|
||||||
|
legacy: false,
|
||||||
|
locale: detectInitialLanguage(),
|
||||||
|
fallbackLocale: 'en',
|
||||||
|
messages: supportedLanguages as any,
|
||||||
|
missingWarn: false,
|
||||||
|
fallbackWarn: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
export function changeLanguage(lang: string) {
|
||||||
|
if (lang === 'auto') {
|
||||||
|
localStorage.setItem('app_language', 'auto')
|
||||||
|
const detected = detectOSLanguage()
|
||||||
|
i18n.global.locale = detected as any
|
||||||
|
} else {
|
||||||
|
localStorage.setItem('app_language', lang)
|
||||||
|
i18n.global.locale = lang as any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentLanguage(): string {
|
||||||
|
return i18n.global.locale as string
|
||||||
|
}
|
||||||
|
|
||||||
|
export { detectOSLanguage }
|
||||||
|
export default i18n
|
||||||
273
src/locales/zh-TW.json
Normal file
273
src/locales/zh-TW.json
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"loading": "載入中...",
|
||||||
|
"error": "錯誤",
|
||||||
|
"success": "成功",
|
||||||
|
"cancel": "取消",
|
||||||
|
"confirm": "確認",
|
||||||
|
"delete": "刪除",
|
||||||
|
"save": "儲存",
|
||||||
|
"edit": "編輯",
|
||||||
|
"search": "搜尋",
|
||||||
|
"back": "返回",
|
||||||
|
"close": "關閉",
|
||||||
|
"play": "播放",
|
||||||
|
"logout": "登出",
|
||||||
|
"login": "登入"
|
||||||
|
},
|
||||||
|
"library": {
|
||||||
|
"sort": {
|
||||||
|
"title": "排序方式",
|
||||||
|
"time_desc": "依時間・由近到遠",
|
||||||
|
"time_asc": "依時間・由遠到近",
|
||||||
|
"size_desc": "依大小・由大到小",
|
||||||
|
"size_asc": "依大小・由小到大",
|
||||||
|
"name_asc": "依檔名・由 A 到 Z",
|
||||||
|
"name_desc": "依檔名・由 Z 到 A"
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"title": "過濾方式",
|
||||||
|
"unregistered": "未註冊",
|
||||||
|
"registered": "已註冊",
|
||||||
|
"pending": "待處理",
|
||||||
|
"completed": "已完成",
|
||||||
|
"videos_only": "僅顯示影片",
|
||||||
|
"photos_only": "僅顯示照片"
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"title": "大小",
|
||||||
|
"min_mb": "最小 MB",
|
||||||
|
"max_mb": "最大 MB"
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"title": "影片時長",
|
||||||
|
"min_minutes": "最小分鐘",
|
||||||
|
"max_minutes": "最大分鐘"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"files": "{count} 個檔案",
|
||||||
|
"registered": "已註冊",
|
||||||
|
"unregistered": "未註冊",
|
||||||
|
"pending": "等待中",
|
||||||
|
"processing": "處理中",
|
||||||
|
"completed": "已完成"
|
||||||
|
},
|
||||||
|
"thumbnail": {
|
||||||
|
"register_to_preview": "註冊以預覽"
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"register_file": "註冊此檔案",
|
||||||
|
"process_file": "處理此檔案",
|
||||||
|
"reprocess": "重新處理",
|
||||||
|
"run_identity_agent": "執行 Identity Agent",
|
||||||
|
"unregister": "移除註冊",
|
||||||
|
"play_video": "播放影片",
|
||||||
|
"processor_selection": "處理器選擇",
|
||||||
|
"overall_progress": "整體進度",
|
||||||
|
"missing_file_path": "缺少檔案路徑",
|
||||||
|
"file_not_registered": "檔案尚未註冊",
|
||||||
|
"select_processor": "請至少選擇一個處理器",
|
||||||
|
"file_must_be_completed": "檔案需先完成處理"
|
||||||
|
},
|
||||||
|
"pipeline": {
|
||||||
|
"processors": "處理器",
|
||||||
|
"rule1_ingestion": "Rule1 入庫",
|
||||||
|
"face_tracing": "Face Tracing",
|
||||||
|
"tkg_nodes": "TKG Nodes",
|
||||||
|
"tkg_edges": "TKG Edges",
|
||||||
|
"rule2_ingestion": "Rule2 入庫"
|
||||||
|
},
|
||||||
|
"phase": {
|
||||||
|
"processor_output": "Rule1: 處理器輸出",
|
||||||
|
"postgresql_ingestion": "Rule1: PostgreSQL 入庫",
|
||||||
|
"qdrant_ingestion": "Rule1: Qdrant 入庫"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"registering": "註冊中...",
|
||||||
|
"registered": "註冊成功:{name}",
|
||||||
|
"registration_failed": "註冊失敗:{message}",
|
||||||
|
"registration_error": "註冊錯誤:{error}",
|
||||||
|
"processing": "處理中...",
|
||||||
|
"processing_started": "處理已觸發:{name}",
|
||||||
|
"processing_failed": "處理失敗:{message}",
|
||||||
|
"processing_error": "處理錯誤:{error}",
|
||||||
|
"identity_agent_started": "Identity Agent 已啟動:{name}",
|
||||||
|
"identity_agent_failed": "Identity Agent 失敗:{message}",
|
||||||
|
"identity_agent_error": "Identity Agent 錯誤:{error}",
|
||||||
|
"removing": "移除中...",
|
||||||
|
"removed": "移除成功:{name}",
|
||||||
|
"removal_failed": "移除失敗:{message}",
|
||||||
|
"removal_error": "移除錯誤:{error}",
|
||||||
|
"clear_filters": "清除篩選"
|
||||||
|
},
|
||||||
|
"refresh": "重新整理",
|
||||||
|
"refresh_status": "重新整理狀態"
|
||||||
|
},
|
||||||
|
"people": {
|
||||||
|
"toolbar": {
|
||||||
|
"all_files": "全部檔案",
|
||||||
|
"view_all_people": "查看所有人物",
|
||||||
|
"view_starred_people": "查看重要人物",
|
||||||
|
"refresh": "重新整理",
|
||||||
|
"pending_people": "人物",
|
||||||
|
"skipped_people": "已略過",
|
||||||
|
"pending_faces": "人臉",
|
||||||
|
"face_group": "人臉群組"
|
||||||
|
},
|
||||||
|
"section": {
|
||||||
|
"known_people": "已知人物",
|
||||||
|
"pending_people": "人物",
|
||||||
|
"skipped_people": "已略過",
|
||||||
|
"pending_faces": "人臉",
|
||||||
|
"face_group": "人臉群組"
|
||||||
|
},
|
||||||
|
"sort": {
|
||||||
|
"title": "排序方式",
|
||||||
|
"recent": "最近瀏覽",
|
||||||
|
"file_name": "依檔案名稱",
|
||||||
|
"name_az": "依檔名・由 A 到 Z",
|
||||||
|
"name_za": "依檔名・由 Z 到 A",
|
||||||
|
"time_desc": "依時間・由近到遠",
|
||||||
|
"time_asc": "依時間・由遠到近"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"known_people": "搜尋已知人物",
|
||||||
|
"pending_people": "搜尋待定人物",
|
||||||
|
"person_name": "搜尋人物名稱..."
|
||||||
|
},
|
||||||
|
"action_history": {
|
||||||
|
"title": "操作記錄 · {name}",
|
||||||
|
"undo_all": "全部復原",
|
||||||
|
"redo_all": "全部重做",
|
||||||
|
"delete_person": "刪除此人物",
|
||||||
|
"no_history": "尚無操作記錄"
|
||||||
|
},
|
||||||
|
"expand": {
|
||||||
|
"collapse": "收起",
|
||||||
|
"expand": "展開",
|
||||||
|
"view_faces": "查看面孔",
|
||||||
|
"appears_in": "出現檔案:",
|
||||||
|
"faces": "{count} 面孔",
|
||||||
|
"set_as_profile": "設為大頭貼",
|
||||||
|
"no_face_data": "無面孔資料"
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"star_person": "★ 標為重要人物",
|
||||||
|
"unstar_person": "☆ 取消重要人物",
|
||||||
|
"details": "📋 詳情",
|
||||||
|
"confirm_person": "✓ 確認人物",
|
||||||
|
"pending": "待定",
|
||||||
|
"edit_name": "✎ 編輯名稱",
|
||||||
|
"merge_person": "⇄ 已有此人物",
|
||||||
|
"skip_person": "✕ 略過此人物",
|
||||||
|
"undo": "↩ 復原",
|
||||||
|
"redo": "↪ 重做",
|
||||||
|
"delete_person": "🗑 刪除此人物"
|
||||||
|
},
|
||||||
|
"face_context": {
|
||||||
|
"details": "📋 詳情",
|
||||||
|
"assign_existing": "⇄ 指派給現有人物",
|
||||||
|
"skip_face": "✕ 略過此人臉"
|
||||||
|
},
|
||||||
|
"assign": {
|
||||||
|
"title": "指派給現有人物",
|
||||||
|
"search_placeholder": "搜尋人物名稱...",
|
||||||
|
"no_results": "沒有找到符合的人物",
|
||||||
|
"confirm_assign": "確認指派"
|
||||||
|
},
|
||||||
|
"face_detail": {
|
||||||
|
"title": "待定人臉詳情",
|
||||||
|
"file": "檔案:",
|
||||||
|
"trace_id": "Trace ID:",
|
||||||
|
"frame_number": "帧號:",
|
||||||
|
"confidence": "置信度:",
|
||||||
|
"face_id": "Face ID:",
|
||||||
|
"play_segment": "播放影片片段",
|
||||||
|
"new_identity": "新增為新人物",
|
||||||
|
"enter_name": "輸入人物名稱...",
|
||||||
|
"add": "新增",
|
||||||
|
"close": "關閉"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"back": "返回",
|
||||||
|
"edit": "編輯",
|
||||||
|
"role": "角色",
|
||||||
|
"description": "描述",
|
||||||
|
"enter_name": "加入人名",
|
||||||
|
"enter_alias": "輸入別名後按 Enter",
|
||||||
|
"add_alias": "+ 別名",
|
||||||
|
"collapse": "收起",
|
||||||
|
"role_name": "角色名稱",
|
||||||
|
"custom_description": "自訂描述",
|
||||||
|
"saving": "儲存中...",
|
||||||
|
"save_changes": "✓ 儲存更改",
|
||||||
|
"cancel": "取消",
|
||||||
|
"add_same_person": "加入相同人物",
|
||||||
|
"play": "▶ 播放",
|
||||||
|
"unbind": "✕ 解綁",
|
||||||
|
"segments": "{count} 個片段",
|
||||||
|
"traces": "{count} 個追蹤",
|
||||||
|
"all_segments": "所有片段",
|
||||||
|
"mode_trace": "追蹤",
|
||||||
|
"mode_segment": "片段",
|
||||||
|
"mode_continuous": "連續",
|
||||||
|
"status": "狀態:",
|
||||||
|
"confirm": "✓ 確認",
|
||||||
|
"skip": "略過",
|
||||||
|
"bind_face": "+ 綁定人臉",
|
||||||
|
"merge_other": "⇄ 合併到其他人物",
|
||||||
|
"delete_person": "刪除此人物",
|
||||||
|
"bind_face_title": "Bind Face",
|
||||||
|
"merge_title": "⇄ 合併到其他人物",
|
||||||
|
"unbind_trace": "解綁 trace T{trace_id}(face_id: {face_id})?",
|
||||||
|
"no_face_id": "此 trace 沒有 face_id,無法解綁",
|
||||||
|
"unbind_failed": "解綁失敗:{error}",
|
||||||
|
"merge_confirm": "合併「{source}」到「{target}」?",
|
||||||
|
"not_this_person": "不是此人物",
|
||||||
|
"delete_confirm": "刪除「{name}」?",
|
||||||
|
"delete_confirm_message": "刪除「{name}」?此操作無法復原。",
|
||||||
|
"delete_failed": "刪除失敗:{error}",
|
||||||
|
"create_identity_failed": "新增人物失敗:API 未返回 identity_uuid",
|
||||||
|
"create_identity_error": "新增人物失敗:{error}",
|
||||||
|
"assign_failed": "指派失敗:{error}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agent": {
|
||||||
|
"welcome": {
|
||||||
|
"title": "👋 歡迎使用 Momentry Studio",
|
||||||
|
"intro": "我會幫助你更好地使用系統。讓我介紹一下主要功能:",
|
||||||
|
"smart_search": {
|
||||||
|
"title": "智能搜索",
|
||||||
|
"desc": "關鍵詞、語義、人物三種搜索模式"
|
||||||
|
},
|
||||||
|
"people_management": {
|
||||||
|
"title": "人物管理",
|
||||||
|
"desc": "識別視頻中的人物,建立人物庫"
|
||||||
|
},
|
||||||
|
"file_management": {
|
||||||
|
"title": "文件管理",
|
||||||
|
"desc": "管理和處理你的視頻、照片文件"
|
||||||
|
},
|
||||||
|
"got_it": "知道了,開始使用"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Momentry Studio",
|
||||||
|
"subtitle": "Login to continue",
|
||||||
|
"username": "Username",
|
||||||
|
"password": "Password",
|
||||||
|
"username_placeholder": "Enter username",
|
||||||
|
"password_placeholder": "Enter password",
|
||||||
|
"login_button": "Login",
|
||||||
|
"logging_in": "Logging in...",
|
||||||
|
"login_success": "Login successful!",
|
||||||
|
"default_credentials": "Default credentials: demo / demo123"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"search": "Search",
|
||||||
|
"library": "Library",
|
||||||
|
"people": "People",
|
||||||
|
"admin": "Admin",
|
||||||
|
"client": "Client"
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/main.ts.backup_local
Normal file
19
src/main.ts.backup_local
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import App from './App.vue'
|
||||||
|
import LoginView from './views/LoginView.vue'
|
||||||
|
import ClientView from './views/ClientView.vue'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', redirect: '/login' },
|
||||||
|
{ path: '/login', name: 'Login', component: LoginView },
|
||||||
|
{ path: '/client', name: 'Client', component: ClientView },
|
||||||
|
{ path: '/search', name: 'Search', component: { template: '<div>Search Page (Coming Soon)</div>' } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(router)
|
||||||
|
app.mount('#app')
|
||||||
28
src/stores/advancedMode.ts
Normal file
28
src/stores/advancedMode.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// Advanced Mode Store - controls visibility of advanced features
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
const ADVANCED_KEY = 'momentry_advanced_mode'
|
||||||
|
|
||||||
|
// Load initial state
|
||||||
|
const savedAdvanced = localStorage.getItem(ADVANCED_KEY)
|
||||||
|
const isAdvancedMode = ref(savedAdvanced === 'true')
|
||||||
|
|
||||||
|
export function useAdvancedMode() {
|
||||||
|
const isAdvanced = computed(() => isAdvancedMode.value)
|
||||||
|
|
||||||
|
function toggleAdvanced() {
|
||||||
|
isAdvancedMode.value = !isAdvancedMode.value
|
||||||
|
localStorage.setItem(ADVANCED_KEY, isAdvancedMode.value.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAdvanced(value: boolean) {
|
||||||
|
isAdvancedMode.value = value
|
||||||
|
localStorage.setItem(ADVANCED_KEY, value.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAdvanced,
|
||||||
|
toggleAdvanced,
|
||||||
|
setAdvanced
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/stores/agentStore.ts
Normal file
48
src/stores/agentStore.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export const agentTip = ref<string>('')
|
||||||
|
export const showAgentTip = ref(false)
|
||||||
|
export const agentSuggestions = ref<string[]>([])
|
||||||
|
export const discoveryTip = ref<string>('')
|
||||||
|
export const userBehavior = ref({
|
||||||
|
hasUsedKeywordSearch: false,
|
||||||
|
hasUsedSemanticSearch: false,
|
||||||
|
hasUsedPeopleSearch: false,
|
||||||
|
hasUsedFileFilter: false,
|
||||||
|
hasUsedContextMenu: false,
|
||||||
|
hasViewedPersonDetails: false,
|
||||||
|
hasBindedFace: false,
|
||||||
|
hasUsedSearchHistory: false,
|
||||||
|
hasUsedBookmark: false
|
||||||
|
})
|
||||||
|
|
||||||
|
export function showTip(message: string, duration = 5000) {
|
||||||
|
agentTip.value = message
|
||||||
|
showAgentTip.value = true
|
||||||
|
setTimeout(() => {
|
||||||
|
showAgentTip.value = false
|
||||||
|
}, duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showDiscovery(message: string, duration = 8000) {
|
||||||
|
discoveryTip.value = message
|
||||||
|
setTimeout(() => {
|
||||||
|
discoveryTip.value = ''
|
||||||
|
}, duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackBehavior(action: keyof typeof userBehavior.value) {
|
||||||
|
userBehavior.value[action] = true
|
||||||
|
localStorage.setItem('momentry_user_behavior', JSON.stringify(userBehavior.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadUserBehavior() {
|
||||||
|
const saved = localStorage.getItem('momentry_user_behavior')
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
userBehavior.value = JSON.parse(saved)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load user behavior:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/utils/mockPoseData.ts
Normal file
60
src/utils/mockPoseData.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import type { PoseResponse, AppearanceResponse } from '@/api/types'
|
||||||
|
|
||||||
|
export const mockPose: PoseResponse = {
|
||||||
|
frame: 100,
|
||||||
|
keypoints: [
|
||||||
|
{ name: 'nose', x: 100, y: 50, confidence: 0.9 },
|
||||||
|
{ name: 'left_eye', x: 90, y: 45, confidence: 0.95 },
|
||||||
|
{ name: 'right_eye', x: 110, y: 45, confidence: 0.95 },
|
||||||
|
{ name: 'left_ear', x: 80, y: 50, confidence: 0.85 },
|
||||||
|
{ name: 'right_ear', x: 120, y: 50, confidence: 0.85 },
|
||||||
|
{ name: 'left_shoulder', x: 60, y: 100, confidence: 0.9 },
|
||||||
|
{ name: 'right_shoulder', x: 140, y: 100, confidence: 0.9 },
|
||||||
|
{ name: 'left_elbow', x: 50, y: 150, confidence: 0.85 },
|
||||||
|
{ name: 'right_elbow', x: 150, y: 150, confidence: 0.85 },
|
||||||
|
{ name: 'left_wrist', x: 45, y: 190, confidence: 0.8 },
|
||||||
|
{ name: 'right_wrist', x: 155, y: 190, confidence: 0.8 },
|
||||||
|
{ name: 'left_hip', x: 70, y: 200, confidence: 0.9 },
|
||||||
|
{ name: 'right_hip', x: 130, y: 200, confidence: 0.9 },
|
||||||
|
{ name: 'left_knee', x: 65, y: 280, confidence: 0.85 },
|
||||||
|
{ name: 'right_knee', x: 135, y: 280, confidence: 0.85 },
|
||||||
|
{ name: 'left_ankle', x: 60, y: 350, confidence: 0.8 },
|
||||||
|
{ name: 'right_ankle', x: 140, y: 350, confidence: 0.8 },
|
||||||
|
],
|
||||||
|
pose_class: 'standing',
|
||||||
|
confidence: 0.92
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mockAppearance: AppearanceResponse = {
|
||||||
|
frame: 100,
|
||||||
|
dominant_colors: [
|
||||||
|
{ rgb: [255, 100, 50], percentage: 0.35 },
|
||||||
|
{ rgb: [50, 150, 200], percentage: 0.25 },
|
||||||
|
{ rgb: [100, 200, 100], percentage: 0.15 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mockSittingPose: PoseResponse = {
|
||||||
|
frame: 200,
|
||||||
|
keypoints: [
|
||||||
|
{ name: 'nose', x: 100, y: 60, confidence: 0.88 },
|
||||||
|
{ name: 'left_eye', x: 90, y: 55, confidence: 0.92 },
|
||||||
|
{ name: 'right_eye', x: 110, y: 55, confidence: 0.92 },
|
||||||
|
{ name: 'left_ear', x: 80, y: 60, confidence: 0.82 },
|
||||||
|
{ name: 'right_ear', x: 120, y: 60, confidence: 0.82 },
|
||||||
|
{ name: 'left_shoulder', x: 60, y: 110, confidence: 0.88 },
|
||||||
|
{ name: 'right_shoulder', x: 140, y: 110, confidence: 0.88 },
|
||||||
|
{ name: 'left_elbow', x: 55, y: 140, confidence: 0.83 },
|
||||||
|
{ name: 'right_elbow', x: 145, y: 140, confidence: 0.83 },
|
||||||
|
{ name: 'left_wrist', x: 50, y: 160, confidence: 0.78 },
|
||||||
|
{ name: 'right_wrist', x: 150, y: 160, confidence: 0.78 },
|
||||||
|
{ name: 'left_hip', x: 70, y: 180, confidence: 0.88 },
|
||||||
|
{ name: 'right_hip', x: 130, y: 180, confidence: 0.88 },
|
||||||
|
{ name: 'left_knee', x: 90, y: 200, confidence: 0.83 },
|
||||||
|
{ name: 'right_knee', x: 110, y: 200, confidence: 0.83 },
|
||||||
|
{ name: 'left_ankle', x: 85, y: 240, confidence: 0.78 },
|
||||||
|
{ name: 'right_ankle', x: 115, y: 240, confidence: 0.78 },
|
||||||
|
],
|
||||||
|
pose_class: 'sitting',
|
||||||
|
confidence: 0.87
|
||||||
|
}
|
||||||
264
src/utils/poseRenderer.ts
Normal file
264
src/utils/poseRenderer.ts
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
import type { PoseResponse, AppearanceResponse } from '@/api/types'
|
||||||
|
|
||||||
|
const SKELETON_CONNECTIONS: [string, string][] = [
|
||||||
|
['nose', 'left_eye'],
|
||||||
|
['nose', 'right_eye'],
|
||||||
|
['left_eye', 'left_ear'],
|
||||||
|
['right_eye', 'right_ear'],
|
||||||
|
['left_shoulder', 'right_shoulder'],
|
||||||
|
['left_shoulder', 'left_elbow'],
|
||||||
|
['left_elbow', 'left_wrist'],
|
||||||
|
['right_shoulder', 'right_elbow'],
|
||||||
|
['right_elbow', 'right_wrist'],
|
||||||
|
['left_shoulder', 'left_hip'],
|
||||||
|
['right_shoulder', 'right_hip'],
|
||||||
|
['left_hip', 'right_hip'],
|
||||||
|
['left_hip', 'left_knee'],
|
||||||
|
['left_knee', 'left_ankle'],
|
||||||
|
['right_hip', 'right_knee'],
|
||||||
|
['right_knee', 'right_ankle'],
|
||||||
|
]
|
||||||
|
|
||||||
|
function transformKeypoints(
|
||||||
|
keypoints: Array<{ name: string; x: number; y: number; confidence?: number }>,
|
||||||
|
canvasWidth: number,
|
||||||
|
canvasHeight: number
|
||||||
|
): Map<string, { x: number; y: number; confidence: number }> {
|
||||||
|
console.log('[transformKeypoints] input keypoints:', keypoints?.length)
|
||||||
|
|
||||||
|
const validKeypoints = keypoints.filter(kp => {
|
||||||
|
const conf = kp.confidence ?? 0
|
||||||
|
return conf > 0 && kp.x > 0 && kp.y > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[transformKeypoints] valid keypoints:', validKeypoints.length)
|
||||||
|
|
||||||
|
if (validKeypoints.length === 0) {
|
||||||
|
return new Map()
|
||||||
|
}
|
||||||
|
|
||||||
|
let minX = Infinity, maxX = -Infinity
|
||||||
|
let minY = Infinity, maxY = -Infinity
|
||||||
|
for (const kp of validKeypoints) {
|
||||||
|
minX = Math.min(minX, kp.x)
|
||||||
|
maxX = Math.max(maxX, kp.x)
|
||||||
|
minY = Math.min(minY, kp.y)
|
||||||
|
maxY = Math.max(maxY, kp.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = maxX - minX || 1
|
||||||
|
const height = maxY - minY || 1
|
||||||
|
|
||||||
|
console.log('[transformKeypoints] bounding box:', minX, minY, 'to', maxX, maxY)
|
||||||
|
console.log('[transformKeypoints] bbox size:', width, 'x', height)
|
||||||
|
|
||||||
|
const padding = 20
|
||||||
|
const availableWidth = canvasWidth - 2 * padding
|
||||||
|
const availableHeight = canvasHeight - 2 * padding
|
||||||
|
|
||||||
|
const scaleX = availableWidth / width
|
||||||
|
const scaleY = availableHeight / height
|
||||||
|
const scale = Math.min(scaleX, scaleY)
|
||||||
|
|
||||||
|
const scaledWidth = width * scale
|
||||||
|
const scaledHeight = height * scale
|
||||||
|
const offsetX = (canvasWidth - scaledWidth) / 2 - minX * scale
|
||||||
|
const offsetY = (canvasHeight - scaledHeight) / 2 - minY * scale
|
||||||
|
|
||||||
|
console.log('[transformKeypoints] scale:', scale, 'offset:', offsetX, offsetY)
|
||||||
|
|
||||||
|
const keypointMap = new Map<string, { x: number; y: number; confidence: number }>()
|
||||||
|
for (const kp of keypoints) {
|
||||||
|
const conf = kp.confidence ?? 0
|
||||||
|
keypointMap.set(kp.name, {
|
||||||
|
x: kp.x * scale + offsetX,
|
||||||
|
y: kp.y * scale + offsetY,
|
||||||
|
confidence: conf
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[transformKeypoints] First 3 transformed:',
|
||||||
|
[...keypointMap.entries()].slice(0, 3).map(([k, v]) => ({ name: k, ...v })))
|
||||||
|
|
||||||
|
return keypointMap
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawPoseSkeleton(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
pose: PoseResponse,
|
||||||
|
options?: {
|
||||||
|
lineWidth?: number
|
||||||
|
pointRadius?: number
|
||||||
|
lineColor?: string
|
||||||
|
pointColor?: string
|
||||||
|
videoWidth?: number
|
||||||
|
videoHeight?: number
|
||||||
|
noTransform?: boolean
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
console.log('[drawPoseSkeleton] canvas size:', canvas.width, 'x', canvas.height)
|
||||||
|
console.log('[drawPoseSkeleton] pose:', pose)
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) {
|
||||||
|
console.error('[drawPoseSkeleton] No context')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
lineWidth = 2,
|
||||||
|
pointRadius = 4,
|
||||||
|
lineColor = '#00ff00',
|
||||||
|
pointColor = '#ff0000',
|
||||||
|
noTransform = false
|
||||||
|
} = options || {}
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
// 不轉換,直接畫原始座標(用於 debug)
|
||||||
|
if (noTransform) {
|
||||||
|
console.log('[drawPoseSkeleton] Drawing raw keypoints (no transform)')
|
||||||
|
|
||||||
|
const validKeypoints = pose.keypoints.filter(kp => kp.confidence > 0 && kp.x > 0 && kp.y > 0)
|
||||||
|
|
||||||
|
if (validKeypoints.length === 0) {
|
||||||
|
ctx.fillStyle = '#9aa0a6'
|
||||||
|
ctx.font = '12px sans-serif'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.fillText('No valid keypoints', canvas.width / 2, canvas.height / 2)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找 bounding box
|
||||||
|
let minX = Infinity, maxX = -Infinity
|
||||||
|
let minY = Infinity, maxY = -Infinity
|
||||||
|
for (const kp of validKeypoints) {
|
||||||
|
minX = Math.min(minX, kp.x)
|
||||||
|
maxX = Math.max(maxX, kp.x)
|
||||||
|
minY = Math.min(minY, kp.y)
|
||||||
|
maxY = Math.max(maxY, kp.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[drawPoseSkeleton] Raw bbox:', minX, minY, 'to', maxX, maxY)
|
||||||
|
|
||||||
|
// 繪製
|
||||||
|
ctx.fillStyle = pointColor
|
||||||
|
for (const kp of validKeypoints) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(kp.x, kp.y, pointRadius, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const keypointMap = transformKeypoints(pose.keypoints, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
console.log('[drawPoseSkeleton] transformed keypoints:', keypointMap.size)
|
||||||
|
|
||||||
|
if (keypointMap.size === 0) {
|
||||||
|
console.log('[drawPoseSkeleton] No valid keypoints')
|
||||||
|
ctx.fillStyle = '#9aa0a6'
|
||||||
|
ctx.font = '12px sans-serif'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.fillText('No pose detected', canvas.width / 2, canvas.height / 2)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.strokeStyle = lineColor
|
||||||
|
ctx.lineWidth = lineWidth
|
||||||
|
for (const [from, to] of SKELETON_CONNECTIONS) {
|
||||||
|
const fromPt = keypointMap.get(from)
|
||||||
|
const toPt = keypointMap.get(to)
|
||||||
|
if (fromPt && toPt && fromPt.confidence > 0 && toPt.confidence > 0) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(fromPt.x, fromPt.y)
|
||||||
|
ctx.lineTo(toPt.x, toPt.y)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = pointColor
|
||||||
|
for (const kp of pose.keypoints) {
|
||||||
|
const transformed = keypointMap.get(kp.name)
|
||||||
|
if (transformed && transformed.confidence > 0) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(transformed.x, transformed.y, pointRadius, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawAppearanceOverlay(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
appearance: AppearanceResponse,
|
||||||
|
pose?: PoseResponse
|
||||||
|
) {
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
if (!appearance.dominant_colors?.length) {
|
||||||
|
if (appearance.hsv_histogram && appearance.hsv_histogram.length > 0) {
|
||||||
|
ctx.fillStyle = '#f8f9fa'
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
ctx.fillStyle = '#5f6368'
|
||||||
|
ctx.font = '12px sans-serif'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.fillText('HSV histogram', canvas.width / 2, canvas.height / 2 - 10)
|
||||||
|
ctx.fillText(`(${appearance.hsv_histogram.length} channels)`, canvas.width / 2, canvas.height / 2 + 10)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pose) {
|
||||||
|
const swatchWidth = canvas.width / appearance.dominant_colors.length
|
||||||
|
appearance.dominant_colors.forEach((color, i) => {
|
||||||
|
ctx.fillStyle = `rgb(${color.rgb.join(',')})`
|
||||||
|
ctx.fillRect(i * swatchWidth, 0, swatchWidth, canvas.height)
|
||||||
|
if (color.percentage) {
|
||||||
|
ctx.fillStyle = '#fff'
|
||||||
|
ctx.font = '12px sans-serif'
|
||||||
|
ctx.fillText(`${Math.round(color.percentage * 100)}%`, i * swatchWidth + 4, canvas.height - 8)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const keypointMap = transformKeypoints(pose.keypoints, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
if (keypointMap.size === 0) return
|
||||||
|
|
||||||
|
ctx.globalAlpha = 0.4
|
||||||
|
|
||||||
|
const upperBodyPts = ['left_shoulder', 'right_shoulder', 'left_elbow', 'right_elbow']
|
||||||
|
const lowerBodyPts = ['left_hip', 'right_hip', 'left_knee', 'right_knee']
|
||||||
|
|
||||||
|
if (appearance.dominant_colors[0]) {
|
||||||
|
const color = appearance.dominant_colors[0]
|
||||||
|
ctx.fillStyle = `rgb(${color.rgb.join(',')})`
|
||||||
|
for (const name of upperBodyPts) {
|
||||||
|
const pt = keypointMap.get(name)
|
||||||
|
if (pt && pt.confidence > 0) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(pt.x, pt.y, 15, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appearance.dominant_colors[1]) {
|
||||||
|
const color = appearance.dominant_colors[1]
|
||||||
|
ctx.fillStyle = `rgb(${color.rgb.join(',')})`
|
||||||
|
for (const name of lowerBodyPts) {
|
||||||
|
const pt = keypointMap.get(name)
|
||||||
|
if (pt && pt.confidence > 0) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(pt.x, pt.y, 15, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.globalAlpha = 1.0
|
||||||
|
}
|
||||||
85
src/views/AdminView.vue.backup_20260626_162155
Normal file
85
src/views/AdminView.vue.backup_20260626_162155
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<template>
|
||||||
|
<div id="ms-view-admin">
|
||||||
|
<div class="admin-header">
|
||||||
|
<h1>Admin Panel</h1>
|
||||||
|
<div class="admin-tabs">
|
||||||
|
<button class="admin-tab" :class="{ active: activeTab === 'dashboard' }" @click="activeTab = 'dashboard'">Dashboard</button>
|
||||||
|
<button class="admin-tab" :class="{ active: activeTab === 'users' }" @click="activeTab = 'users'">Users</button>
|
||||||
|
<button class="admin-tab" :class="{ active: activeTab === 'shares' }" @click="activeTab = 'shares'">Shares</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-content">
|
||||||
|
<div v-if="activeTab === 'dashboard'" class="dashboard-panel">
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card"><div class="stat-label">CPU</div><div class="stat-value">{{ stats.cpu }}%</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-label">Memory</div><div class="stat-value">{{ stats.memory }}%</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-label">Disk</div><div class="stat-value">{{ stats.disk }}%</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-label">Users</div><div class="stat-value">{{ users.length }}</div></div>
|
||||||
|
<div class="stat-card"><div class="stat-label">Shares</div><div class="stat-value">{{ shares.length }}</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="activeTab === 'users'" class="users-panel">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead><tr><th>Username</th><th>Home Dir</th><th>Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="u in users" :key="u.username">
|
||||||
|
<td>{{ u.username }}</td><td>{{ u.homeDir }}</td><td>{{ u.status === 1 ? 'Active' : 'Disabled' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="activeTab === 'shares'" class="shares-panel">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead><tr><th>Name</th><th>Port</th><th>Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="s in shares" :key="s.name">
|
||||||
|
<td>{{ s.name }}</td><td>{{ s.port }}</td><td>{{ s.status }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { apiCall } from '../api'
|
||||||
|
|
||||||
|
const activeTab = ref('dashboard')
|
||||||
|
const stats = ref({ cpu: 0, memory: 0, disk: 0 })
|
||||||
|
const users = ref<any[]>([])
|
||||||
|
const shares = ref<any[]>([])
|
||||||
|
|
||||||
|
async function loadDashboard() {
|
||||||
|
try { stats.value = await apiCall('get_system_stats', {}) || { cpu: 0, memory: 0, disk: 0 } } catch (e) { console.error(e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
try { users.value = await apiCall('list_admin_users', {}) || [] } catch (e) { console.error(e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadShares() {
|
||||||
|
try { shares.value = await apiCall('list_shares', {}) || [] } catch (e) { console.error(e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => { await loadDashboard(); await loadUsers(); await loadShares() })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
#ms-view-admin { padding: 20px; }
|
||||||
|
.admin-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||||
|
.admin-tabs { display: flex; gap: 10px; }
|
||||||
|
.admin-tab { padding: 8px 16px; border: none; background: #f0f0f0; cursor: pointer; border-radius: 4px; }
|
||||||
|
.admin-tab.active { background: #2563eb; color: white; }
|
||||||
|
.admin-content { background: white; border-radius: 8px; padding: 20px; }
|
||||||
|
.stats-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 15px; }
|
||||||
|
.stat-card { background: #f8f9fa; padding: 15px; border-radius: 8px; text-align: center; }
|
||||||
|
.stat-label { font-size: 12px; color: #666; }
|
||||||
|
.stat-value { font-size: 24px; font-weight: bold; }
|
||||||
|
.admin-table { width: 100%; border-collapse: collapse; }
|
||||||
|
.admin-table th, .admin-table td { padding: 10px; border: 1px solid #ddd; text-align: left; }
|
||||||
|
</style>
|
||||||
84
src/views/ClientView.vue.backup_20260626_162155
Normal file
84
src/views/ClientView.vue.backup_20260626_162155
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<div id="ms-view-client">
|
||||||
|
<div class="client-header">
|
||||||
|
<h1>File Manager</h1>
|
||||||
|
<div class="client-toolbar">
|
||||||
|
<button @click="goUp">Up</button>
|
||||||
|
<button @click="createFolder">New Folder</button>
|
||||||
|
<button @click="deleteSelected" :disabled="!selectedFile">Delete</button>
|
||||||
|
<span class="current-path">{{ currentPath }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="client-content">
|
||||||
|
<table class="file-table">
|
||||||
|
<thead><tr><th>Name</th><th>Size</th><th>Type</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="f in files" :key="f.name" @click="selectFile(f)" :class="{ selected: selectedFile?.name === f.name }">
|
||||||
|
<td><span :class="f.isDir ? 'folder-icon' : 'file-icon'">{{ f.isDir ? '📁' : '📄' }}</span> {{ f.name }}</td>
|
||||||
|
<td>{{ f.isDir ? '-' : formatSize(f.size) }}</td>
|
||||||
|
<td>{{ f.isDir ? 'Folder' : 'File' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { apiCall } from '../api'
|
||||||
|
|
||||||
|
const currentPath = ref('/')
|
||||||
|
const files = ref<any[]>([])
|
||||||
|
const selectedFile = ref<any>(null)
|
||||||
|
|
||||||
|
async function loadFiles() {
|
||||||
|
try {
|
||||||
|
const data = await apiCall('list_client_files', { path: currentPath.value })
|
||||||
|
files.value = (data || []).sort((a: any, b: any) => a.isDir === b.isDir ? a.name.localeCompare(b.name) : a.isDir ? -1 : 1)
|
||||||
|
} catch (e) { console.error(e); files.value = [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function goUp() {
|
||||||
|
const parts = currentPath.value.split('/').filter(p => p)
|
||||||
|
parts.pop()
|
||||||
|
currentPath.value = '/' + parts.join('/')
|
||||||
|
loadFiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectFile(f: any) {
|
||||||
|
if (f.isDir) { currentPath.value = currentPath.value === '/' ? '/' + f.name : currentPath.value + '/' + f.name; loadFiles() }
|
||||||
|
else { selectedFile.value = f }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFolder() {
|
||||||
|
const name = prompt('Folder name:')
|
||||||
|
if (!name) return
|
||||||
|
const path = currentPath.value === '/' ? '/' + name : currentPath.value + '/' + name
|
||||||
|
try { await apiCall('mkdir_client', { path }); loadFiles() } catch (e) { alert('Failed: ' + e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSelected() {
|
||||||
|
if (!selectedFile.value || !confirm('Delete ' + selectedFile.value.name + '?')) return
|
||||||
|
const path = currentPath.value === '/' ? '/' + selectedFile.value.name : currentPath.value + '/' + selectedFile.value.name
|
||||||
|
try { await apiCall('rm_client', { path }); selectedFile.value = null; loadFiles() } catch (e) { alert('Failed: ' + e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(n: number) { return n < 1024 ? n + 'B' : n < 1024*1024 ? (n/1024).toFixed(1) + 'KB' : (n/1024/1024).toFixed(1) + 'MB' }
|
||||||
|
|
||||||
|
onMounted(loadFiles)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
#ms-view-client { padding: 20px; }
|
||||||
|
.client-header { margin-bottom: 15px; }
|
||||||
|
.client-toolbar { display: flex; gap: 10px; align-items: center; margin-top: 10px; }
|
||||||
|
.client-toolbar button { padding: 6px 12px; border: 1px solid #ddd; background: white; cursor: pointer; border-radius: 4px; }
|
||||||
|
.client-toolbar button:disabled { opacity: 0.5; }
|
||||||
|
.current-path { margin-left: 20px; font-weight: bold; }
|
||||||
|
.file-table { width: 100%; border-collapse: collapse; }
|
||||||
|
.file-table th, .file-table td { padding: 8px; border: 1px solid #eee; }
|
||||||
|
.file-table tr.selected { background: #e3f2fd; }
|
||||||
|
.file-table tr:hover { background: #f5f5f5; cursor: pointer; }
|
||||||
|
</style>
|
||||||
@@ -1353,26 +1353,29 @@ async function playFaceDetailVideo() {
|
|||||||
const c = faceDetailModal.value.candidate
|
const c = faceDetailModal.value.candidate
|
||||||
if (!c || !c.file_uuid) return
|
if (!c || !c.file_uuid) return
|
||||||
const frame = c.frame_number || 0
|
const frame = c.frame_number || 0
|
||||||
|
const defaultFps = 30
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fileInfo: any = await apiCall('get_file_info', { uuid: c.file_uuid })
|
const fileInfo: any = await apiCall('get_file_info', { uuid: c.file_uuid })
|
||||||
const fps = fileInfo?.fps || 30
|
const fps = fileInfo?.fps || defaultFps
|
||||||
const frameTime = frame / fps
|
const frameTime = frame / fps
|
||||||
const startTime = Math.max(0, frameTime - 5)
|
const startTime = Math.max(0, frameTime - 5)
|
||||||
const endTime = frameTime + 5
|
const endTime = frameTime + 5
|
||||||
currentVideo.value = {
|
currentVideo.value = {
|
||||||
fileUuid: c.file_uuid,
|
fileUuid: c.file_uuid,
|
||||||
startFrame: startTime * fps,
|
startFrame: Math.round(startTime * fps),
|
||||||
endFrame: endTime * fps,
|
endFrame: Math.round(endTime * fps),
|
||||||
title: `F${Math.round(startTime * fps)}-F${Math.round(endTime * fps)} (${c.name})`
|
title: `F${Math.round(startTime * fps)}-F${Math.round(endTime * fps)} (${c.name})`
|
||||||
}
|
}
|
||||||
playing.value = true
|
playing.value = true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to get file info:', e)
|
console.error('Failed to get file info:', e)
|
||||||
|
const frameTime = frame / defaultFps
|
||||||
currentVideo.value = {
|
currentVideo.value = {
|
||||||
fileUuid: c.file_uuid,
|
fileUuid: c.file_uuid,
|
||||||
startFrame: Math.round(Math.max(0, frameTime - 5) * 30),
|
startFrame: Math.round(Math.max(0, frameTime - 5) * defaultFps),
|
||||||
endFrame: Math.round((frameTime + 5) * 30),
|
endFrame: Math.round((frameTime + 5) * defaultFps),
|
||||||
title: `F${Math.round(Math.max(0, frameTime - 5) * 30)}-F${Math.round((frameTime + 5) * 30)}`
|
title: `F${Math.round(Math.max(0, frameTime - 5) * defaultFps)}-F${Math.round((frameTime + 5) * defaultFps)}`
|
||||||
}
|
}
|
||||||
playing.value = true
|
playing.value = true
|
||||||
}
|
}
|
||||||
|
|||||||
247
vlm_search.py
Normal file
247
vlm_search.py
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
VLM-based semantic search for video keyframes.
|
||||||
|
Architecture:
|
||||||
|
1. Extract keyframes from video at configurable interval
|
||||||
|
2. Send each keyframe to VLM (Ollama LLaVA) for description
|
||||||
|
3. Store frame descriptions in SQLite with embedding
|
||||||
|
4. Search via text similarity using embedding model
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 vlm_search.py index <file_uuid> # Index a video
|
||||||
|
python3 vlm_search.py search <query> # Search indexed frames
|
||||||
|
python3 vlm_search.py status # Show index status
|
||||||
|
python3 vlm_search.py benchmark <file_uuid> # Benchmark VLM speed
|
||||||
|
"""
|
||||||
|
import sys, os, json, time, sqlite3, argparse, base64, io, struct
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
# ─── Configuration ───
|
||||||
|
CORE_API = "http://localhost:3002"
|
||||||
|
PROXY = "http://localhost:8888"
|
||||||
|
API_KEY = "muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69"
|
||||||
|
DB_PATH = Path(__file__).parent / "data" / "vlm_index.sqlite"
|
||||||
|
FRAME_INTERVAL = 120 # every N frames (default ~5s at 24fps)
|
||||||
|
VLM_MODEL = "llava"
|
||||||
|
EMBED_MODEL = "mxbai-embed-large"
|
||||||
|
|
||||||
|
def api_get(path):
|
||||||
|
url = f"{CORE_API}{path}?api_key={API_KEY}"
|
||||||
|
with urllib.request.urlopen(url) as r:
|
||||||
|
return json.loads(r.read())
|
||||||
|
|
||||||
|
def api_post(path, body):
|
||||||
|
url = f"{CORE_API}{path}?api_key={API_KEY}"
|
||||||
|
data = json.dumps(body).encode()
|
||||||
|
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
|
||||||
|
with urllib.request.urlopen(req) as r:
|
||||||
|
return json.loads(r.read())
|
||||||
|
|
||||||
|
def get_thumbnail_proxy(file_uuid, frame):
|
||||||
|
url = f"{PROXY}/api/v1/file/{file_uuid}/thumbnail?frame={frame}"
|
||||||
|
with urllib.request.urlopen(url) as r:
|
||||||
|
return r.read()
|
||||||
|
|
||||||
|
# ─── SQLite Index ───
|
||||||
|
def init_db():
|
||||||
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(str(DB_PATH))
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS frames (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
file_uuid TEXT NOT NULL,
|
||||||
|
frame INTEGER NOT NULL,
|
||||||
|
time_sec REAL,
|
||||||
|
description TEXT,
|
||||||
|
embedding BLOB,
|
||||||
|
created_at REAL DEFAULT (julianday('now'))
|
||||||
|
)""")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_frames_desc ON frames(description)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_frames_file ON frames(file_uuid)")
|
||||||
|
conn.commit()
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def get_embedding(text):
|
||||||
|
"""Use Ollama embedding model to get vector."""
|
||||||
|
payload = json.dumps({"model": EMBED_MODEL, "prompt": text}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"http://localhost:11434/api/embeddings",
|
||||||
|
data=payload,
|
||||||
|
headers={"Content-Type": "application/json"}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
data = json.loads(r.read())
|
||||||
|
return data.get("embedding")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Embedding error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def cosine_similarity(a, b):
|
||||||
|
dot = sum(x*y for x,y in zip(a,b))
|
||||||
|
na = sum(x*x for x in a)**0.5
|
||||||
|
nb = sum(x*x for x in b)**0.5
|
||||||
|
return dot / (na * nb) if na and nb else 0
|
||||||
|
|
||||||
|
# ─── VLM Inference ───
|
||||||
|
def vlm_describe(image_bytes, prompt="Describe this image in 1-2 sentences. Focus on what is happening, people, objects, and scene."):
|
||||||
|
b64 = base64.b64encode(image_bytes).decode()
|
||||||
|
payload = json.dumps({
|
||||||
|
"model": VLM_MODEL,
|
||||||
|
"prompt": prompt,
|
||||||
|
"images": [b64],
|
||||||
|
"stream": False,
|
||||||
|
"options": {"num_predict": 128}
|
||||||
|
}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"http://localhost:11434/api/generate",
|
||||||
|
data=payload,
|
||||||
|
headers={"Content-Type": "application/json"}
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=120) as r:
|
||||||
|
data = json.loads(r.read())
|
||||||
|
return data.get("response", "").strip()
|
||||||
|
|
||||||
|
# ─── Commands ───
|
||||||
|
def cmd_index(file_uuid, interval=FRAME_INTERVAL, limit=None):
|
||||||
|
conn = init_db()
|
||||||
|
info = api_get(f"/api/v1/file/{file_uuid}")
|
||||||
|
if not info.get("file_uuid"):
|
||||||
|
print(f"File not found: {file_uuid}")
|
||||||
|
return
|
||||||
|
file_name = info.get("file_name", "?")
|
||||||
|
fps = info.get("fps", 24)
|
||||||
|
duration = info.get("duration", 0)
|
||||||
|
total = 0
|
||||||
|
frame = 0
|
||||||
|
times = []
|
||||||
|
print(f"Indexing: {file_name} ({duration:.1f}s @ {fps:.2f}fps)")
|
||||||
|
while True:
|
||||||
|
if limit and total >= limit:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
img = get_thumbnail_proxy(file_uuid, frame)
|
||||||
|
except Exception as e:
|
||||||
|
if "404" in str(e):
|
||||||
|
break # no more frames
|
||||||
|
print(f" Error frame {frame}: {e}")
|
||||||
|
frame += interval
|
||||||
|
continue
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
desc = vlm_describe(img, "Describe this image briefly. What are people doing? What are they wearing? What objects/scene?")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" VLM error frame {frame}: {e}")
|
||||||
|
frame += interval
|
||||||
|
continue
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
times.append(elapsed)
|
||||||
|
# Get embedding
|
||||||
|
emb = get_embedding(desc)
|
||||||
|
emb_blob = struct.pack(f"{len(emb)}d", *emb) if emb else None
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO frames (file_uuid, frame, time_sec, description, embedding) VALUES (?,?,?,?,?)",
|
||||||
|
(file_uuid, frame, frame/fps, desc, emb_blob)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
total += 1
|
||||||
|
print(f" F{frame} ({frame/fps:.1f}s) [{elapsed:.1f}s] {desc[:80]}")
|
||||||
|
frame += interval
|
||||||
|
avg = sum(times)/len(times) if times else 0
|
||||||
|
print(f"\nDone: {total} frames indexed, avg {avg:.2f}s per VLM call")
|
||||||
|
|
||||||
|
def cmd_status():
|
||||||
|
conn = init_db()
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT file_uuid, COUNT(*), MIN(frame), MAX(frame), MIN(created_at)
|
||||||
|
FROM frames GROUP BY file_uuid
|
||||||
|
""").fetchall()
|
||||||
|
total = conn.execute("SELECT COUNT(*) FROM frames").fetchone()[0]
|
||||||
|
print(f"Total indexed frames: {total}")
|
||||||
|
for r in rows:
|
||||||
|
print(f" {r[0][:12]}: {r[1]} frames (F{r[2]}–F{r[3]})")
|
||||||
|
|
||||||
|
def cmd_search(query, top_k=10):
|
||||||
|
conn = init_db()
|
||||||
|
# Get query embedding
|
||||||
|
q_emb = get_embedding(query)
|
||||||
|
if not q_emb:
|
||||||
|
# Fallback to keyword search
|
||||||
|
print("Embedding failed, using keyword search fallback")
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT file_uuid, frame, time_sec, description FROM frames WHERE description LIKE ? LIMIT ?",
|
||||||
|
(f"%{query}%", top_k)
|
||||||
|
).fetchall()
|
||||||
|
if not rows:
|
||||||
|
print("No results")
|
||||||
|
return
|
||||||
|
for r in rows:
|
||||||
|
print(f" [{r[0][:12]}] F{r[1]} ({r[2]:.1f}s): {r[3][:100]}")
|
||||||
|
return
|
||||||
|
# Cosine similarity search
|
||||||
|
rows = conn.execute("SELECT id, file_uuid, frame, time_sec, description, embedding FROM frames").fetchall()
|
||||||
|
scored = []
|
||||||
|
for r in rows:
|
||||||
|
emb_blob = r[5]
|
||||||
|
if not emb_blob or len(emb_blob) < 8:
|
||||||
|
continue
|
||||||
|
emb = struct.unpack(f"{len(emb_blob)//8}d", emb_blob)
|
||||||
|
sim = cosine_similarity(q_emb, emb)
|
||||||
|
scored.append((sim, r[1], r[2], r[3], r[4]))
|
||||||
|
scored.sort(key=lambda x: -x[0])
|
||||||
|
print(f"Top {min(top_k, len(scored))} results for '{query}':")
|
||||||
|
for sim, fu, fr, ts, desc in scored[:top_k]:
|
||||||
|
print(f" [{sim:.3f}] [{fu[:12]}] F{fr} ({ts:.1f}s): {desc[:100]}")
|
||||||
|
|
||||||
|
def cmd_benchmark(file_uuid, count=5):
|
||||||
|
"""Benchmark VLM speed by processing N frames."""
|
||||||
|
times = []
|
||||||
|
for i in range(count):
|
||||||
|
frame = i * 120
|
||||||
|
try:
|
||||||
|
img = get_thumbnail_proxy(file_uuid, frame)
|
||||||
|
except:
|
||||||
|
print(f" Frame {frame}: not available")
|
||||||
|
continue
|
||||||
|
t0 = time.time()
|
||||||
|
desc = vlm_describe(img)
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
times.append(elapsed)
|
||||||
|
print(f" F{frame}: {elapsed:.2f}s — {desc[:60]}")
|
||||||
|
if times:
|
||||||
|
print(f"\nAvg: {sum(times)/len(times):.2f}s, Min: {min(times):.2f}s, Max: {max(times):.2f}s, Total: {sum(times):.1f}s")
|
||||||
|
|
||||||
|
# ─── Main ───
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description="VLM Semantic Search for Video Keyframes")
|
||||||
|
sub = parser.add_subparsers(dest="cmd")
|
||||||
|
p_idx = sub.add_parser("index", help="Index a video's keyframes")
|
||||||
|
p_idx.add_argument("file_uuid")
|
||||||
|
p_idx.add_argument("--interval", type=int, default=FRAME_INTERVAL)
|
||||||
|
p_idx.add_argument("--limit", type=int)
|
||||||
|
|
||||||
|
p_search = sub.add_parser("search", help="Search indexed frames")
|
||||||
|
p_search.add_argument("query")
|
||||||
|
p_search.add_argument("--top-k", type=int, default=10)
|
||||||
|
|
||||||
|
sub.add_parser("status", help="Show index status")
|
||||||
|
|
||||||
|
p_bench = sub.add_parser("benchmark", help="Benchmark VLM speed")
|
||||||
|
p_bench.add_argument("file_uuid")
|
||||||
|
p_bench.add_argument("--count", type=int, default=5)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.cmd == "index":
|
||||||
|
cmd_index(args.file_uuid, args.interval, args.limit)
|
||||||
|
elif args.cmd == "search":
|
||||||
|
cmd_search(args.query, args.top_k)
|
||||||
|
elif args.cmd == "status":
|
||||||
|
cmd_status()
|
||||||
|
elif args.cmd == "benchmark":
|
||||||
|
cmd_benchmark(args.file_uuid, args.count)
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
Reference in New Issue
Block a user