Initial commit: WordPress wp-content (themes, plugins, languages)

- Theme: momentry (custom theme with REST API routes)
- Plugins: code-snippets (contains all API proxies)
- Languages: zh_TW translations
- Excludes: cache, backups, uploads, logs
This commit is contained in:
OpenCode
2026-05-29 19:07:56 +08:00
commit 09ef1f000f
6521 changed files with 867163 additions and 0 deletions

2
themes/index.php Executable file
View File

@@ -0,0 +1,2 @@
<?php
// Silence is golden.

View File

@@ -0,0 +1,108 @@
# Momentry API Demo Pages
## 頁面分類
Momentry Portal 提供四個 API 示範頁面,涵蓋查詢、展示、操作、應用四大類別:
| 頁面 | Template Name | 用途 |
|------|---------------|------|
| 查詢 | `API Demo - 查詢` | 檔案查詢、身份查詢、處理狀態、遷移歷史、語義搜尋 |
| 展示 | `API Demo - 展示` | 檔案詳情儀表板、身份視覺化、片段展示、分類結果 |
| 操作 | `API Demo - 操作` | 檔案註冊、身份綁定、處理觸發、身份合併、處理器重試 |
| 應用 | `API Demo - 應用` | 完整工作流程、身份追蹤、遷移示範、批次處理、語義搜尋工作流 |
## 設定步驟
### 1. 確認 Theme 檔案已部署
以下檔案位於 `/Users/accusys/wordpress/web/wp-content/themes/momentry/`:
```
page-api-demo-query.php # 查詢頁面
page-api-demo-display.php # 展示頁面
page-api-demo-operation.php # 操作頁面
page-api-demo-application.php # 應用頁面
style.css # 已更新共用樣式
```
### 2. 在 WordPress 建立頁面
1. 進入 WordPress 後台 (`http://localhost/wp-admin`)
2. 點擊 **Pages > Add New**
3. 建立以下四個頁面:
| 頁面標題 | URL Slug | Template |
|----------|----------|----------|
| API Demo - 查詢 | `api-demo-query` | API Demo - 查詢 |
| API Demo - 展示 | `api-demo-display` | API Demo - 展示 |
| API Demo - 操作 | `api-demo-operation` | API Demo - 操作 |
| API Demo - 應用 | `api-demo-application` | API Demo - 應用 |
4. 建立時,在右側 **Page Attributes** 選擇對應的 **Template**
5. 點擊 **Publish**
### 3. 啟動 Momentry Playground Server
API 示範頁面需要連線到 Momentry Playground API server (port 3003):
```bash
cargo run --bin momentry_playground -- server --host 0.0.0.0 --port 3003
```
### 4. 訪問示範頁面
- 查詢: `http://localhost/api-demo-query/`
- 展示: `http://localhost/api-demo-display/`
- 操作: `http://localhost/api-demo-operation/`
- 應用: `http://localhost/api-demo-application/`
## API 端點說明
### 查詢類 API
| 端點 | 方法 | 說明 |
|------|------|------|
| `/api/v1/files/:uuid` | GET | 查詢檔案詳細資訊 |
| `/api/v1/files` | GET | 查詢檔案列表 |
| `/api/v1/identities/:uuid` | GET | 查詢身份資訊 |
| `/api/v1/jobs/:uuid/status` | GET | 查詢處理狀態 |
| `/api/v1/files/:uuid/history` | GET | 查詢遷移歷史 |
| `/api/v1/search` | POST | 語義搜尋 |
### 操作類 API
| 端點 | 方法 | 說明 |
|------|------|------|
| `/api/v1/register` | POST | 註冊檔案 |
| `/api/v1/identities/bind` | POST | 綁定身份 |
| `/api/v1/files/:uuid/process` | POST | 觸發處理 |
| `/api/v1/identities/merge` | POST | 合併身份 |
| `/api/v1/jobs/:uuid/retry` | POST | 重試處理器 |
## 工作流程示範
### 完整工作流程 (應用頁面)
1. **註冊檔案**: 輸入影片路徑,呼叫 `/register`
2. **查詢處理狀態**: 定期檢查 `/jobs/:uuid/status` 直到完成
3. **查詢檢測結果**: 取得身份和片段資訊
4. **搜尋身份**: 展示檔案中檢測到的身份列表
### 檔案遷移示範 (應用頁面)
1. **原始註冊**: 註冊原始路徑的檔案
2. **模擬移動**: 使用新路徑重新註冊,系統產生新的 file_uuid
3. **查詢歷史**: 透過 `/files/:uuid/history` 查看遷移鏈
### 批次處理 (應用頁面)
1. 輸入多個檔案路徑 (每行一個)
2. 批次呼叫 `/register`
3. 顯示進度條和每個檔案的註冊結果
## 注意事項
- API base URL 預設為 `http://localhost:3003/api/v1`
- 如需更改,請修改各頁面的 `const API_BASE = '...'` 設定
- 確保 Playground server 已啟動且 CORS 設定允許來自 WordPress 的請求
- 部分 API 端點可能需要實作後才能正常使用

View File

@@ -0,0 +1,23 @@
<?php
get_header();
?>
<div id="primary" class="content-area">
<main id="main" class="site-main">
<div class="site-content">
<h1>Identity Management</h1>
<p>Manage and view identity reference vectors and angle coverage.</p>
<div class="identity-list">
<p>Loading identities...</p>
</div>
<div class="pagination-container"></div>
<div class="modal-container"></div>
</div>
</main>
</div>
<?php
get_footer();

View File

@@ -0,0 +1,249 @@
(function($) {
'use strict';
const MomentryPortal = {
apiRoot: momentryApi.root,
nonce: momentryApi.nonce,
init: function() {
this.bindEvents();
this.loadIdentities();
},
bindEvents: function() {
$(document).on('click', '.identity-card', this.showIdentityDetail.bind(this));
$(document).on('click', '.angle-coverage-btn', this.showAngleCoverage.bind(this));
$(document).on('click', '.body-actions-btn', this.showBodyActions.bind(this));
},
request: function(endpoint, method, data) {
return $.ajax({
url: this.apiRoot + endpoint,
method: method,
data: data,
beforeSend: function(xhr) {
xhr.setRequestHeader('X-WP-Nonce', momentryApi.nonce);
},
});
},
loadIdentities: function(page = 1) {
const self = this;
this.request('momentry/v1/identities', 'GET', {page: page, per_page: 20})
.done(function(response) {
if (response.success) {
self.renderIdentityList(response.data);
self.renderPagination(response.pagination);
}
})
.fail(function(error) {
console.error('Failed to load identities:', error);
$('.identity-list').html('<p class="error">Failed to load identities. Please try again.</p>');
});
},
renderIdentityList: function(identities) {
const html = identities.map(function(identity) {
return `
<div class="identity-card" data-uuid="${identity.uuid}">
<h3 class="identity-name">${identity.name}</h3>
<div class="identity-meta">
<span class="source badge">${identity.source}</span>
<span class="references">${identity.total_references} vectors</span>
</div>
<div class="quality-score">
<div class="score-bar" style="width: ${(identity.quality_avg || 0) * 100}%"></div>
<span class="score-value">${((identity.quality_avg || 0) * 100).toFixed(1)}%</span>
</div>
${identity.trace_duration ? `
<div class="trace-stats">
<span class="duration">${identity.trace_duration}s</span>
<span class="confidence">${(identity.trace_confidence * 100).toFixed(1)}% confidence</span>
</div>
` : ''}
<div class="identity-actions">
<button class="btn angle-coverage-btn" data-uuid="${identity.uuid}">
Angle Coverage
</button>
<button class="btn body-actions-btn" data-uuid="${identity.uuid}">
Body Actions
</button>
</div>
</div>
`;
}).join('');
$('.identity-list').html(html);
},
renderPagination: function(pagination) {
const html = `
<div class="pagination">
<button class="btn prev" ${pagination.page === 1 ? 'disabled' : ''}>
Previous
</button>
<span class="page-info">
Page ${pagination.page} of ${pagination.total_pages}
</span>
<button class="btn next" ${pagination.page >= pagination.total_pages ? 'disabled' : ''}>
Next
</button>
</div>
`;
$('.pagination-container').html(html);
},
showIdentityDetail: function(e) {
const uuid = $(e.currentTarget).data('uuid');
const self = this;
this.request(`momentry/v1/identities/${uuid}`, 'GET')
.done(function(response) {
if (response.success) {
self.renderIdentityDetail(response.data);
}
})
.fail(function(error) {
console.error('Failed to load identity detail:', error);
});
},
renderIdentityDetail: function(identity) {
const html = `
<div class="identity-detail-modal">
<h2>${identity.name}</h2>
<div class="detail-section">
<h3>Reference Vectors</h3>
<p>Total: ${identity.reference_vectors.total}</p>
<p>Angles: ${identity.reference_vectors.angles.join(', ') || 'None'}</p>
<p>Quality Avg: ${((identity.reference_vectors.quality_avg || 0) * 100).toFixed(1)}%</p>
</div>
${identity.trace_stats ? `
<div class="detail-section">
<h3>Trace Statistics</h3>
<p>Duration: ${identity.trace_stats.duration_seconds}s</p>
<p>Confidence: ${(identity.trace_stats.avg_confidence * 100).toFixed(1)}%</p>
<p>Appearances: ${identity.trace_stats.total_appearances} frames</p>
</div>
` : ''}
<button class="btn close-modal">Close</button>
</div>
`;
$('.modal-container').html(html).show();
},
showAngleCoverage: function(e) {
e.stopPropagation();
const uuid = $(e.currentTarget).data('uuid');
const self = this;
this.request(`momentry/v1/identities/${uuid}/angle-coverage`, 'GET')
.done(function(response) {
if (response.success) {
self.renderAngleCoverage(response.data);
}
})
.fail(function(error) {
console.error('Failed to load angle coverage:', error);
});
},
renderAngleCoverage: function(data) {
const angles = Object.entries(data.angles).map(function([angle, stats]) {
const statusIcon = stats.status === 'dominant' ? '✅' :
stats.status === 'present' ? '✅' : '⚠️';
const count = stats.count > 0 ? `${stats.count} frames` : '0 frames';
const quality = stats.quality_avg ? `(${(stats.quality_avg * 100).toFixed(1)}%)` : '';
return `
<div class="angle-item ${stats.status}">
<span class="status-icon">${statusIcon}</span>
<span class="angle-name">${angle.replace('_', ' ')}</span>
<span class="count">${count} ${quality}</span>
</div>
`;
}).join('');
const html = `
<div class="angle-coverage-modal">
<h2>Angle Coverage</h2>
<div class="coverage-score">
Coverage Score: ${data.score}%
</div>
<div class="angles-list">
${angles}
</div>
<p class="recommendation">${data.recommendation}</p>
<button class="btn close-modal">Close</button>
</div>
`;
$('.modal-container').html(html).show();
},
showBodyActions: function(e) {
e.stopPropagation();
const uuid = $(e.currentTarget).data('uuid');
const self = this;
this.request(`momentry/v1/identities/${uuid}/body-actions`, 'GET')
.done(function(response) {
if (response.success) {
self.renderBodyActions(response.data);
}
})
.fail(function(error) {
console.error('Failed to load body actions:', error);
});
},
renderBodyActions: function(data) {
const actions = Object.entries(data.actions || {}).map(function([category, items]) {
const itemsHtml = items.map(function(item) {
return `<span class="action-badge">${item.action}: ${item.count}</span>`;
}).join('');
return `
<div class="action-category">
<h4>${category}</h4>
<div class="action-list">${itemsHtml}</div>
</div>
`;
}).join('');
const html = `
<div class="body-actions-modal">
<h2>Body Actions: ${data.name}</h2>
<div class="actions-container">
${actions || '<p>No body actions detected</p>'}
</div>
<button class="btn close-modal">Close</button>
</div>
`;
$('.modal-container').html(html).show();
},
};
$(document).ready(function() {
MomentryPortal.init();
$(document).on('click', '.close-modal', function() {
$('.modal-container').hide();
});
$(document).on('click', '.pagination .prev:not([disabled])', function() {
const page = parseInt($('.page-info').text().match(/Page (\d+)/)[1]);
MomentryPortal.loadIdentities(page - 1);
});
$(document).on('click', '.pagination .next:not([disabled])', function() {
const page = parseInt($('.page-info').text().match(/Page (\d+)/)[1]);
MomentryPortal.loadIdentities(page + 1);
});
});
})(jQuery);

