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:
2026-07-24 23:33:40 +08:00
parent 48975ef247
commit cb7e1ba0ba
37 changed files with 6875 additions and 205 deletions

50
DEPLOYMENT_TEST_REPORT.md Normal file
View 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

Binary file not shown.

BIN
data/users/demo.sqlite-shm Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Momentry Studio</title>
<script type="module" crossorigin src="/assets/index-BXXbgmgw.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B7dM6B6s.css">
<script type="module" crossorigin src="/assets/index-XYmskk93.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D8U_cUr3.css">
</head>
<body>
<div id="app"></div>

109
node_modules/.package-lock.json generated vendored
View File

@@ -67,6 +67,50 @@
"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": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
@@ -104,6 +148,12 @@
"dev": true,
"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": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
@@ -289,6 +339,44 @@
"integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==",
"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": {
"version": "1.0.13",
"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": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",

View File

@@ -1,29 +1,44 @@
{
"hash": "4fedd820",
"configHash": "912a7b44",
"lockfileHash": "8417729c",
"browserHash": "686c2332",
"hash": "813ccdf0",
"configHash": "5b9e4b03",
"lockfileHash": "ac269d47",
"browserHash": "2eabb9c1",
"optimized": {
"@tauri-apps/api/core": {
"src": "../../@tauri-apps/api/core.js",
"file": "@tauri-apps_api_core.js",
"fileHash": "e45d7d78",
"fileHash": "3969e853",
"needsInterop": false
},
"vue": {
"src": "../../vue/dist/vue.runtime.esm-bundler.js",
"file": "vue.js",
"fileHash": "7f9396bc",
"fileHash": "b283492a",
"needsInterop": false
},
"vue-router": {
"src": "../../vue-router/dist/vue-router.mjs",
"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
}
},
"chunks": {
"chunk-YFT6OQ5R": {
"file": "chunk-YFT6OQ5R.js"
},
"chunk-5G6OTI3J": {
"file": "chunk-5G6OTI3J.js"
}

161
node_modules/.vite/deps/vue-router.js generated vendored
View File

@@ -1,3 +1,6 @@
import {
setupDevtoolsPlugin
} from "./chunk-YFT6OQ5R.js";
import {
computed,
defineComponent,
@@ -18,164 +21,6 @@ import {
watchEffect
} 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
var isBrowser = typeof document !== "undefined";
function isRouteComponent(component) {

File diff suppressed because one or more lines are too long

73
setup-gitea-ci.sh Executable file
View 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"

View 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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

1180
src-tauri/src/main.rs.work Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

99
src/App.vue.backup_final Normal file
View 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
View 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
View 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
View 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>

View File

@@ -419,7 +419,6 @@ function getTagColor(tag: string): string {
}
const allTags = computed(() => [...new Set(marks.value.map(m => m.tag))])
const visibleTags = computed(() => tagFilter.value ? [tagFilter.value] : allTags.value)
const filteredMarks = computed(() => {
if (!tagFilter.value) return marks.value

View 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
}

View 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
View 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
View 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
View 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
View 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')

View 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
View 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
View 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
View 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
}

View 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>

View 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>

View File

@@ -1353,26 +1353,29 @@ async function playFaceDetailVideo() {
const c = faceDetailModal.value.candidate
if (!c || !c.file_uuid) return
const frame = c.frame_number || 0
const defaultFps = 30
try {
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 startTime = Math.max(0, frameTime - 5)
const endTime = frameTime + 5
currentVideo.value = {
currentVideo.value = {
fileUuid: c.file_uuid,
startFrame: startTime * fps,
endFrame: endTime * fps,
startFrame: Math.round(startTime * fps),
endFrame: Math.round(endTime * fps),
title: `F${Math.round(startTime * fps)}-F${Math.round(endTime * fps)} (${c.name})`
}
playing.value = true
} catch (e) {
console.error('Failed to get file info:', e)
const frameTime = frame / defaultFps
currentVideo.value = {
fileUuid: c.file_uuid,
startFrame: Math.round(Math.max(0, frameTime - 5) * 30),
endFrame: Math.round((frameTime + 5) * 30),
title: `F${Math.round(Math.max(0, frameTime - 5) * 30)}-F${Math.round((frameTime + 5) * 30)}`
startFrame: Math.round(Math.max(0, frameTime - 5) * defaultFps),
endFrame: Math.round((frameTime + 5) * defaultFps),
title: `F${Math.round(Math.max(0, frameTime - 5) * defaultFps)}-F${Math.round((frameTime + 5) * defaultFps)}`
}
playing.value = true
}

247
vlm_search.py Normal file
View 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()