521 lines
17 KiB
PHP
521 lines
17 KiB
PHP
<?php
|
|
/**
|
|
* Template Name: API Demo - 查詢 (Query)
|
|
* Description: Demonstrates file and identity query APIs
|
|
*/
|
|
get_header();
|
|
?>
|
|
|
|
<div class="site-content">
|
|
<h1>Momentry API Demo - 查詢</h1>
|
|
<p class="page-description">示範檔案查詢、身份查詢、處理狀態等查詢類 API 的操作</p>
|
|
|
|
<div class="api-nav">
|
|
<a href="/api-demo-query/" class="nav-item active">查詢</a>
|
|
<a href="/api-demo-display/" class="nav-item">展示</a>
|
|
<a href="/api-demo-operation/" class="nav-item">操作</a>
|
|
<a href="/api-demo-application/" class="nav-item">應用</a>
|
|
</div>
|
|
|
|
<!-- 查詢 1: 檔案查詢 -->
|
|
<div class="api-section">
|
|
<h2>1. 檔案查詢 (GET /api/v1/files/:uuid)</h2>
|
|
<p>透過 file_uuid 查詢檔案的完整資訊,包含元數據、處理狀態、分類標籤等。</p>
|
|
|
|
<div class="api-tester">
|
|
<div class="input-group">
|
|
<label for="file_uuid">File UUID:</label>
|
|
<input type="text" id="file_uuid" placeholder="輸入 file_uuid 或從下方列表選擇">
|
|
<button class="btn btn-primary" onclick="queryFile()">查詢</button>
|
|
</div>
|
|
<div class="response-panel" id="file_query_result"></div>
|
|
</div>
|
|
|
|
<div class="file-list-panel">
|
|
<h3>最近註冊的檔案</h3>
|
|
<button class="btn btn-secondary" onclick="loadRecentFiles()">載入列表</button>
|
|
<div id="recent_files_list"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 查詢 2: 身份查詢 -->
|
|
<div class="api-section">
|
|
<h2>2. 身份查詢 (GET /api/v1/identities/:uuid)</h2>
|
|
<p>查詢跨檔案的全域身份資訊,包含關聯的檔案、臉部特徵、品質分數等。</p>
|
|
|
|
<div class="api-tester">
|
|
<div class="input-group">
|
|
<label for="identity_uuid">Identity UUID:</label>
|
|
<input type="text" id="identity_uuid" placeholder="輸入 identity_uuid">
|
|
<button class="btn btn-primary" onclick="queryIdentity()">查詢</button>
|
|
</div>
|
|
<div class="response-panel" id="identity_query_result"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 查詢 3: 處理狀態 -->
|
|
<div class="api-section">
|
|
<h2>3. 處理狀態查詢 (GET /api/v1/jobs/:uuid/status)</h2>
|
|
<p>查詢檔案的處理進度與各處理器狀態 (ASR, YOLO, Face, OCR 等)。</p>
|
|
|
|
<div class="api-tester">
|
|
<div class="input-group">
|
|
<label for="job_uuid">File UUID (Job):</label>
|
|
<input type="text" id="job_uuid" placeholder="輸入 file_uuid 查詢處理狀態">
|
|
<button class="btn btn-primary" onclick="queryJobStatus()">查詢</button>
|
|
</div>
|
|
<div class="response-panel" id="job_status_result"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 查詢 4: 遷移歷史 -->
|
|
<div class="api-section">
|
|
<h2>4. 檔案遷移歷史 (GET /api/v1/files/:uuid/history)</h2>
|
|
<p>查詢檔案因移動而產生的身份變更鏈,追蹤 parent_uuid 關聯。</p>
|
|
|
|
<div class="api-tester">
|
|
<div class="input-group">
|
|
<label for="history_uuid">File UUID:</label>
|
|
<input type="text" id="history_uuid" placeholder="輸入 file_uuid 查詢歷史">
|
|
<button class="btn btn-primary" onclick="queryFileHistory()">查詢</button>
|
|
</div>
|
|
<div class="response-panel" id="file_history_result"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 查詢 5: 語義搜尋 -->
|
|
<div class="api-section">
|
|
<h2>5. 語義搜尋 (POST /api/v1/search)</h2>
|
|
<p>使用自然語言搜尋相關的影片片段或身份。</p>
|
|
|
|
<div class="api-tester">
|
|
<div class="input-group">
|
|
<label for="search_query">搜尋查詢:</label>
|
|
<input type="text" id="search_query" placeholder="例如: 戴眼鏡的男性在說話">
|
|
<select id="search_type">
|
|
<option value="chunk">片段搜尋</option>
|
|
<option value="identity">身份搜尋</option>
|
|
<option value="face">臉部搜尋</option>
|
|
</select>
|
|
<button class="btn btn-primary" onclick="semanticSearch()">搜尋</button>
|
|
</div>
|
|
<div class="response-panel" id="search_result"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 视频播放模态窗口 -->
|
|
<div id="video-modal" class="modal">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<span class="close-btn">×</span>
|
|
</div>
|
|
<div class="modal-body">
|
|
<video id="video-player" controls width="100%">
|
|
<source src="" type="video/mp4">
|
|
</video>
|
|
<div class="video-info">
|
|
<div class="chunk-time"></div>
|
|
<div class="chunk-text"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.api-nav {
|
|
display: flex;
|
|
gap: 1rem;
|
|
margin-bottom: 2rem;
|
|
padding: 1rem;
|
|
background: var(--card-background);
|
|
border-radius: var(--border-radius);
|
|
box-shadow: var(--shadow);
|
|
}
|
|
.api-nav .nav-item {
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 4px;
|
|
text-decoration: none;
|
|
color: var(--text-secondary);
|
|
font-weight: 500;
|
|
}
|
|
.api-nav .nav-item.active {
|
|
background: var(--primary-color);
|
|
color: white;
|
|
}
|
|
.api-section {
|
|
background: var(--card-background);
|
|
border-radius: var(--border-radius);
|
|
padding: 1.5rem;
|
|
margin-bottom: 1.5rem;
|
|
box-shadow: var(--shadow);
|
|
}
|
|
.api-section h2 {
|
|
margin-top: 0;
|
|
color: var(--primary-color);
|
|
}
|
|
.api-tester {
|
|
margin: 1rem 0;
|
|
}
|
|
.input-group {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
.input-group label {
|
|
font-weight: 500;
|
|
min-width: 120px;
|
|
}
|
|
.input-group input,
|
|
.input-group select {
|
|
padding: 0.5rem;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 4px;
|
|
font-size: 0.875rem;
|
|
flex: 1;
|
|
min-width: 200px;
|
|
}
|
|
.btn-primary {
|
|
background: var(--primary-color);
|
|
color: white;
|
|
padding: 0.5rem 1rem;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
}
|
|
.btn-secondary {
|
|
background: var(--secondary-color);
|
|
color: white;
|
|
padding: 0.5rem 1rem;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
}
|
|
.response-panel {
|
|
margin-top: 1rem;
|
|
padding: 1rem;
|
|
background: #f1f5f9;
|
|
border-radius: 4px;
|
|
font-family: monospace;
|
|
font-size: 0.75rem;
|
|
max-height: 400px;
|
|
overflow-y: auto;
|
|
white-space: pre-wrap;
|
|
display: none;
|
|
}
|
|
.response-panel.has-content {
|
|
display: block;
|
|
}
|
|
.file-list-panel {
|
|
margin-top: 1rem;
|
|
}
|
|
.file-item {
|
|
padding: 0.75rem;
|
|
background: #f8fafc;
|
|
border-radius: 4px;
|
|
margin-bottom: 0.5rem;
|
|
cursor: pointer;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
.file-item:hover {
|
|
background: #e2e8f0;
|
|
}
|
|
.file-item .uuid {
|
|
font-family: monospace;
|
|
font-size: 0.75rem;
|
|
color: var(--primary-color);
|
|
}
|
|
.file-item .name {
|
|
font-weight: 500;
|
|
}
|
|
.badge-status {
|
|
padding: 0.25rem 0.5rem;
|
|
border-radius: 4px;
|
|
font-size: 0.7rem;
|
|
font-weight: 600;
|
|
}
|
|
.badge-status.pending { background: #fef3c7; color: #92400e; }
|
|
.badge-status.processing { background: #dbeafe; color: #1e40af; }
|
|
.badge-status.completed { background: #d1fae5; color: #065f46; }
|
|
.badge-status.error { background: #fee2e2; color: #991b1b; }
|
|
|
|
/* 视频播放模态窗口 */
|
|
.modal {
|
|
display: none;
|
|
position: fixed;
|
|
z-index: 1000;
|
|
left: 0;
|
|
top: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background-color: rgba(0,0,0,0.8);
|
|
}
|
|
.modal-content {
|
|
margin: 5% auto;
|
|
width: 80%;
|
|
max-width: 1200px;
|
|
background: #1a1a1a;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
}
|
|
.modal-header {
|
|
padding: 15px;
|
|
background: #2d2d2d;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
}
|
|
.close-btn {
|
|
color: #fff;
|
|
font-size: 28px;
|
|
cursor: pointer;
|
|
}
|
|
#video-player {
|
|
width: 100%;
|
|
max-height: 70vh;
|
|
}
|
|
.video-info {
|
|
padding: 15px;
|
|
background: #2d2d2d;
|
|
color: #fff;
|
|
}
|
|
.chunk-item.clickable {
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
padding: 0.75rem;
|
|
background: #f8fafc;
|
|
border-radius: 4px;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
.chunk-item.clickable:hover {
|
|
background: #dbeafe;
|
|
}
|
|
.play-btn {
|
|
margin-top: 8px;
|
|
padding: 4px 12px;
|
|
background: var(--primary-color);
|
|
color: white;
|
|
border-radius: 4px;
|
|
font-size: 0.8rem;
|
|
border: none;
|
|
cursor: pointer;
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
const API_BASE = 'http://localhost:3002/api/v1';
|
|
const API_KEY = 'muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69';
|
|
|
|
const URL_WITH_KEY = `${API_BASE}?api_key=${API_KEY}`;
|
|
const URL_WITHOUT_KEY = API_BASE;
|
|
|
|
function showResult(elementId, data) {
|
|
const el = document.getElementById(elementId);
|
|
el.textContent = JSON.stringify(data, null, 2);
|
|
el.classList.add('has-content');
|
|
}
|
|
|
|
function showError(elementId, message) {
|
|
const el = document.getElementById(elementId);
|
|
el.textContent = 'Error: ' + message;
|
|
el.classList.add('has-content');
|
|
el.style.color = '#ef4444';
|
|
}
|
|
|
|
function playVideoChunk(fileUuid, startTime, endTime, text) {
|
|
const videoUrl = `${API_BASE}/file/${fileUuid}/video?api_key=${API_KEY}&start_time=${startTime}&end_time=${endTime}`;
|
|
const modal = document.getElementById('video-modal');
|
|
const video = document.getElementById('video-player');
|
|
|
|
video.pause();
|
|
video.src = videoUrl;
|
|
video.load();
|
|
|
|
document.querySelector('.chunk-time').textContent =
|
|
`${startTime.toFixed(1)}s - ${endTime.toFixed(1)}s`;
|
|
document.querySelector('.chunk-text').textContent = text || '';
|
|
|
|
modal.style.display = 'block';
|
|
|
|
video.onloadeddata = () => {
|
|
video.play().catch(e => console.warn('playVideoChunk: autoplay blocked', e));
|
|
};
|
|
|
|
video.onerror = () => {
|
|
console.error('playVideoChunk: video error', video.error);
|
|
};
|
|
}
|
|
|
|
function closeVideoModal() {
|
|
const video = document.getElementById('video-player');
|
|
document.getElementById('video-modal').style.display = 'none';
|
|
video.pause();
|
|
video.removeAttribute('src');
|
|
video.load();
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
document.querySelector('#video-modal .close-btn').onclick = closeVideoModal;
|
|
|
|
window.onclick = (event) => {
|
|
if (event.target === document.getElementById('video-modal')) {
|
|
closeVideoModal();
|
|
}
|
|
};
|
|
|
|
document.getElementById('search_result').addEventListener('click', function(e) {
|
|
const item = e.target.closest('.chunk-item.clickable');
|
|
if (!item) return;
|
|
const fu = item.dataset.fileUuid;
|
|
const st = parseFloat(item.dataset.startTime);
|
|
const et = parseFloat(item.dataset.endTime);
|
|
const txt = item.dataset.text || '';
|
|
if (fu && !isNaN(st) && !isNaN(et)) {
|
|
playVideoChunk(fu, st, et, txt);
|
|
}
|
|
});
|
|
});
|
|
|
|
async function queryFile() {
|
|
const uuid = document.getElementById('file_uuid').value.trim();
|
|
if (!uuid) return alert('請輸入 file_uuid');
|
|
|
|
try {
|
|
const res = await fetch(`${API_BASE}/files/${uuid}`);
|
|
const data = await res.json();
|
|
showResult('file_query_result', data);
|
|
} catch (e) {
|
|
showError('file_query_result', e.message);
|
|
}
|
|
}
|
|
|
|
async function loadRecentFiles() {
|
|
const el = document.getElementById('recent_files_list');
|
|
el.innerHTML = '<p>載入中...</p>';
|
|
|
|
try {
|
|
const res = await fetch(`${API_BASE}/files?limit=10&sort=created_at&order=desc`);
|
|
const data = await res.json();
|
|
|
|
if (data.files && data.files.length > 0) {
|
|
el.innerHTML = data.files.map(f => `
|
|
<div class="file-item" onclick="document.getElementById('file_uuid').value='${f.file_uuid}'; queryFile();">
|
|
<span class="name">${f.file_name}</span>
|
|
<span class="uuid">${f.file_uuid}</span>
|
|
<span class="badge-status ${f.status}">${f.status || 'unknown'}</span>
|
|
</div>
|
|
`).join('');
|
|
} else {
|
|
el.innerHTML = '<p>沒有找到檔案</p>';
|
|
}
|
|
} catch (e) {
|
|
el.innerHTML = '<p style="color:#ef4444">載入失敗: ' + e.message + '</p>';
|
|
}
|
|
}
|
|
|
|
async function queryIdentity() {
|
|
const uuid = document.getElementById('identity_uuid').value.trim();
|
|
if (!uuid) return alert('請輸入 identity_uuid');
|
|
|
|
try {
|
|
const res = await fetch(`${API_BASE}/identities/${uuid}`);
|
|
const data = await res.json();
|
|
showResult('identity_query_result', data);
|
|
} catch (e) {
|
|
showError('identity_query_result', e.message);
|
|
}
|
|
}
|
|
|
|
async function queryJobStatus() {
|
|
const uuid = document.getElementById('job_uuid').value.trim();
|
|
if (!uuid) return alert('請輸入 file_uuid');
|
|
|
|
try {
|
|
const res = await fetch(`${API_BASE}/jobs/${uuid}/status`);
|
|
const data = await res.json();
|
|
showResult('job_status_result', data);
|
|
} catch (e) {
|
|
showError('job_status_result', e.message);
|
|
}
|
|
}
|
|
|
|
async function queryFileHistory() {
|
|
const uuid = document.getElementById('history_uuid').value.trim();
|
|
if (!uuid) return alert('請輸入 file_uuid');
|
|
|
|
try {
|
|
const res = await fetch(`${API_BASE}/files/${uuid}/history`);
|
|
const data = await res.json();
|
|
showResult('file_history_result', data);
|
|
} catch (e) {
|
|
showError('file_history_result', e.message);
|
|
}
|
|
}
|
|
|
|
async function semanticSearch() {
|
|
const query = document.getElementById('search_query').value.trim();
|
|
const type = document.getElementById('search_type').value;
|
|
if (!query) return alert('請輸入搜尋查詞');
|
|
|
|
const resultEl = document.getElementById('search_result');
|
|
resultEl.innerHTML = '<p style="padding: 1rem;">搜尋中,請稍候...(身份搜尋可能需要較長時間)</p>';
|
|
resultEl.classList.add('has-content');
|
|
|
|
try {
|
|
let resultsHtml = '';
|
|
if (type === 'identity') {
|
|
const res = await fetch(`${API_BASE}/identities/search?q=${encodeURIComponent(query)}&api_key=${API_KEY}`);
|
|
const data = await res.json();
|
|
const results = data.results || [];
|
|
resultsHtml = results.map(i => {
|
|
const textAttr = (i.text_content || '').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
const startTime = i.start_time || (i.start_frame && i.fps ? i.start_frame / i.fps : 0);
|
|
const endTime = i.end_time || (i.end_frame && i.fps ? i.end_frame / i.fps : startTime + 10);
|
|
return `<div class="chunk-item clickable"
|
|
data-file-uuid="${i.file_uuid}"
|
|
data-start-time="${startTime}"
|
|
data-end-time="${endTime}"
|
|
data-text="${textAttr}">
|
|
<div style="font-weight: 600; color: #1e40af;">${i.name} (${i.source || 'unknown'})</div>
|
|
<div class="chunk-time">${startTime.toFixed(1)}s - ${endTime.toFixed(1)}s${i.chunk_id ? ` | ${i.chunk_id}` : ''}</div>
|
|
<div style="margin-top: 4px;font-size:13px;color:#555;">${i.text_content || ''}</div>
|
|
<button class="play-btn" style="margin-top:6px;">▶ 播放片段</button>
|
|
</div>`;
|
|
}).join('');
|
|
resultEl.innerHTML = `<p style="padding: 0.5rem; background: #d1fae5; border-radius: 4px; margin-bottom: 0.5rem;">找到 ${data.total || 0} 個身份相關片段</p>${resultsHtml || '<p>沒有結果</p>'}`;
|
|
} else {
|
|
const typesParam = type === 'face' ? ['frame'] : ['chunk'];
|
|
const res = await fetch(`${API_BASE}/search/universal?api_key=${API_KEY}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ query, types: typesParam })
|
|
});
|
|
const data = await res.json();
|
|
const results = data.results || [];
|
|
resultsHtml = results.map(r => {
|
|
const textAttr = (r.text || '').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
const startTime = r.start_time || (r.start_frame && r.fps ? r.start_frame / r.fps : 0);
|
|
const endTime = r.end_time || (r.end_frame && r.fps ? r.end_frame / r.fps : startTime + 10);
|
|
return `<div class="chunk-item clickable"
|
|
data-file-uuid="${r.file_uuid}"
|
|
data-start-time="${startTime}"
|
|
data-end-time="${endTime}"
|
|
data-text="${textAttr}">
|
|
<div class="chunk-time">${startTime.toFixed(1)}s - ${endTime.toFixed(1)}s${r.chunk_id ? ` | ${r.chunk_id}` : ''}</div>
|
|
<div style="margin-top: 4px;font-size:13px;color:#555;">${r.text || ''}</div>
|
|
<div class="badge-status completed" style="margin-top: 4px;">相似度: ${(r.score * 100).toFixed(1)}%</div>
|
|
<button class="play-btn" style="margin-top:6px;">▶ 播放片段</button>
|
|
</div>`;
|
|
}).join('');
|
|
resultEl.innerHTML = `<p style="padding: 0.5rem; background: #d1fae5; border-radius: 4px; margin-bottom: 0.5rem;">找到 ${data.total || 0} 個片段</p>${resultsHtml || '<p>沒有結果</p>'}`;
|
|
}
|
|
} catch (e) {
|
|
resultEl.innerHTML = '<p style="color:#ef4444; padding: 1rem;">錯誤: ' + e.message + '</p>';
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<?php get_footer(); ?>
|