View File

@@ -0,0 +1,14 @@
<?php
defined('ABSPATH') || exit;
?>
<footer id="colophon" class="site-footer">
<div class="site-info">
<p>&copy; <?php echo date('Y'); ?> <?php bloginfo('name'); ?>. All rights reserved.</p>
<p>Powered by <a href="https://momentry.ai">Momentry</a></p>
</div>
</footer>
</div>
<?php wp_footer(); ?>
</body>
</html>

View File

@@ -0,0 +1,47 @@
<?php
defined('ABSPATH') || exit;
define('MOMENTRY_THEME_VERSION', '1.0.0');
define('MOMENTRY_THEME_DIR', get_template_directory());
define('MOMENTRY_THEME_URI', get_template_directory_uri());
require_once MOMENTRY_THEME_DIR . '/inc/api/class-identity-api.php';
require_once MOMENTRY_THEME_DIR . '/inc/api/class-database.php';
add_action('after_setup_theme', 'momentry_theme_setup');
function momentry_theme_setup(): void {
add_theme_support('title-tag');
add_theme_support('post-thumbnails');
add_theme_support('html5', ['search-form', 'comment-form', 'comment-list', 'gallery', 'caption']);
load_theme_textdomain('momentry', MOMENTRY_THEME_DIR . '/languages');
}
add_action('wp_enqueue_scripts', 'momentry_enqueue_scripts');
function momentry_enqueue_scripts(): void {
wp_enqueue_style(
'momentry-style',
MOMENTRY_THEME_URI . '/style.css',
[],
MOMENTRY_THEME_VERSION
);
wp_enqueue_script(
'momentry-script',
MOMENTRY_THEME_URI . '/assets/js/main.js',
['jquery'],
MOMENTRY_THEME_VERSION,
true
);
wp_localize_script('momentry-script', 'momentryApi', [
'root' => esc_url_raw(rest_url()),
'nonce' => wp_create_nonce('wp_rest'),
]);
}
add_action('rest_api_init', 'momentry_register_api_routes');
function momentry_register_api_routes(): void {
$identity_api = new Momentry\Identity_API();
$identity_api->register_routes();
}

View File

