feat: media API (video/bbox/thumbnail), UUID unification, dot matrix text, portal fixes, API dictionary V1.3
This commit is contained in:
@@ -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 ===')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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 || []
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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">×</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')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user