feat: media API (video/bbox/thumbnail), UUID unification, dot matrix text, portal fixes, API dictionary V1.3

This commit is contained in:
Warren
2026-05-06 13:34:49 +08:00
parent e75c4d6f07
commit 74b6182eba
197 changed files with 17511 additions and 8759 deletions

View File

@@ -224,9 +224,7 @@
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import TranslatableText from '@/components/TranslatableText.vue'
const API_BASE = 'http://localhost:3003'
const API_KEY = 'muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69'
import { httpFetch, getCurrentConfig } from '@/api/client'
const route = useRoute()
const chunkId = ref('')
@@ -237,32 +235,24 @@ const loadDetail = async () => {
const uuid = route.params.uuid as string
chunkId.value = route.params.chunkId as string
loading.value = true
console.log('=== loadDetail START ===')
console.log('uuid:', uuid, 'chunkId:', chunkId.value)
try {
const url = `${API_BASE}/api/v1/videos/${uuid}/details?chunk_id=${chunkId.value}`
console.log('Fetching URL:', url)
const config = getCurrentConfig()
const url = `${config.api_base_url}/api/v1/file/${uuid}/${chunkId.value}`
const res = await fetch(url, {
headers: { 'X-API-Key': API_KEY }
})
const res = await httpFetch<any>(url)
console.log('Response status:', res.status, res.statusText)
if (!res.ok) {
throw new Error(`API error: ${res.status} ${res.statusText}`)
}
const result = await res.json()
console.log('Result keys:', Object.keys(result))
detail.value = result
console.log('detail.value set')
detail.value = res
} catch (error) {
console.error('ERROR:', error)
console.error('Failed to load chunk detail:', error)
alert('載入失敗: ' + error)
} finally {
loading.value = false
console.log('=== loadDetail END ===')
}
}

View File