@@ -0,0 +1,41 @@
<?php
defined('ABSPATH') || exit;
?>
<!DOCTYPE html>
<html <?php language_attributes(); ?>>
<head>
<meta charset="<?php bloginfo('charset'); ?>">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="profile" href="https://gmpg.org/xfn/11">
<?php wp_head(); ?>
</head>
<body <?php body_class(); ?>>
<?php wp_body_open(); ?>
<div id="page" class="site">
<header id="masthead" class="site-header">
<div class="site-branding">
<h1 class="site-title">
<a href="<?php echo esc_url(home_url('/')); ?>" rel="home">
<?php bloginfo('name'); ?>
</a>
</h1>
<?php
$description = get_bloginfo('description', 'display');
if ($description || is_customize_preview()) :
?>
<p class="site-description"><?php echo $description; ?></p>
<?php endif; ?>
</div>
<nav id="site-navigation" class="main-navigation">
<button class="menu-toggle" aria-controls="primary-menu" aria-expanded="false">
<?php esc_html_e('Primary Menu', 'momentry'); ?>
</button>
<?php
wp_nav_menu([
'theme_location' => 'primary',
'menu_id' => 'primary-menu',
]);
?>
</nav>
</header>

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Momentry;
use PDO;
use PDOException;
use RuntimeException;
defined('ABSPATH') || exit;
class Database {
private static ?PDO $instance = null;
public static function get_instance(): PDO {
if (self::$instance === null) {
self::$instance = self::create_connection();
}
return self::$instance;
}
private static function create_connection(): PDO {
$host = getenv('MOMENTRY_DB_HOST') ?: 'localhost';
$port = getenv('MOMENTRY_DB_PORT') ?: '5432';
$dbname = getenv('MOMENTRY_DB_NAME') ?: 'momentry';
$user = getenv('MOMENTRY_DB_USER') ?: 'accusys';
$password = getenv('MOMENTRY_DB_PASSWORD') ?: '';
$dsn = "pgsql:host={$host};port={$port};dbname={$dbname}";
try {
$pdo = new PDO($dsn, $user, $password, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
} catch (PDOException $e) {
throw new RuntimeException(
'Database connection failed: ' . $e->getMessage()
);
}
}
public static function get_schema(): string {
return getenv('MOMENTRY_DB_SCHEMA') ?: 'dev';
}
}

View File

@@ -0,0 +1,382 @@
<?php
declare(strict_types=1);
namespace Momentry;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
use PDO;
defined('ABSPATH') || exit;
class Identity_API {
private PDO $db;
private string $schema;
public function __construct() {
$this->db = Database::get_instance();
$this->schema = Database::get_schema();
}
public function register_routes(): void {
register_rest_route('momentry/v1', '/identities', [
'methods' => 'GET',
'callback' => [$this, 'get_identities'],
'permission_callback' => [$this, 'check_permission'],
]);
register_rest_route('momentry/v1', '/identities/(?P<uuid>[a-f0-9\-]{36})', [
'methods' => 'GET',
'callback' => [$this, 'get_identity_detail'],
'permission_callback' => [$this, 'check_permission'],
]);
register_rest_route('momentry/v1', '/identities/(?P<uuid>[a-f0-9\-]{36})/angle-coverage', [
'methods' => 'GET',
'callback' => [$this, 'get_angle_coverage'],
'permission_callback' => [$this, 'check_permission'],
]);
register_rest_route('momentry/v1', '/identities/(?P<uuid>[a-f0-9\-]{36})/body-actions', [
'methods' => 'GET',
'callback' => [$this, 'get_body_actions'],
'permission_callback' => [$this, 'check_permission'],
]);
register_rest_route('momentry/v1', '/identities/(?P<uuid>[a-f0-9\-]{36})/reference-vectors', [
'methods' => 'GET',
'callback' => [$this, 'get_reference_vectors'],
'permission_callback' => [$this, 'check_permission'],
]);
}
public function check_permission(): bool {
return current_user_can('read') || $this->validate_api_key();
}
private function validate_api_key(): bool {
$api_key = $_SERVER['HTTP_X_MOMENTRY_API_KEY'] ?? '';
$valid_key = getenv('MOMENTRY_API_KEY');
if (!$valid_key) {
return false;
}
return hash_equals($valid_key, $api_key);
}
public function get_identities(WP_REST_Request $request): WP_REST_Response {
$page = (int) $request->get_param('page') ?: 1;
$per_page = (int) $request->get_param('per_page') ?: 50;
$offset = ($page - 1) * $per_page;
$search = $request->get_param('search');
$source = $request->get_param('source');
$sql = "SELECT
uuid,
name,
identity_type,
source,
tmdb_id,
created_at,
reference_data->>'total_references' as total_references,
reference_data->>'quality_avg' as quality_avg,
reference_data->'trace_stats'->>'duration_seconds' as trace_duration,
reference_data->'trace_stats'->>'avg_confidence' as trace_confidence,
reference_data->'trace_stats'->>'total_appearances' as trace_appearances
FROM {$this->schema}.identities";
$where = [];
$params = [];
if ($search) {
$where[] = "name ILIKE ?";
$params[] = "%{$search}%";
}
if ($source) {
$where[] = "source = ?";
$params[] = $source;
}
if (!empty($where)) {
$sql .= " WHERE " . implode(' AND ', $where);
}
$sql .= " ORDER BY created_at DESC LIMIT ? OFFSET ?";
$params[] = $per_page;
$params[] = $offset;
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
$identities = $stmt->fetchAll(PDO::FETCH_ASSOC);
$count_sql = "SELECT COUNT(*) FROM {$this->schema}.identities";
if (!empty($where)) {
$count_sql .= " WHERE " . implode(' AND ', $where);
}
$stmt = $this->db->prepare($count_sql);
$stmt->execute(array_slice($params, 0, -2));
$total = (int) $stmt->fetchColumn();
$identities = array_map(function ($identity) {
return $this->format_identity_list_item($identity);
}, $identities);
return new WP_REST_Response([
'success' => true,
'data' => $identities,
'pagination' => [
'page' => $page,
'per_page' => $per_page,
'total' => $total,
'total_pages' => ceil($total / $per_page),
],
]);
}
public function get_identity_detail(WP_REST_Request $request): WP_REST_Response|WP_Error {
$uuid = $request['uuid'];
$sql = "SELECT
uuid,
name,
identity_type,
source,
tmdb_id,
tmdb_profile,
reference_data,
created_at,
updated_at
FROM {$this->schema}.identities
WHERE uuid = ?";
$stmt = $this->db->prepare($sql);
$stmt->execute([$uuid]);
$identity = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$identity) {
return new WP_Error(
'not_found',
'Identity not found',
['status' => 404]
);
}
return new WP_REST_Response([
'success' => true,
'data' => $this->format_identity_detail($identity),
]);
}
public function get_angle_coverage(WP_REST_Request $request): WP_REST_Response|WP_Error {
$uuid = $request['uuid'];
$sql = "SELECT reference_data FROM {$this->schema}.identities WHERE uuid = ?";
$stmt = $this->db->prepare($sql);
$stmt->execute([$uuid]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$result) {
return new WP_Error(
'not_found',
'Identity not found',
['status' => 404]
);
}
$reference_data = json_decode($result['reference_data'], true);
$angle_coverage = $this->calculate_angle_coverage($reference_data);
return new WP_REST_Response([
'success' => true,
'data' => [
'uuid' => $uuid,
'angles' => $angle_coverage['angles'],
'coverage_score' => $angle_coverage['score'],
'recommendation' => $angle_coverage['recommendation'],
],
]);
}
public function get_body_actions(WP_REST_Request $request): WP_REST_Response|WP_Error {
$uuid = $request['uuid'];
$sql = "SELECT
i.uuid,
i.name,
i.reference_data
FROM {$this->schema}.identities i
WHERE i.uuid = ?";
$stmt = $this->db->prepare($sql);
$stmt->execute([$uuid]);
$identity = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$identity) {
return new WP_Error(
'not_found',
'Identity not found',
['status' => 404]
);
}
$reference_data = json_decode($identity['reference_data'], true);
$body_actions = $reference_data['body_actions'] ?? [];
$action_statistics = $reference_data['action_statistics'] ?? [];
return new WP_REST_Response([
'success' => true,
'data' => [
'uuid' => $uuid,
'name' => $identity['name'],
'actions' => $body_actions,
'statistics' => $action_statistics,
],
]);
}
public function get_reference_vectors(WP_REST_Request $request): WP_REST_Response|WP_Error {
$uuid = $request['uuid'];
$sql = "SELECT reference_data FROM {$this->schema}.identities WHERE uuid = ?";
$stmt = $this->db->prepare($sql);
$stmt->execute([$uuid]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$result) {
return new WP_Error(
'not_found',
'Identity not found',
['status' => 404]
);
}
$reference_data = json_decode($result['reference_data'], true);
$face_embeddings = $reference_data['face_embeddings'] ?? [];
$vectors = array_map(function ($embedding) {
return [
'angle' => $embedding['angle'] ?? 'unknown',
'frame' => $embedding['frame'] ?? null,
'quality_score' => $embedding['quality_score'] ?? 0,
'pitch' => $embedding['pitch'] ?? 'neutral',
'pose_confidence' => $embedding['pose_confidence'] ?? 0,
'detection_confidence' => $embedding['detection_confidence'] ?? 0,
'attributes' => [
'age' => $embedding['attributes']['age'] ?? null,
'gender' => $embedding['attributes']['gender'] ?? null,
],
];
}, $face_embeddings);
return new WP_REST_Response([
'success' => true,
'data' => [
'uuid' => $uuid,
'total_vectors' => count($vectors),
'vectors' => $vectors,
],
]);
}
private function format_identity_list_item(array $identity): array {
return [
'uuid' => $identity['uuid'],
'name' => $identity['name'],
'type' => $identity['identity_type'],
'source' => $identity['source'],
'total_references' => (int) ($identity['total_references'] ?? 0),
'quality_avg' => $identity['quality_avg'] ? round((float) $identity['quality_avg'], 3) : null,
'trace_duration' => $identity['trace_duration'] ? round((float) $identity['trace_duration'], 2) : null,
'trace_confidence' => $identity['trace_confidence'] ? round((float) $identity['trace_confidence'], 4) : null,
'tmdb_id' => $identity['tmdb_id'],
'created_at' => $identity['created_at'],
];
}
private function format_identity_detail(array $identity): array {
$reference_data = json_decode($identity['reference_data'], true);
return [
'uuid' => $identity['uuid'],
'name' => $identity['name'],
'type' => $identity['identity_type'],
'source' => $identity['source'],
'tmdb_id' => $identity['tmdb_id'],
'tmdb_profile' => $identity['tmdb_profile'],
'reference_vectors' => [
'total' => $reference_data['total_references'] ?? 0,
'angles' => $reference_data['angles_covered'] ?? [],
'quality_avg' => $reference_data['quality_avg'] ?? null,
],
'trace_stats' => $reference_data['trace_stats'] ?? null,
'created_at' => $identity['created_at'],
'updated_at' => $identity['updated_at'],
];
}
private function calculate_angle_coverage(?array $reference_data): array {
if (!$reference_data) {
return [
'angles' => [],
'score' => 0,
'recommendation' => 'No reference data available',
];
}
$face_embeddings = $reference_data['face_embeddings'] ?? [];
$required_angles = ['frontal', 'three_quarter', 'profile_left', 'profile_right'];
$angle_stats = [];
foreach ($required_angles as $angle) {
$angle_stats[$angle] = [
'count' => 0,
'quality_sum' => 0,
'status' => 'missing',
];
}
foreach ($face_embeddings as $embedding) {
$angle = $embedding['angle'] ?? 'unknown';
$quality = $embedding['quality_score'] ?? 0;
if (isset($angle_stats[$angle])) {
$angle_stats[$angle]['count']++;
$angle_stats[$angle]['quality_sum'] += $quality;
}
}
$covered_count = 0;
$total_quality = 0;
foreach ($angle_stats as $angle => &$stats) {
if ($stats['count'] > 0) {
$stats['quality_avg'] = round($stats['quality_sum'] / $stats['count'], 3);
$stats['status'] = $stats['count'] > 10 ? 'dominant' : 'present';
$covered_count++;
$total_quality += $stats['quality_avg'];
}
unset($stats['quality_sum']);
}
$coverage_score = round(($covered_count / count($required_angles)) * 100);
$avg_quality = $covered_count > 0 ? round($total_quality / $covered_count, 3) : 0;
$missing_angles = array_keys(array_filter($angle_stats, fn($s) => $s['count'] === 0));
$recommendation = 'Excellent coverage!';
if (count($missing_angles) > 0) {
$recommendation = 'Add ' . implode(', ', $missing_angles) . ' angle(s) for better coverage';
}
return [
'angles' => $angle_stats,
'score' => $coverage_score,
'quality_avg' => $avg_quality,
'recommendation' => $recommendation,
];
}
}

11
themes/momentry/index.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
get_header();
?>
<div id="primary" class="content-area">
<main id="main" class="site-main">
<h1>Momentry Portal</h1>
<p>Welcome to Momentry Identity Management Portal.</p>
</main>
</div>
<?php
get_footer();

View File

