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
2
themes/index.php
Executable file
@@ -0,0 +1,2 @@
|
||||
<?php
|
||||
// Silence is golden.
|
||||
108
themes/momentry/API_DEMO_README.md
Normal 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 端點可能需要實作後才能正常使用
|
||||
23
themes/momentry/archive-identity.php
Normal 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();
|
||||
249
themes/momentry/assets/js/main.js
Normal 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);
|
||||
14
themes/momentry/footer.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
defined('ABSPATH') || exit;
|
||||
?>
|
||||
<footer id="colophon" class="site-footer">
|
||||
<div class="site-info">
|
||||
<p>© <?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>
|
||||
47
themes/momentry/functions.php
Normal 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();
|
||||
}
|
||||
41
themes/momentry/header.php
Normal 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>
|
||||
49
themes/momentry/inc/api/class-database.php
Normal 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';
|
||||
}
|
||||
}
|
||||
382
themes/momentry/inc/api/class-identity-api.php
Normal 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
@@ -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();
|
||||
598
themes/momentry/page-api-demo-application.php
Normal 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 /path/to/video2.mp4 /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(); ?>
|
||||
333
themes/momentry/page-api-demo-display.php
Normal 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(); ?>
|
||||
284
themes/momentry/page-api-demo-operation.php
Normal 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(); ?>
|
||||
339
themes/momentry/page-api-demo-query.php
Normal 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(); ?>
|
||||
132
themes/momentry/single-identity.php
Normal 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
@@ -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;
|
||||
}
|
||||
8
themes/twentytwentyfive/assets/css/editor-style.css
Normal file
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* Link styles
|
||||
* https://github.com/WordPress/gutenberg/issues/42319
|
||||
*/
|
||||
a {
|
||||
text-decoration-thickness: 1px !important;
|
||||
text-underline-offset: .1em;
|
||||
}
|
||||
BIN
themes/twentytwentyfive/assets/images/404-image.webp
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
themes/twentytwentyfive/assets/images/agenda-img-4.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
BIN
themes/twentytwentyfive/assets/images/book-image-landing.webp
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
themes/twentytwentyfive/assets/images/book-image.webp
Normal file
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 59 KiB |
BIN
themes/twentytwentyfive/assets/images/botany-flowers.webp
Normal file
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 41 KiB |
BIN
themes/twentytwentyfive/assets/images/category-anthuriums.webp
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
themes/twentytwentyfive/assets/images/category-cactus.webp
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
themes/twentytwentyfive/assets/images/category-sunflowers.webp
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
themes/twentytwentyfive/assets/images/coming-soon-bg-image.webp
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
themes/twentytwentyfive/assets/images/coral-square.webp
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
themes/twentytwentyfive/assets/images/dallas-creek-square.webp
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
themes/twentytwentyfive/assets/images/delphinium-flowers.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
themes/twentytwentyfive/assets/images/flower-meadow-square.webp
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
themes/twentytwentyfive/assets/images/grid-flower-1.webp
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
themes/twentytwentyfive/assets/images/grid-flower-2.webp
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
themes/twentytwentyfive/assets/images/hero-podcast.webp
Normal file
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 110 KiB |
BIN
themes/twentytwentyfive/assets/images/link-in-bio-image.webp
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
themes/twentytwentyfive/assets/images/location.webp
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
themes/twentytwentyfive/assets/images/malibu-plantlife.webp
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
themes/twentytwentyfive/assets/images/man-in-hat.webp
Normal file
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 35 KiB |
BIN
themes/twentytwentyfive/assets/images/nurse.webp
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
themes/twentytwentyfive/assets/images/parthenon-square.webp
Normal file
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 218 KiB |
BIN
themes/twentytwentyfive/assets/images/red-hibiscus-closeup.webp
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
themes/twentytwentyfive/assets/images/ruins-image.webp
Normal file
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 51 KiB |
BIN
themes/twentytwentyfive/assets/images/star-thristle-flower.webp
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
themes/twentytwentyfive/assets/images/typewriter.webp
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
themes/twentytwentyfive/assets/images/vash-gon-square.webp
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
themes/twentytwentyfive/assets/images/woman-splashing-water.webp
Normal file
|
After Width: | Height: | Size: 56 KiB |
19
themes/twentytwentyfive/contributing.txt
Normal 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, you’re 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`.
|
||||
159
themes/twentytwentyfive/functions.php
Normal 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;
|
||||