@@ -64,7 +64,7 @@
>
<div class="aspect-square bg-gray-700 flex items-center justify-center overflow-hidden">
<img
:src="getThumbnailUrl(face.id)"
:src="getThumbnailUrl(face)"
alt="Face thumbnail"
class="w-full h-full object-cover"
loading="lazy"
@@ -135,9 +135,11 @@ const pageSize = ref(20)
const minConfidence = ref(0.8)
const selectedFaces = ref<number[]>([])
const getThumbnailUrl = (faceId: number): string => {
const getThumbnailUrl = (face: FaceCandidate): string => {
const config = getCurrentConfig()
return `${config.api_base_url}/api/v1/faces/${faceId}/thumbnail`
if (!face.bbox) return ''
const b = face.bbox
return `${config.api_base_url}/api/v1/file/${face.file_uuid}/thumbnail?frame=${face.frame_number}&x=${b.x}&y=${b.y}&w=${b.width}&h=${b.height}`
}
const onThumbnailError = (event: Event) => {

View File

@@ -202,7 +202,7 @@ async function fetchFiles() {
async function registerFile(filePath: string) {
try {
const result = await registerVideo(filePath)
await registerVideo(filePath)
// Refresh list
await fetchFiles()
} catch (e) {
@@ -230,7 +230,7 @@ async function startProcessing(fileUuid: string) {
try {
const config = getCurrentConfig()
await httpFetch(`${config.api_base_url}/api/v1/assets/${fileUuid}/process`, {
await httpFetch(`${config.api_base_url}/api/v1/file/${fileUuid}/process`, {
method: 'POST',
body: JSON.stringify({})
})
@@ -246,7 +246,7 @@ async function startProcessing(fileUuid: string) {
function enterWorkbench(fileUuid: string) {
// Navigate to the new Face Workbench view
router.push(`/workbench/${fileUuid}`)
router.push(`/video-detail/${fileUuid}`)
}
onMounted(fetchFiles)

View File

@@ -1,165 +0,0 @@
<template>
<div class="space-y-6">
<div class="flex justify-between items-center">
<h2 class="text-2xl font-bold">身份管理</h2>
<button
@click="loadIdentities"
class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg transition"
>
重新整理
</button>
</div>
<!-- Search Filter -->
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700">
<input
v-model="filterQuery"
@keyup.enter="loadIdentities"
type="text"
placeholder="搜尋身份..."
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-blue-500"
/>
</div>
<!-- Identity List -->
<div v-if="identities.length > 0" class="grid gap-4">
<div
v-for="identity in identities"
:key="identity.id"
class="bg-gray-800 rounded-lg p-6 border border-gray-700"
>
<div class="flex justify-between items-start">
<div class="flex-1">
<div class="flex items-center space-x-3 mb-2">
<h3 class="text-xl font-semibold text-blue-400">
{{ identity.profile.name || '未命名' }}
</h3>
<span
v-if="identity.face_identity_id"
class="bg-green-900 text-green-300 px-2 py-1 rounded text-xs"
>
已註冊
</span>
<span
v-else
class="bg-yellow-900 text-yellow-300 px-2 py-1 rounded text-xs"
>
未註冊
</span>
</div>
<div class="grid grid-cols-2 gap-4 text-sm text-gray-400 mb-3">
<div>角色: {{ identity.profile.character_name || '-' }}</div>
<div>Speaker: {{ identity.profile.speaker_id || '-' }}</div>
<div>影片: {{ identity.file_uuid }}</div>
<div>出現次數: {{ identity.stats.appearance_count }}</div>
</div>
<div v-if="identity.profile.original_name" class="text-sm text-gray-500">
原始名稱: {{ identity.profile.original_name }}
</div>
</div>
<!-- Actions -->
<div class="flex space-x-2">
<button
v-if="!identity.face_identity_id"
@click="registerIdentity(identity.person_id, identity.file_uuid)"
class="bg-green-600 hover:bg-green-700 px-4 py-2 rounded-lg text-sm transition"
>
註冊為全域身份
</button>
<button
@click="viewDetails(identity)"
class="bg-gray-700 hover:bg-gray-600 px-4 py-2 rounded-lg text-sm transition"
>
查看詳情
</button>
</div>
</div>
</div>
</div>
<!-- Loading State -->
<div v-else-if="loading" class="text-center py-12 text-gray-500">
載入中...
</div>
<!-- Empty State -->
<div v-else class="text-center py-12 text-gray-500">
尚無身份資料
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { listIdentities } from '@/api/client'
import { useRouter } from 'vue-router'
import { invoke } from '@tauri-apps/api/core'
interface IdentityProfile {
name: string | null
original_name: string | null
character_name: string | null
speaker_id: string | null
}
interface IdentityStats {
appearance_count: number
total_duration: number
first_appearance: number | null
last_appearance: number | null
}
interface Identity {
id: number
person_id: string
face_identity_id: number | null
file_uuid: string
profile: IdentityProfile
stats: IdentityStats
is_confirmed: boolean
}
const identities = ref<Identity[]>([])
const loading = ref(false)
const filterQuery = ref('')
const router = useRouter()
const loadIdentities = async () => {
loading.value = true
try {
const result = await listIdentities()
identities.value = result.identities
} catch (error) {
console.error('Failed to load identities:', error)
alert('載入失敗: ' + error)
} finally {
loading.value = false
}
}
const registerIdentity = async (personId: string, videoUuid: string) => {
try {
await invoke('register_identity', {
personId,
videoUuid
})
alert('註冊成功!')
await loadIdentities()
} catch (error) {
console.error('Registration failed:', error)
alert('註冊失敗: ' + error)
}
}
const viewDetails = (identity: Identity) => {
router.push({
name: 'video-detail',
params: { uuid: identity.file_uuid }
})
}
onMounted(() => {
loadIdentities()
})
</script>

View File

@@ -83,6 +83,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { httpFetch, getCurrentConfig } from '@/api/client'
const route = useRoute()
const identityId = ref('')
@@ -96,12 +97,8 @@ const loadDetail = async () => {
loading.value = true
try {
const config = JSON.parse(localStorage.getItem('portal_config') || '{}')
const baseUrl = config.api_base_url || 'http://127.0.0.1:3003'
const resp = await fetch(`${baseUrl}/api/v1/identities/${identityId.value}`, {
headers: { 'X-API-Key': config.api_key || '' }
})
const result = await resp.json()
const config = getCurrentConfig()
const result = await httpFetch<any>(`${config.api_base_url}/api/v1/identity/${identityId.value}`)
detail.value = result
profile.value = result.profile || {}
videos.value = result.videos || []

View File

@@ -60,13 +60,14 @@
<div class="space-y-2 text-xs font-mono">
<div class="bg-gray-900 p-2 rounded">
<span class="text-green-400"># Login</span>
<pre class="text-gray-300 whitespace-pre-wrap">curl -X POST http://127.0.0.1:3003/api/v1/auth/login \
<pre class="text-gray-300 whitespace-pre-wrap">curl -X POST {{ baseUrl }}/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"demo","password":"demo"}'</pre>
</div>
<div class="bg-gray-900 p-2 rounded">
<span class="text-red-400"># Logout</span>
<pre class="text-gray-300">curl -X POST http://127.0.0.1:3003/api/v1/auth/logout</pre>
<pre class="text-gray-300">curl -X POST {{ baseUrl }}/api/v1/auth/logout \
-H "X-API-Key: YOUR_API_KEY"</pre>
</div>
</div>
</div>
@@ -75,8 +76,9 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { httpFetch, getCurrentConfig, saveConfig } from '@/api/client'
const username = ref('')
const password = ref('')
@@ -85,50 +87,40 @@ const loading = ref(false)
const showPassword = ref(false)
const router = useRouter()
const baseUrl = computed(() => getCurrentConfig().api_base_url)
const handleLogin = async () => {
error.value = ''
loading.value = true
try {
const response = await fetch('http://127.0.0.1:3003/api/v1/auth/login', {
const config = getCurrentConfig()
const data = await httpFetch<{
success: boolean
message?: string
api_key: string
user: Record<string, any>
}>(`${config.api_base_url}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: username.value,
password: password.value
})
});
body: JSON.stringify({ username: username.value, password: password.value })
})
if (response.ok) {
const data = await response.json();
if (data.success) {
// Save User Info
localStorage.setItem('momentry_user', JSON.stringify(data.user));
// Save API Key
localStorage.setItem('momentry_api_key', data.api_key);
// Update Config
const config = {
api_base_url: 'http://127.0.0.1:3003',
api_key: data.api_key,
timeout_secs: 30,
};
localStorage.setItem('portal_config', JSON.stringify(config));
router.push('/home');
} else {
error.value = data.message || 'Login failed';
}
} else if (response.status === 401) {
error.value = 'Invalid username or password';
if (data.success) {
localStorage.setItem('momentry_user', JSON.stringify(data.user))
localStorage.setItem('momentry_api_key', data.api_key)
saveConfig({ ...config, api_key: data.api_key })
router.push('/home')
} else {
error.value = 'Server error';
error.value = data.message || 'Login failed'
}
} catch (e: any) {
if (e.message?.includes('401')) {
error.value = 'Invalid username or password'
} else {
error.value = 'Connection error. Is the server running?'
}
} catch (e) {
error.value = 'Connection error. Is the server running?';
} finally {
loading.value = false;
loading.value = false
}
}
</script>

View File

@@ -95,10 +95,9 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { listIdentities } from '@/api/client'
import { listIdentities, httpFetch, getCurrentConfig } from '@/api/client'
import { useRouter } from 'vue-router'
import PersonThumbnail from '@/components/PersonThumbnail.vue'
import { invoke } from '@tauri-apps/api/core'
interface PersonProfile {
name: string | null
@@ -142,11 +141,12 @@ const loadPersons = async () => {
}
}
const registerPerson = async (personId: string, videoUuid: string) => {
const registerPerson = async (personId: string, _videoUuid: string) => {
try {
await invoke('register_identity', {
personId,
videoUuid
const config = getCurrentConfig()
await httpFetch(`${config.api_base_url}/api/v1/identity`, {
method: 'POST',
body: JSON.stringify({ face_json_path: personId, identity_name: personId })
})
alert('註冊成功!')
await loadPersons()

View File

@@ -55,17 +55,18 @@
type: <span class="text-blue-300">{{ hit.id.split('_')[0] }}</span>
uuid: <span class="text-purple-300">{{ hit.vid }}</span>
</span>
<!-- Parent ID Badge -->
<span v-if="hit.parent_id" class="bg-yellow-900 text-yellow-300 px-2 py-1 rounded text-sm">
parent_id: {{ hit.parent_id }}
</span>
<!-- Visual Stats Badge -->
<span v-if="hit.has_visual_stats" class="bg-cyan-900 text-cyan-300 px-2 py-1 rounded text-sm">
📦 Visual
</span>
<span v-else class="bg-gray-700 text-gray-400 px-2 py-1 rounded text-sm">
No Visual
Visual
</span>
<button
@click.stop="playChunk(hit)"
class="bg-green-700 hover:bg-green-600 px-3 py-1 rounded text-sm transition ml-auto"
>
Play
</button>
</div>
<p class="text-lg mb-3">{{ hit.text }}</p>
<!-- Frame Range (精確定位) -->
@@ -102,16 +103,61 @@
找不到符合的結果
</div>
</div>
<!-- Player Modal -->
<div v-if="player.visible" class="fixed inset-0 bg-black bg-opacity-80 z-50 flex items-center justify-center p-4" @click.self="player.visible = false">
<div class="bg-gray-900 rounded-lg w-full max-w-4xl border border-gray-700">
<div class="flex items-center justify-between p-4 border-b border-gray-700">
<span class="text-white font-semibold">{{ player.title }}</span>
<span class="text-gray-400 text-sm">{{ player.start.toFixed(1) }}s - {{ player.end.toFixed(1) }}s</span>
<button @click="player.visible = false" class="text-gray-400 hover:text-white text-xl">&times;</button>
</div>
<video
ref="videoPlayer"
:src="player.url"
class="w-full"
controls
autoplay
@loadedmetadata="seekToStart"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, reactive } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { searchVideos } from '@/api/client'
import { searchVideos, getCurrentConfig } from '@/api/client'
const router = useRouter()
const route = useRoute()
const videoPlayer = ref<HTMLVideoElement | null>(null)
const player = reactive({
visible: false,
url: '',
title: '',
start: 0,
end: 0
})
const playChunk = (hit: SearchHit) => {
const config = getCurrentConfig()
player.visible = true
player.title = hit.text.substring(0, 80)
player.start = hit.start
player.end = hit.end
player.url = `${config.api_base_url}/api/v1/file/${hit.vid}/video#t=${hit.start},${hit.end}`
}
const seekToStart = () => {
const el = videoPlayer.value
if (el) {
el.currentTime = player.start
setTimeout(() => { if (el.currentTime > player.end) el.pause() }, (player.end - player.start) * 1000 + 2000)
}
}
onMounted(() => {
localStorage.removeItem('searchState')