@@ -0,0 +1,598 @@
<?php
/**
* Template Name: API Demo - 應用 (Application)
* Description: Demonstrates practical workflow scenarios combining multiple 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">查詢</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 active">應用</a>
</div>
<!-- 應用 1: 完整工作流程 -->
<div class="api-section">
<h2>1. 完整工作流程示範</h2>
<p>從檔案註冊到處理完成,展示完整的端到端工作流程。</p>
<div class="workflow-container">
<div class="workflow-step" id="step1">
<div class="step-header">
<span class="step-number">1</span>
<span class="step-title">註冊檔案</span>
<span class="step-status" id="step1_status">等待中</span>
</div>
<div class="step-content">
<input type="text" id="workflow_path" placeholder="輸入影片路徑">
<button class="btn btn-primary" onclick="workflowRegister()">執行</button>
</div>
</div>
<div class="workflow-step" id="step2">
<div class="step-header">
<span class="step-number">2</span>
<span class="step-title">查詢處理狀態</span>
<span class="step-status" id="step2_status">等待中</span>
</div>
<div class="step-content">
<button class="btn btn-secondary" onclick="workflowCheckStatus()" id="btn_step2" disabled>執行</button>
<span id="step2_progress"></span>
</div>
</div>
<div class="workflow-step" id="step3">
<div class="step-header">
<span class="step-number">3</span>
<span class="step-title">查詢檢測結果</span>
<span class="step-status" id="step3_status">等待中</span>
</div>
<div class="step-content">
<button class="btn btn-secondary" onclick="workflowCheckResults()" id="btn_step3" disabled>執行</button>
<div id="step3_results"></div>
</div>
</div>
<div class="workflow-step" id="step4">
<div class="step-header">
<span class="step-number">4</span>
<span class="step-title">搜尋身份</span>
<span class="step-status" id="step4_status">等待中</span>
</div>
<div class="step-content">
<button class="btn btn-secondary" onclick="workflowSearchIdentity()" id="btn_step4" disabled>執行</button>
<div id="step4_results"></div>
</div>
</div>
</div>
</div>
<!-- 應用 2: 身份追蹤 -->
<div class="api-section">
<h2>2. 跨檔案身份追蹤</h2>
<p>追蹤特定身份在所有檔案中的出現情況,建立身份關聯圖。</p>
<div class="input-group">
<label for="track_uuid">Identity UUID:</label>
<input type="text" id="track_uuid" placeholder="輸入要追蹤的 identity_uuid">
<button class="btn btn-primary" onclick="trackIdentity()">開始追蹤</button>
</div>
<div id="tracking_results" class="tracking-results" style="display:none;">
<div class="tracking-header">
<h3 id="tracking_identity_name"></h3>
<span class="badge" id="tracking_file_count"></span>
</div>
<div class="tracking-timeline" id="tracking_timeline"></div>
<div class="tracking-stats" id="tracking_stats"></div>
</div>
</div>
<!-- 應用 3: 檔案遷移示範 -->
<div class="api-section">
<h2>3. 檔案遷移與身份繼承示範</h2>
<p>演示檔案移動後重新註冊,系統如何建立新的 file_uuid 並保留身份綁定。</p>
<div class="migration-demo">
<div class="migration-step">
<h4>步驟 1: 原始註冊</h4>
<input type="text" id="migration_original_path" placeholder="原始路徑 /path/to/original.mp4">
<button class="btn btn-primary" onclick="migrationRegisterOriginal()">註冊原始檔案</button>
<div class="result-box" id="migration_original_result"></div>
</div>
<div class="migration-arrow">↓</div>
<div class="migration-step">
<h4>步驟 2: 模擬移動檔案</h4>
<input type="text" id="migration_new_path" placeholder="新路徑 /path/to/moved.mp4">
<button class="btn btn-secondary" onclick="migrationSimulateMove()">移動並重新註冊</button>
<div class="result-box" id="migration_new_result"></div>
</div>
<div class="migration-arrow">↓</div>
<div class="migration-step">
<h4>步驟 3: 查詢遷移歷史</h4>
<button class="btn btn-primary" onclick="migrationCheckHistory()">查看歷史</button>
<div class="result-box" id="migration_history_result"></div>
</div>
</div>
</div>
<!-- 應用 4: 批次處理 -->
<div class="api-section">
<h2>4. 批次檔案處理</h2>
<p>一次註冊多個檔案,監控批次處理進度。</p>
<div class="batch-container">
<div class="input-group">
<label for="batch_paths">檔案路徑列表 (每行一個):</label>
<textarea id="batch_paths" rows="5" placeholder="/path/to/video1.mp4&#10;/path/to/video2.mp4&#10;/path/to/video3.mp4"></textarea>
<button class="btn btn-primary" onclick="batchRegister()">批次註冊</button>
</div>
<div class="batch-progress" id="batch_progress" style="display:none;">
<div class="progress-bar">
<div class="progress-fill" id="batch_progress_fill"></div>
</div>
<span id="batch_progress_text">0/0</span>
</div>
<div class="batch-results" id="batch_results"></div>
</div>
</div>
<!-- 應用 5: 語義搜尋工作流 -->
<div class="api-section">
<h2>5. 語義搜尋與片段提取工作流</h2>
<p>使用語義搜尋找到相關片段,然後提取詳細資訊。</p>
<div class="search-workflow">
<div class="input-group">
<label for="semantic_query">搜尋查詢:</label>
<input type="text" id="semantic_query" placeholder="描述你要尋找的內容,例如: 戴眼鏡的男性在戶外說話">
<button class="btn btn-primary" onclick="searchWorkflow()">搜尋</button>
</div>
<div id="search_workflow_results" class="search-results-grid" style="display:none;">
<div class="search-summary">
<h3>搜尋結果摘要</h3>
<div id="search_summary_content"></div>
</div>
<div class="search-details">
<h3>詳細片段</h3>
<div id="search_details_content"></div>
</div>
</div>
</div>
</div>
</div>
<style>
.workflow-container {
margin-top: 1rem;
}
.workflow-step {
background: #f8fafc;
border-radius: var(--border-radius);
padding: 1rem;
margin-bottom: 1rem;
border-left: 4px solid #e2e8f0;
}
.workflow-step.active {
border-left-color: var(--primary-color);
background: #eff6ff;
}
.workflow-step.completed {
border-left-color: var(--success-color);
background: #f0fdf4;
}
.step-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.step-number {
width: 24px;
height: 24px;
background: var(--secondary-color);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 600;
}
.workflow-step.completed .step-number {
background: var(--success-color);
}
.step-title {
font-weight: 600;
flex: 1;
}
.step-status {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
background: #e2e8f0;
}
.step-content {
display: flex;
gap: 0.5rem;
align-items: center;
}
.step-content input {
flex: 1;
padding: 0.5rem;
border: 1px solid #e2e8f0;
border-radius: 4px;
}
.migration-demo {
margin-top: 1rem;
}
.migration-step {
background: #f8fafc;
padding: 1rem;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.migration-arrow {
text-align: center;
font-size: 1.5rem;
color: var(--secondary-color);
}
.result-box {
margin-top: 0.5rem;
padding: 0.75rem;
background: #1e293b;
color: #e2e8f0;
border-radius: 4px;
font-family: monospace;
font-size: 0.75rem;
max-height: 200px;
overflow-y: auto;
display: none;
}
.result-box.has-content {
display: block;
}
.batch-container {
margin-top: 1rem;
}
.batch-container textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #e2e8f0;
border-radius: 4px;
font-family: monospace;
font-size: 0.75rem;
resize: vertical;
}
.batch-progress {
margin: 1rem 0;
display: flex;
align-items: center;
gap: 1rem;
}
.progress-bar {
flex: 1;
height: 8px;
background: #e2e8f0;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--success-color);
width: 0%;
transition: width 0.3s;
}
.batch-results {
margin-top: 1rem;
}
.batch-item {
padding: 0.75rem;
background: #f8fafc;
border-radius: 4px;
margin-bottom: 0.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.tracking-results {
margin-top: 1rem;
}
.tracking-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.tracking-timeline {
border-left: 2px solid #e2e8f0;
padding-left: 1rem;
margin-bottom: 1rem;
}
.timeline-item {
margin-bottom: 1rem;
position: relative;
}
.timeline-item::before {
content: '';
width: 8px;
height: 8px;
background: var(--primary-color);
border-radius: 50%;
position: absolute;
left: -1.35rem;
top: 0.5rem;
}
.search-results-grid {
margin-top: 1rem;
display: grid;
grid-template-columns: 1fr 2fr;
gap: 1rem;
}
.search-summary, .search-details {
background: #f8fafc;
padding: 1rem;
border-radius: 4px;
}
</style>
<script>
const API_BASE = 'http://localhost:3003/api/v1';
let workflowFileUuid = null;
function showResult(elementId, data) {
const el = document.getElementById(elementId);
el.textContent = JSON.stringify(data, null, 2);
el.classList.add('has-content');
}
function updateStep(step, status, isCompleted = false) {
const stepEl = document.getElementById(`step${step}`);
const statusEl = document.getElementById(`step${step}_status`);
stepEl.classList.remove('active', 'completed');
if (isCompleted) {
stepEl.classList.add('completed');
statusEl.textContent = '完成';
statusEl.style.background = '#d1fae5';
statusEl.style.color = '#065f46';
} else if (status === 'active') {
stepEl.classList.add('active');
statusEl.textContent = '執行中';
statusEl.style.background = '#dbeafe';
statusEl.style.color = '#1e40af';
} else {
statusEl.textContent = status;
}
}
async function workflowRegister() {
const path = document.getElementById('workflow_path').value.trim();
if (!path) return alert('請輸入影片路徑');
updateStep(1, 'active');
try {
const res = await fetch(`${API_BASE}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: path })
});
const data = await res.json();
showResult('step1_result', data);
workflowFileUuid = data.file_uuid || data.uuid;
updateStep(1, '完成', true);
document.getElementById('btn_step2').disabled = false;
} catch (e) {
updateStep(1, '失敗');
}
}
async function workflowCheckStatus() {
if (!workflowFileUuid) return;
updateStep(2, 'active');
try {
const res = await fetch(`${API_BASE}/jobs/${workflowFileUuid}/status`);
const data = await res.json();
document.getElementById('step2_progress').textContent = `進度: ${data.progress || 0}%`;
if (data.status === 'completed') {
updateStep(2, '完成', true);
document.getElementById('btn_step3').disabled = false;
} else {
updateStep(2, `狀態: ${data.status || 'processing'}`);
setTimeout(() => workflowCheckStatus(), 3000);
}
} catch (e) {
updateStep(2, '失敗');
}
}
async function workflowCheckResults() {
if (!workflowFileUuid) return;
updateStep(3, 'active');
try {
const [identitiesRes, chunksRes] = await Promise.all([
fetch(`${API_BASE}/files/${workflowFileUuid}/identities`),
fetch(`${API_BASE}/files/${workflowFileUuid}/chunks`)
]);
const identities = await identitiesRes.json();
const chunks = await chunksRes.json();
document.getElementById('step3_results').innerHTML = `
<p>檢測到身份: ${(identities.identities || []).length}</p>
<p>語義片段: ${(chunks.chunks || []).length}</p>
`;
updateStep(3, '完成', true);
document.getElementById('btn_step4').disabled = false;
} catch (e) {
updateStep(3, '失敗');
}
}
async function workflowSearchIdentity() {
if (!workflowFileUuid) return;
updateStep(4, 'active');
try {
const res = await fetch(`${API_BASE}/files/${workflowFileUuid}/identities`);
const data = await res.json();
document.getElementById('step4_results').innerHTML = (data.identities || [])
.map(i => `<div class="file-item"><span>${i.name || '未命名'}</span><span class="uuid">${i.uuid}</span></div>`)
.join('') || '<p>無身份</p>';
updateStep(4, '完成', true);
} catch (e) {
updateStep(4, '失敗');
}
}
async function trackIdentity() {
const uuid = document.getElementById('track_uuid').value.trim();
if (!uuid) return alert('請輸入 identity_uuid');
try {
const res = await fetch(`${API_BASE}/identities/${uuid}/files`);
const data = await res.json();
document.getElementById('tracking_results').style.display = 'block';
document.getElementById('tracking_identity_name').textContent = data.name || '未命名身份';
document.getElementById('tracking_file_count').textContent = `${(data.files || []).length} 個檔案`;
document.getElementById('tracking_timeline').innerHTML = (data.files || [])
.map(f => `
<div class="timeline-item">
<strong>${f.file_name}</strong>
<div class="uuid">${f.file_uuid}</div>
<div class="chunk-time">時間: ${f.start_time?.toFixed(1) || '?'}s - ${f.end_time?.toFixed(1) || '?'}s</div>
</div>
`).join('') || '<p>無關聯檔案</p>';
document.getElementById('tracking_stats').innerHTML = `
<p>總檢測次數: ${data.total_detections || 0}</p>
<p>平均品質: ${(data.avg_quality || 0).toFixed(1)}</p>
<p>覆蓋角度: ${(data.angle_coverage || []).join(', ') || '無資料'}</p>
`;
} catch (e) {
alert('追蹤失敗: ' + e.message);
}
}
async function migrationRegisterOriginal() {
const path = document.getElementById('migration_original_path').value.trim();
if (!path) return alert('請輸入原始路徑');
try {
const res = await fetch(`${API_BASE}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: path })
});
const data = await res.json();
showResult('migration_original_result', data);
} catch (e) {
showResult('migration_original_result', { error: e.message });
}
}
async function migrationSimulateMove() {
const path = document.getElementById('migration_new_path').value.trim();
if (!path) return alert('請輸入新路徑');
try {
const res = await fetch(`${API_BASE}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: path })
});
const data = await res.json();
showResult('migration_new_result', data);
} catch (e) {
showResult('migration_new_result', { error: e.message });
}
}
async function migrationCheckHistory() {
const path = document.getElementById('migration_new_path').value.trim();
if (!path) return alert('請先輸入新路徑');
try {
const res = await fetch(`${API_BASE}/files/${path}/history`);
const data = await res.json();
showResult('migration_history_result', data);
} catch (e) {
showResult('migration_history_result', { error: e.message });
}
}
async function batchRegister() {
const paths = document.getElementById('batch_paths').value.trim().split('\n').filter(p => p.trim());
if (paths.length === 0) return alert('請輸入至少一個檔案路徑');
document.getElementById('batch_progress').style.display = 'flex';
const resultsEl = document.getElementById('batch_results');
resultsEl.innerHTML = '';
let completed = 0;
for (const path of paths) {
try {
const res = await fetch(`${API_BASE}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: path.trim() })
});
const data = await res.json();
resultsEl.innerHTML += `
<div class="batch-item">
<span>${path.trim()}</span>
<span class="badge-status ${data.status || 'pending'}">${data.file_uuid || 'failed'}</span>
</div>
`;
} catch (e) {
resultsEl.innerHTML += `
<div class="batch-item">
<span>${path.trim()}</span>
<span class="badge-status error">失敗: ${e.message}</span>
</div>
`;
}
completed++;
document.getElementById('batch_progress_fill').style.width = `${(completed / paths.length) * 100}%`;
document.getElementById('batch_progress_text').textContent = `${completed}/${paths.length}`;
}
}
async function searchWorkflow() {
const query = document.getElementById('semantic_query').value.trim();
if (!query) return alert('請輸入搜尋查詢');
try {
const res = await fetch(`${API_BASE}/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, type: 'chunk' })
});
const data = await res.json();
document.getElementById('search_workflow_results').style.display = 'grid';
document.getElementById('search_summary_content').innerHTML = `
<p>找到 ${data.total || 0} 個結果</p>
<p>涵蓋 ${data.files_count || 0} 個檔案</p>
`;
document.getElementById('search_details_content').innerHTML = (data.results || [])
.map(r => `
<div class="chunk-item">
<div class="chunk-time">${r.file_name} | ${r.start_time?.toFixed(1)}s - ${r.end_time?.toFixed(1)}s</div>
<div>${r.text_content || r.description || '無內容'}</div>
<div class="badge">相似度: ${(r.similarity * 100).toFixed(1)}%</div>
</div>
`).join('') || '<p>沒有結果</p>';
} catch (e) {
alert('搜尋失敗: ' + e.message);
}
}
</script>
<?php get_footer(); ?>

View File

@@ -0,0 +1,333 @@
<?php
/**
* Template Name: API Demo - 展示 (Display)
* Description: Demonstrates file detail display, identity visualization, and classification results
*/
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">查詢</a>
<a href="/api-demo-display/" class="nav-item active">展示</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. 檔案詳情儀表板</h2>
<p>整合展示檔案的元數據、處理進度、分類標籤等完整資訊。</p>
<div class="input-group">
<label for="dashboard_uuid">File UUID:</label>
<input type="text" id="dashboard_uuid" placeholder="輸入 file_uuid">
<button class="btn btn-primary" onclick="loadDashboard()">載入</button>
</div>
<div id="dashboard_container" class="dashboard-grid" style="display:none;">
<div class="card card-metadata">
<h3>基本資訊</h3>
<div id="metadata_content"></div>
</div>
<div class="card card-status">
<h3>處理狀態</h3>
<div id="status_content"></div>
</div>
<div class="card class="card-classification">
<h3>分類標籤</h3>
<div id="classification_content"></div>
</div>
<div class="card card-identity">
<h3>關聯身份</h3>
<div id="identity_content"></div>
</div>
</div>
</div>
<!-- 展示 2: 身份視覺化 -->
<div class="api-section">
<h2>2. 身份視覺化</h2>
<p>展示身份的跨檔案關聯、臉部檢測統計、品質分數等。</p>
<div class="input-group">
<label for="identity_viz_uuid">Identity UUID:</label>
<input type="text" id="identity_viz_uuid" placeholder="輸入 identity_uuid">
<button class="btn btn-primary" onclick="visualizeIdentity()">視覺化</button>
</div>
<div id="identity_viz_container" class="identity-visualization" style="display:none;">
<div class="viz-header">
<h3 id="identity_name"></h3>
<div class="quality-score" id="quality_score"></div>
</div>
<div class="viz-grid">
<div class="viz-card">
<h4>關聯檔案</h4>
<ul id="linked_files"></ul>
</div>
<div class="viz-card">
<h4>臉部統計</h4>
<div id="face_stats"></div>
</div>
<div class="viz-card">
<h4>角度覆蓋</h4>
<div id="angle_coverage"></div>
</div>
</div>
</div>
</div>
<!-- 展示 3: 片段展示 -->
<div class="api-section">
<h2>3. 影片片段展示</h2>
<p>展示影片的語義片段、說話者分段、鏡頭切換等分類結果。</p>
<div class="input-group">
<label for="chunks_uuid">File UUID:</label>
<input type="text" id="chunks_uuid" placeholder="輸入 file_uuid">
<select id="chunk_type">
<option value="sentence">語義片段</option>
<option value="cut">鏡頭切換</option>
<option value="time">時間片段</option>
</select>
<button class="btn btn-primary" onclick="loadChunks()">載入片段</button>
</div>
<div id="chunks_container" class="chunks-timeline" style="display:none;">
<div id="chunks_list"></div>
</div>
</div>
<!-- 展示 4: 分類結果 -->
<div class="api-section">
<h2>4. 分類結果展示</h2>
<p>展示 YOLO 檢測、姿勢估計、動作識別等視覺分類結果。</p>
<div class="input-group">
<label for="classify_uuid">File UUID:</label>
<input type="text" id="classify_uuid" placeholder="輸入 file_uuid">
<select id="processor_type">
<option value="yolo">YOLO 物件檢測</option>
<option value="pose">姿勢估計</option>
<option value="face">臉部檢測</option>
<option value="ocr">OCR 文字識別</option>
</select>
<button class="btn btn-primary" onclick="loadClassification()">載入結果</button>
</div>
<div id="classification_container" class="classification-results" style="display:none;">
<div id="classification_content"></div>
</div>
</div>
</div>
<style>
.dashboard-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
margin-top: 1rem;
}
.card {
background: var(--card-background);
border-radius: var(--border-radius);
padding: 1rem;
box-shadow: var(--shadow);
}
.card h3 {
margin-top: 0;
color: var(--primary-color);
border-bottom: 1px solid #e2e8f0;
padding-bottom: 0.5rem;
}
.identity-visualization {
margin-top: 1rem;
}
.viz-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.viz-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.viz-card {
background: #f8fafc;
border-radius: 4px;
padding: 1rem;
}
.viz-card h4 {
margin-top: 0;
color: var(--secondary-color);
}
.chunks-timeline {
margin-top: 1rem;
}
.chunk-item {
padding: 0.75rem;
background: #f1f5f9;
border-radius: 4px;
margin-bottom: 0.5rem;
border-left: 3px solid var(--primary-color);
}
.chunk-time {
font-size: 0.75rem;
color: var(--text-secondary);
font-family: monospace;
}
.classification-results {
margin-top: 1rem;
}
.classification-item {
display: flex;
gap: 1rem;
padding: 0.75rem;
background: #f8fafc;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.confidence-bar {
width: 100px;
height: 8px;
background: #e2e8f0;
border-radius: 4px;
overflow: hidden;
}
.confidence-fill {
height: 100%;
background: var(--success-color);
}
</style>
<script>
const API_BASE = 'http://localhost:3003/api/v1';
async function loadDashboard() {
const uuid = document.getElementById('dashboard_uuid').value.trim();
if (!uuid) return alert('請輸入 file_uuid');
try {
const [fileRes, jobRes] = await Promise.all([
fetch(`${API_BASE}/files/${uuid}`),
fetch(`${API_BASE}/jobs/${uuid}/status`)
]);
const fileData = await fileRes.json();
const jobData = await jobRes.json();
document.getElementById('dashboard_container').style.display = 'grid';
document.getElementById('metadata_content').innerHTML = `
<p><strong>檔案名稱:</strong> ${fileData.file_name || '-'}</p>
<p><strong>檔案類型:</strong> ${fileData.file_type || '-'}</p>
<p><strong>時長:</strong> ${fileData.duration ? fileData.duration.toFixed(1) + 's' : '-'}</p>
<p><strong>解析度:</strong> ${fileData.width}x${fileData.height}</p>
<p><strong>幀率:</strong> ${fileData.fps ? fileData.fps.toFixed(1) : '-'} fps</p>
`;
document.getElementById('status_content').innerHTML = `
<p><strong>狀態:</strong> <span class="badge-status ${fileData.status}">${fileData.status || 'unknown'}</span></p>
<p><strong>處理進度:</strong> ${jobData.progress || 0}%</p>
<p><strong>已完成處理器:</strong> ${(jobData.completed_processors || []).join(', ') || '無'}</p>
`;
document.getElementById('classification_content').innerHTML = `
<p><strong>分類標籤:</strong> ${(fileData.tags || []).join(', ') || '無'}</p>
<p><strong>語義標籤:</strong> ${(fileData.semantic_tags || []).join(', ') || '無'}</p>
`;
document.getElementById('identity_content').innerHTML = `
<p><strong>檢測到身份:</strong> ${fileData.identities_count || 0}</p>
<p><strong>主要身份:</strong> ${fileData.primary_identity || '無'}</p>
`;
} catch (e) {
alert('載入失敗: ' + e.message);
}
}
async function visualizeIdentity() {
const uuid = document.getElementById('identity_viz_uuid').value.trim();
if (!uuid) return alert('請輸入 identity_uuid');
try {
const res = await fetch(`${API_BASE}/identities/${uuid}`);
const data = await res.json();
document.getElementById('identity_viz_container').style.display = 'block';
document.getElementById('identity_name').textContent = data.name || '未命名身份';
document.getElementById('quality_score').innerHTML = `
品質分數: <strong>${(data.quality_score || 0).toFixed(1)}</strong>/100
`;
document.getElementById('linked_files').innerHTML = (data.linked_files || [])
.map(f => `<li>${f.file_name} <span class="uuid">${f.file_uuid}</span></li>`)
.join('') || '<li>無關聯檔案</li>';
document.getElementById('face_stats').innerHTML = `
<p>檢測次數: ${data.detection_count || 0}</p>
<p>平均品質: ${(data.avg_quality || 0).toFixed(1)}</p>
`;
document.getElementById('angle_coverage').innerHTML = (data.angle_coverage || {})
.map(a => `<span class="badge ${a.status}">${a.angle}</span>`)
.join('') || '無資料';
} catch (e) {
alert('載入失敗: ' + e.message);
}
}
async function loadChunks() {
const uuid = document.getElementById('chunks_uuid').value.trim();
const type = document.getElementById('chunk_type').value;
if (!uuid) return alert('請輸入 file_uuid');
try {
const res = await fetch(`${API_BASE}/files/${uuid}/chunks?type=${type}`);
const data = await res.json();
document.getElementById('chunks_container').style.display = 'block';
document.getElementById('chunks_list').innerHTML = (data.chunks || [])
.map(c => `
<div class="chunk-item">
<div class="chunk-time">${c.start_time?.toFixed(1) || 0}s - ${c.end_time?.toFixed(1) || 0}s</div>
<div>${c.text_content || c.description || '無內容'}</div>
</div>
`).join('') || '<p>沒有片段</p>';
} catch (e) {
alert('載入失敗: ' + e.message);
}
}
async function loadClassification() {
const uuid = document.getElementById('classify_uuid').value.trim();
const processor = document.getElementById('processor_type').value;
if (!uuid) return alert('請輸入 file_uuid');
try {
const res = await fetch(`${API_BASE}/files/${uuid}/processors/${processor}`);
const data = await res.json();
document.getElementById('classification_container').style.display = 'block';
document.getElementById('classification_content').innerHTML = (data.results || [])
.map(r => `
<div class="classification-item">
<div>
<strong>${r.label || r.class}</strong>
<div class="confidence-bar"><div class="confidence-fill" style="width:${(r.confidence || 0) * 100}%"></div></div>
</div>
<div>${r.bbox ? `[${r.bbox.join(',')}]` : ''}</div>
</div>
`).join('') || '<p>沒有檢測結果</p>';
} catch (e) {
alert('載入失敗: ' + e.message);
}
}
</script>
<?php get_footer(); ?>

View File

@@ -0,0 +1,284 @@
<?php
/**
* Template Name: API Demo - 操作 (Operation)
* Description: Demonstrates file registration, identity binding, processing triggers and other operational 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">查詢</a>
<a href="/api-demo-display/" class="nav-item">展示</a>
<a href="/api-demo-operation/" class="nav-item active">操作</a>
<a href="/api-demo-application/" class="nav-item">應用</a>
</div>
<!-- 操作 1: 檔案註冊 -->
<div class="api-section">
<h2>1. 檔案註冊 (POST /api/v1/register)</h2>
<p>將新影片或音訊檔案註冊到系統,觸發後續處理流程。</p>
<div class="api-tester">
<div class="input-group">
<label for="register_path">檔案路徑:</label>
<input type="text" id="register_path" placeholder="/path/to/video.mp4">
<button class="btn btn-primary" onclick="registerFile()">註冊</button>
</div>
<div class="response-panel" id="register_result"></div>
</div>
<div class="quick-actions">
<h4>快速測試:</h4>
<button class="btn btn-secondary" onclick="document.getElementById('register_path').value='/Users/accusys/Videos/test1.mp4'">測試影片 1</button>
<button class="btn btn-secondary" onclick="document.getElementById('register_path').value='/Users/accusys/Videos/test2.mp4'">測試影片 2</button>
</div>
</div>
<!-- 操作 2: 身份綁定 -->
<div class="api-section">
<h2>2. 身份綁定 (POST /api/v1/identities/bind)</h2>
<p>將臉部檢測綁定到特定身份,建立臉部與身份的關聯。</p>
<div class="api-tester">
<div class="input-group">
<label for="bind_face_id">Face ID:</label>
<input type="text" id="bind_face_id" placeholder="face_100">
<label for="bind_identity_uuid">Identity UUID:</label>
<input type="text" id="bind_identity_uuid" placeholder="a9a90105-...">
<button class="btn btn-primary" onclick="bindFace()">綁定</button>
</div>
<div class="response-panel" id="bind_result"></div>
</div>
</div>
<!-- 操作 3: 處理觸發 -->
<div class="api-section">
<h2>3. 處理觸發 (POST /api/v1/files/:uuid/process)</h2>
<p>手動觸發檔案的處理流程,指定要執行的處理器。</p>
<div class="api-tester">
<div class="input-group">
<label for="process_uuid">File UUID:</label>
<input type="text" id="process_uuid" placeholder="輸入 file_uuid">
<div class="checkbox-group">
<label><input type="checkbox" value="asr" checked> ASR</label>
<label><input type="checkbox" value="yolo" checked> YOLO</label>
<label><input type="checkbox" value="face" checked> Face</label>
<label><input type="checkbox" value="ocr"> OCR</label>
<label><input type="checkbox" value="pose"> Pose</label>
<label><input type="checkbox" value="cut" checked> CUT</label>
</div>
<button class="btn btn-primary" onclick="triggerProcess()">觸發處理</button>
</div>
<div class="response-panel" id="process_result"></div>
</div>
</div>
<!-- 操作 4: 身份合併 -->
<div class="api-section">
<h2>4. 身份合併 (POST /api/v1/identities/merge)</h2>
<p>將多個身份合併為單一身份,用於處理重複的身份記錄。</p>
<div class="api-tester">
<div class="input-group">
<label for="merge_target">目標 Identity UUID:</label>
<input type="text" id="merge_target" placeholder="保留的身份 UUID">
<label for="merge_sources">來源 Identity UUIDs:</label>
<input type="text" id="merge_sources" placeholder="uuid1,uuid2,uuid3 (逗號分隔)">
<button class="btn btn-primary" onclick="mergeIdentities()">合併</button>
</div>
<div class="response-panel" id="merge_result"></div>
</div>
</div>
<!-- 操作 5: 處理器重試 -->
<div class="api-section">
<h2>5. 處理器重試 (POST /api/v1/jobs/:uuid/retry)</h2>
<p>重試失敗的處理器,可選擇重試全部或特定處理器。</p>
<div class="api-tester">
<div class="input-group">
<label for="retry_uuid">File UUID:</label>
<input type="text" id="retry_uuid" placeholder="輸入 file_uuid">
<select id="retry_processor">
<option value="all">全部</option>
<option value="asr">ASR</option>
<option value="yolo">YOLO</option>
<option value="face">Face</option>
<option value="ocr">OCR</option>
</select>
<button class="btn btn-primary" onclick="retryProcessor()">重試</button>
</div>
<div class="response-panel" id="retry_result"></div>
</div>
</div>
</div>
<style>
.checkbox-group {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
}
.quick-actions {
margin-top: 1rem;
padding: 0.75rem;
background: #f8fafc;
border-radius: 4px;
}
.quick-actions h4 {
margin: 0 0 0.5rem 0;
font-size: 0.875rem;
color: var(--text-secondary);
}
.quick-actions .btn-secondary {
margin-right: 0.5rem;
margin-bottom: 0.5rem;
}
.operation-log {
margin-top: 1rem;
padding: 1rem;
background: #1e293b;
color: #e2e8f0;
border-radius: 4px;
font-family: monospace;
font-size: 0.75rem;
max-height: 300px;
overflow-y: auto;
}
.operation-log .log-entry {
margin-bottom: 0.25rem;
}
.operation-log .log-time {
color: #64748b;
}
.operation-log .log-success {
color: #22c55e;
}
.operation-log .log-error {
color: #ef4444;
}
</style>
<script>
const API_BASE = 'http://localhost:3003/api/v1';
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';
}
async function registerFile() {
const path = document.getElementById('register_path').value.trim();
if (!path) return alert('請輸入檔案路徑');
try {
const res = await fetch(`${API_BASE}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: path })
});
const data = await res.json();
showResult('register_result', data);
} catch (e) {
showError('register_result', e.message);
}
}
async function bindFace() {
const faceId = document.getElementById('bind_face_id').value.trim();
const identityUuid = document.getElementById('bind_identity_uuid').value.trim();
if (!faceId || !identityUuid) return alert('請輸入 Face ID 和 Identity UUID');
try {
const res = await fetch(`${API_BASE}/identities/bind`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ face_id: faceId, identity_uuid: identityUuid })
});
const data = await res.json();
showResult('bind_result', data);
} catch (e) {
showError('bind_result', e.message);
}
}
async function triggerProcess() {
const uuid = document.getElementById('process_uuid').value.trim();
if (!uuid) return alert('請輸入 file_uuid');
const processors = Array.from(document.querySelectorAll('.checkbox-group input:checked'))
.map(cb => cb.value);
try {
const res = await fetch(`${API_BASE}/files/${uuid}/process`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ processors })
});
const data = await res.json();
showResult('process_result', data);
} catch (e) {
showError('process_result', e.message);
}
}
async function mergeIdentities() {
const target = document.getElementById('merge_target').value.trim();
const sources = document.getElementById('merge_sources').value.trim();
if (!target || !sources) return alert('請輸入目標和來源 Identity UUIDs');
try {
const res = await fetch(`${API_BASE}/identities/merge`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
target_uuid: target,
source_uuids: sources.split(',').map(s => s.trim())
})
});
const data = await res.json();
showResult('merge_result', data);
} catch (e) {
showError('merge_result', e.message);
}
}
async function retryProcessor() {
const uuid = document.getElementById('retry_uuid').value.trim();
const processor = document.getElementById('retry_processor').value;
if (!uuid) return alert('請輸入 file_uuid');
try {
const res = await fetch(`${API_BASE}/jobs/${uuid}/retry`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ processor: processor === 'all' ? null : processor })
});
const data = await res.json();
showResult('retry_result', data);
} catch (e) {
showError('retry_result', e.message);
}
}
</script>
<?php get_footer(); ?>

View File

@@ -0,0 +1,339 @@
<?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>
<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; }
</style>
<script>
const API_BASE = 'http://localhost:3002/api/v1';
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';
}
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('請輸入搜尋查詢');
try {
const res = await fetch(`${API_BASE}/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, type })
});
const data = await res.json();
showResult('search_result', data);
} catch (e) {
showError('search_result', e.message);
}
}
</script>
<?php get_footer(); ?>

View File

@@ -0,0 +1,132 @@
<?php
get_header();
?>
<div id="primary" class="content-area">
<main id="main" class="site-main">
<div class="site-content">
<div id="identity-detail">
<p>Loading identity details...</p>
</div>
</div>
</main>
</div>
<script>
(function($) {
'use strict';
const uuid = window.location.pathname.match(/identity\/([a-f0-9\-]{36})/);
if (!uuid) {
$('#identity-detail').html('<p class="error">Invalid identity UUID</p>');
return;
}
$.ajax({
url: momentryApi.root + 'momentry/v1/identities/' + uuid[1],
method: 'GET',
beforeSend: function(xhr) {
xhr.setRequestHeader('X-WP-Nonce', momentryApi.nonce);
},
}).done(function(response) {
if (response.success) {
const identity = response.data;
const html = `
<div class="identity-detail-page">
<h1>${identity.name}</h1>
<div class="identity-meta">
<span class="badge source-${identity.source}">${identity.source}</span>
<span class="type">${identity.type}</span>
</div>
<section class="detail-section">
<h2>Reference Vectors</h2>
<div class="info-grid">
<div class="info-item">
<span class="label">Total Vectors:</span>
<span class="value">${identity.reference_vectors.total}</span>
</div>
<div class="info-item">
<span class="label">Quality Avg:</span>
<span class="value">${((identity.reference_vectors.quality_avg || 0) * 100).toFixed(1)}%</span>
</div>
<div class="info-item">
<span class="label">Angles:</span>
<span class="value">${identity.reference_vectors.angles.join(', ') || 'None'}</span>
</div>
</div>
</section>
${identity.trace_stats ? `
<section class="detail-section">
<h2>Trace Statistics</h2>
<div class="info-grid">
<div class="info-item">
<span class="label">Trace ID:</span>
<span class="value">${identity.trace_stats.trace_id}</span>
</div>
<div class="info-item">
<span class="label">Duration:</span>
<span class="value">${identity.trace_stats.duration_seconds}s</span>
</div>
<div class="info-item">
<span class="label">Confidence:</span>
<span class="value">${(identity.trace_stats.avg_confidence * 100).toFixed(1)}%</span>
</div>
<div class="info-item">
<span class="label">Appearances:</span>
<span class="value">${identity.trace_stats.total_appearances} frames</span>
</div>
</div>
<h3>Pose Distribution</h3>
<div class="pose-distribution">
${Object.entries(identity.trace_stats.pose_distribution || {}).map(([pose, count]) => `
<div class="pose-item">
<span class="pose-name">${pose.replace('_', ' ')}</span>
<span class="pose-count">${count} frames</span>
</div>
`).join('')}
</div>
</section>
` : ''}
<section class="detail-section">
<h2>Metadata</h2>
<div class="info-grid">
<div class="info-item">
<span class="label">UUID:</span>
<span class="value">${identity.uuid}</span>
</div>
<div class="info-item">
<span class="label">Source:</span>
<span class="value">${identity.source}</span>
</div>
${identity.tmdb_id ? `
<div class="info-item">
<span class="label">TMDB ID:</span>
<span class="value">${identity.tmdb_id}</span>
</div>
` : ''}
<div class="info-item">
<span class="label">Created:</span>
<span class="value">${new Date(identity.created_at).toLocaleString()}</span>
</div>
</div>
</section>
</div>
`;
$('#identity-detail').html(html);
}
}).fail(function(error) {
$('#identity-detail').html('<p class="error">Failed to load identity details</p>');
console.error('Error:', error);
});
})(jQuery);
</script>
<?php
get_footer();

488
themes/momentry/style.css Normal file
View File

@@ -0,0 +1,488 @@
:root {
--primary-color: #2563eb;
--secondary-color: #64748b;
--success-color: #22c55e;
--warning-color: #f59e0b;
--error-color: #ef4444;
--background-color: #f8fafc;
--card-background: #ffffff;
--text-primary: #1e293b;
--text-secondary: #64748b;
--border-radius: 8px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: var(--background-color);
color: var(--text-primary);
line-height: 1.6;
}
.site-header {
background: var(--card-background);
border-bottom: 1px solid #e2e8f0;
padding: 1rem 2rem;
box-shadow: var(--shadow);
}
.site-title {
font-size: 1.5rem;
font-weight: 700;
margin: 0;
}
.site-title a {
text-decoration: none;
color: var(--primary-color);
}
.site-content {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.identity-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.identity-card {
background: var(--card-background);
border-radius: var(--border-radius);
padding: 1.5rem;
box-shadow: var(--shadow);
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
}
.identity-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.identity-name {
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 0.5rem 0;
}
.identity-meta {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.badge.source-tmdb {
background: #dbeafe;
color: #1e40af;
}
.badge.source-manual {
background: #fef3c7;
color: #92400e;
}
.badge.source-auto_trace {
background: #d1fae5;
color: #065f46;
}
.quality-score {
position: relative;
height: 24px;
background: #e2e8f0;
border-radius: 4px;
margin-bottom: 0.75rem;
overflow: hidden;
}
.score-bar {
position: absolute;
left: 0;
top: 0;
height: 100%;
background: linear-gradient(90deg, var(--warning-color), var(--success-color));
border-radius: 4px;
}
.score-value {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
font-size: 0.75rem;
font-weight: 600;
}
.trace-stats {
display: flex;
gap: 1rem;
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 0.75rem;
}
.identity-actions {
display: flex;
gap: 0.5rem;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.btn:hover {
opacity: 0.9;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.angle-coverage-btn {
background: var(--primary-color);
color: white;
}
.body-actions-btn {
background: var(--secondary-color);
color: white;
}
.pagination-container {
display: flex;
justify-content: center;
margin-top: 2rem;
}
.pagination {
display: flex;
align-items: center;
gap: 1rem;
}
.pagination .prev,
.pagination .next {
background: var(--primary-color);
color: white;
}
.page-info {
font-size: 0.875rem;
color: var(--text-secondary);
}
.modal-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: none;
justify-content: center;
align-items: center;
z-index: 1000;
}
.identity-detail-modal,
.angle-coverage-modal,
.body-actions-modal {
background: var(--card-background);
border-radius: var(--border-radius);
padding: 2rem;
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
}
.detail-section {
margin-bottom: 1.5rem;
}
.detail-section h3 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.coverage-score {
font-size: 1.5rem;
font-weight: 700;
color: var(--primary-color);
margin-bottom: 1rem;
}
.angles-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.angle-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border-radius: 4px;
background: #f1f5f9;
}
.angle-item.dominant {
background: #d1fae5;
}
.angle-item.present {
background: #dbeafe;
}
.angle-item.missing {
background: #fef3c7;
}
.status-icon {
font-size: 1.25rem;
}
.angle-name {
flex: 1;
font-weight: 500;
text-transform: capitalize;
}
.count {
font-size: 0.875rem;
color: var(--text-secondary);
}
.recommendation {
margin-top: 1rem;
padding: 0.75rem;
background: #fef3c7;
border-radius: 4px;
font-size: 0.875rem;
}
.actions-container {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
}
.action-category h4 {
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 0.5rem;
text-transform: capitalize;
}
.action-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.action-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
background: #e2e8f0;
font-size: 0.75rem;
}
.close-modal {
background: var(--secondary-color);
color: white;
width: 100%;
}
.error {
color: var(--error-color);
padding: 1rem;
background: #fee2e2;
border-radius: 4px;
}
.site-footer {
text-align: center;
padding: 2rem;
margin-top: 2rem;
border-top: 1px solid #e2e8f0;
color: var(--text-secondary);
}
.site-footer a {
color: var(--primary-color);
text-decoration: none;
}
/* API Demo Pages - Shared Styles */
.page-description {
color: var(--text-secondary);
margin-bottom: 1.5rem;
}
.api-nav {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
padding: 1rem;
background: var(--card-background);
border-radius: var(--border-radius);
box-shadow: var(--shadow);
flex-wrap: wrap;
}
.api-nav .nav-item {
padding: 0.5rem 1rem;
border-radius: 4px;
text-decoration: none;
color: var(--text-secondary);
font-weight: 500;
transition: background-color 0.2s;
}
.api-nav .nav-item:hover {
background: #f1f5f9;
}
.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);
font-size: 1.25rem;
}
.api-tester {
margin: 1rem 0;
}
.input-group {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
margin-bottom: 0.5rem;
}
.input-group label {
font-weight: 500;
min-width: 120px;
font-size: 0.875rem;
}
.input-group input,
.input-group select,
.input-group textarea {
padding: 0.5rem;
border: 1px solid #e2e8f0;
border-radius: 4px;
font-size: 0.875rem;
flex: 1;
min-width: 200px;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.btn:hover {
opacity: 0.9;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--primary-color);
color: white;
}
.btn-secondary {
background: var(--secondary-color);
color: white;
}
.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;
}
.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; }
.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;
}
.chunk-item {
padding: 0.75rem;
background: #f1f5f9;
border-radius: 4px;
margin-bottom: 0.5rem;
border-left: 3px solid var(--primary-color);
}
.chunk-time {
font-size: 0.75rem;
color: var(--text-secondary);
font-family: monospace;
}

View File

@@ -0,0 +1,8 @@
/*
* Link styles
* https://github.com/WordPress/gutenberg/issues/42319
*/
a {
text-decoration-thickness: 1px !important;
text-underline-offset: .1em;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -0,0 +1,19 @@
=== Contributing to Twenty Twenty-Five ===
= Minifying CSS =
Twenty Twenty-Five has a single stylesheet `style.css` which is enqueued in addition to the global styles coming from core. On a normal production site, when `SCRIPT_DEBUG` is disabled, then the minified version `style.min.css` will be enqueued instead. If you make a change to `style.css`, you'll need to re-minify the `style.min.css` using the built-in npm build tool. As always, it is preferable to use the Site Editor to supply Additional CSS instead of directly editing the theme stylesheet.
Installation instructions
1. Using a command line interface, go to the “twentytwentyfive” directory `cd /my-computer/local-wordpress-install/src/wp-content/themes/twentytwentyfive`.
2. Type `npm install` into the command line, and press the [return] key, to install all Node.js dependencies.
3. The dependencies may take a few minutes to download but once it completes, youre done.
Usage instructions
1. After making a change to the `style.css` file, run `npm run build` from within the theme directory to regenerate `style.min.css` with your new changes.
2. You can also “watch” the theme directory for CSS changes and re-minify the CSS anytime a change occurs by running: `npm run watch`.

View File

@@ -0,0 +1,159 @@
<?php
/**
* Twenty Twenty-Five functions and definitions.
*
* @link https://developer.wordpress.org/themes/basics/theme-functions/
*
* @package WordPress
* @subpackage Twenty_Twenty_Five
* @since Twenty Twenty-Five 1.0
*/
if ( ! function_exists( 'twentytwentyfive_post_format_setup' ) ) :
/**
* Adds theme support for post formats.
*
* @since Twenty Twenty-Five 1.0
*
* @return void
*/
function twentytwentyfive_post_format_setup() {
add_theme_support( 'post-formats', array( 'aside', 'audio', 'chat', 'gallery', 'image', 'link', 'quote', 'status', 'video' ) );
}
endif;
add_action( 'after_setup_theme', 'twentytwentyfive_post_format_setup' );
if ( ! function_exists( 'twentytwentyfive_editor_style' ) ) :
/**
* Enqueues editor-style.css in the editors.
*
* @since Twenty Twenty-Five 1.0
*
* @return void
*/
function twentytwentyfive_editor_style() {
add_editor_style( 'assets/css/editor-style.css' );
}
endif;
add_action( 'after_setup_theme', 'twentytwentyfive_editor_style' );
if ( ! function_exists( 'twentytwentyfive_enqueue_styles' ) ) :
/**
* Enqueues the theme stylesheet on the front.
*
* @since Twenty Twenty-Five 1.0
*
* @return void
*/
function twentytwentyfive_enqueue_styles() {
$suffix = SCRIPT_DEBUG ? '' : '.min';
$src = 'style' . $suffix . '.css';
wp_enqueue_style(
'twentytwentyfive-style',
get_parent_theme_file_uri( $src ),
array(),
wp_get_theme()->get( 'Version' )
);
wp_style_add_data(
'twentytwentyfive-style',
'path',
get_parent_theme_file_path( $src )
);
}
endif;
add_action( 'wp_enqueue_scripts', 'twentytwentyfive_enqueue_styles' );
if ( ! function_exists( 'twentytwentyfive_block_styles' ) ) :
/**
* Registers custom block styles.
*
* @since Twenty Twenty-Five 1.0
*
* @return void
*/
function twentytwentyfive_block_styles() {
register_block_style(
'core/list',
array(
'name' => 'checkmark-list',
'label' => __( 'Checkmark', 'twentytwentyfive' ),
'inline_style' => '
ul.is-style-checkmark-list {
list-style-type: "\2713";
}
ul.is-style-checkmark-list li {
padding-inline-start: 1ch;
}',
)
);
}
endif;
add_action( 'init', 'twentytwentyfive_block_styles' );
if ( ! function_exists( 'twentytwentyfive_pattern_categories' ) ) :
/**
* Registers pattern categories.
*
* @since Twenty Twenty-Five 1.0
*
* @return void
*/
function twentytwentyfive_pattern_categories() {
register_block_pattern_category(
'twentytwentyfive_page',
array(
'label' => __( 'Pages', 'twentytwentyfive' ),
'description' => __( 'A collection of full page layouts.', 'twentytwentyfive' ),
)
);
register_block_pattern_category(
'twentytwentyfive_post-format',
array(
'label' => __( 'Post formats', 'twentytwentyfive' ),
'description' => __( 'A collection of post format patterns.', 'twentytwentyfive' ),
)
);
}
endif;
add_action( 'init', 'twentytwentyfive_pattern_categories' );
if ( ! function_exists( 'twentytwentyfive_register_block_bindings' ) ) :
/**
* Registers the post format block binding source.
*
* @since Twenty Twenty-Five 1.0
*
* @return void
*/
function twentytwentyfive_register_block_bindings() {
register_block_bindings_source(
'twentytwentyfive/format',
array(
'label' => _x( 'Post format name', 'Label for the block binding placeholder in the editor', 'twentytwentyfive' ),
'get_value_callback' => 'twentytwentyfive_format_binding',
)
);
}
endif;
add_action( 'init', 'twentytwentyfive_register_block_bindings' );
if ( ! function_exists( 'twentytwentyfive_format_binding' ) ) :
/**
* Callback function for the post format name block binding source.
*
* @since Twenty Twenty-Five 1.0
*
* @return string|void Post format name, or nothing if the format is 'standard'.
*/
function twentytwentyfive_format_binding() {
$post_format_slug = get_post_format();
if ( $post_format_slug && 'standard' !== $post_format_slug ) {
return get_post_format_string( $post_format_slug );
}
}
endif;

1725
themes/twentytwentyfive/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More