initial commit

This commit is contained in:
2026-05-30 23:29:44 -07:00
commit 431ffdff06
48 changed files with 4221 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.vite
npm-debug.log
+16
View File
@@ -0,0 +1,16 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json ./
RUN npm install
COPY index.html tsconfig.json vite.config.ts ./
COPY public ./public
COPY src ./src
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Evanescere</title>
<script src="/config.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location = /config.js {
add_header Cache-Control "no-store";
try_files $uri =404;
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"name": "evanescere-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"lucide-react": "^0.468.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
}
+3
View File
@@ -0,0 +1,3 @@
window.__EVANESCERE_FRONTEND_CONFIG__ = {
apiBaseUrl: "http://localhost:8000"
};
+304
View File
@@ -0,0 +1,304 @@
import {
Check,
Clapperboard,
Play,
RefreshCw,
Save,
Send,
Settings2,
Upload,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
apiBaseUrl,
approveClip,
getClips,
getSettings,
getTranscript,
getVideos,
patchSettings,
renderClip,
runVideo,
uploadClip,
} from "./api";
import type { ClipSuggestion, PipelineSettings, TranscriptSegment, Video } from "./types";
const settingLabels: Record<keyof PipelineSettings, string> = {
suggest_enabled: "Suggest",
render_enabled: "Render",
upload_enabled: "Upload",
preserve_final_artifacts: "Preserve",
bake_subtitles: "Bake subs",
};
function formatDuration(seconds: number | null) {
if (seconds === null) return "";
const total = Math.round(seconds);
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
return hours > 0
? `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`
: `${minutes}:${secs.toString().padStart(2, "0")}`;
}
function formatTimeRange(start: number, end: number) {
return `${formatDuration(start)}-${formatDuration(end)}`;
}
function statusTone(value: string) {
if (["done", "stable", "approved", "auto_approved"].includes(value)) return "good";
if (["failed", "error"].includes(value)) return "bad";
if (["running", "queued", "observing", "pending"].includes(value)) return "busy";
return "neutral";
}
export function App() {
const [videos, setVideos] = useState<Video[]>([]);
const [settings, setSettings] = useState<PipelineSettings | null>(null);
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
const [transcript, setTranscript] = useState<TranscriptSegment[]>([]);
const [clips, setClips] = useState<ClipSuggestion[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const selectedVideo = useMemo(
() => videos.find((video) => video.id === selectedVideoId) ?? null,
[selectedVideoId, videos],
);
const loadSelected = useCallback(async (videoId: number) => {
const [nextTranscript, nextClips] = await Promise.all([getTranscript(videoId), getClips(videoId)]);
setTranscript(nextTranscript);
setClips(nextClips);
}, []);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [nextSettings, nextVideos] = await Promise.all([getSettings(), getVideos()]);
setSettings(nextSettings);
setVideos(nextVideos);
const targetVideoId = selectedVideoId ?? nextVideos[0]?.id ?? null;
setSelectedVideoId(targetVideoId);
if (targetVideoId !== null) {
await loadSelected(targetVideoId);
} else {
setTranscript([]);
setClips([]);
}
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Unknown error");
} finally {
setLoading(false);
}
}, [loadSelected, selectedVideoId]);
useEffect(() => {
void refresh();
}, [refresh]);
async function handleSelect(videoId: number) {
setSelectedVideoId(videoId);
setLoading(true);
setError(null);
try {
await loadSelected(videoId);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Unknown error");
} finally {
setLoading(false);
}
}
async function withRefresh(action: () => Promise<unknown>) {
setLoading(true);
setError(null);
try {
await action();
await refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Unknown error");
setLoading(false);
}
}
async function saveSettings() {
if (!settings) return;
await withRefresh(() => patchSettings(settings));
}
return (
<main className="app-shell">
<header className="topbar">
<div>
<h1>Evanescere</h1>
<p>{apiBaseUrl}</p>
</div>
<div className="toolbar">
<button type="button" className="icon-button" onClick={() => void refresh()} disabled={loading}>
<RefreshCw size={18} />
Refresh
</button>
<button type="button" className="icon-button primary" onClick={() => void saveSettings()} disabled={loading || !settings}>
<Save size={18} />
Save
</button>
</div>
</header>
{error && <div className="error-strip">{error}</div>}
<section className="band controls-band">
<div className="section-title">
<Settings2 size={18} />
<h2>Pipeline</h2>
</div>
<div className="toggle-row">
{settings &&
(Object.keys(settingLabels) as Array<keyof PipelineSettings>).map((key) => (
<label className="switch" key={key}>
<input
type="checkbox"
checked={settings[key]}
onChange={(event) =>
setSettings((current) =>
current ? { ...current, [key]: event.currentTarget.checked } : current,
)
}
/>
<span>{settingLabels[key]}</span>
</label>
))}
</div>
</section>
<section className="workbench">
<aside className="video-list">
<div className="section-title">
<Clapperboard size={18} />
<h2>Videos</h2>
</div>
<div className="table-scroll">
<table>
<thead>
<tr>
<th>ID</th>
<th>File</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{videos.map((video) => (
<tr key={video.id} className={video.id === selectedVideoId ? "selected" : ""}>
<td>{video.id}</td>
<td>
<button type="button" className="link-button" onClick={() => void handleSelect(video.id)}>
{video.filename}
</button>
<div className="muted">{formatDuration(video.duration_sec)}</div>
</td>
<td>
<StatusPill value={video.ingest_status} />
<StatusPill value={video.processing_status} />
</td>
<td>
<button
type="button"
className="square-button"
title="Run pipeline"
onClick={() => void withRefresh(() => runVideo(video.id))}
>
<Play size={17} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</aside>
<section className="detail-pane">
<div className="detail-header">
<div>
<h2>{selectedVideo?.filename ?? "No video selected"}</h2>
<p>{selectedVideo?.source_url ?? ""}</p>
</div>
<div className="metric-row">
<Metric label="Transcript" value={transcript.length.toString()} />
<Metric label="Clips" value={clips.length.toString()} />
</div>
</div>
<div className="split">
<div className="transcript-pane">
<h3>Transcript</h3>
<div className="transcript-lines">
{transcript.slice(0, 180).map((segment) => (
<div className="transcript-line" key={segment.id}>
<span>{formatTimeRange(segment.start_sec, segment.end_sec)}</span>
<p>{segment.text}</p>
</div>
))}
</div>
</div>
<div className="clips-pane">
<h3>Clips</h3>
<div className="clip-list">
{clips.map((clip) => (
<article className="clip-item" key={clip.id}>
<div className="clip-main">
<div>
<h4>{clip.title_zh}</h4>
<p>{clip.summary_zh}</p>
</div>
<strong>{Math.round(clip.score * 100)}</strong>
</div>
<div className="clip-meta">
<span>{formatTimeRange(clip.start_sec, clip.end_sec)}</span>
<StatusPill value={clip.approval_status} />
<StatusPill value={clip.render_status} />
<StatusPill value={clip.upload_status} />
</div>
<div className="toolbar compact">
<button type="button" className="icon-button" onClick={() => void withRefresh(() => approveClip(clip.id))}>
<Check size={16} />
Approve
</button>
<button type="button" className="icon-button" onClick={() => void withRefresh(() => renderClip(clip.id))}>
<Send size={16} />
Render
</button>
<button type="button" className="icon-button" onClick={() => void withRefresh(() => uploadClip(clip.id))}>
<Upload size={16} />
Upload
</button>
</div>
</article>
))}
</div>
</div>
</div>
</section>
</section>
</main>
);
}
function StatusPill({ value }: { value: string }) {
return <span className={`status-pill ${statusTone(value)}`}>{value}</span>;
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div className="metric">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import type { Artifact, ClipSuggestion, PipelineSettings, TranscriptSegment, Video } from "./types";
const runtimeApiBase = window.__EVANESCERE_FRONTEND_CONFIG__?.apiBaseUrl;
export const apiBaseUrl = (runtimeApiBase || "http://localhost:8000").replace(
/\/$/,
"",
);
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`${apiBaseUrl}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...init.headers,
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || `${response.status} ${response.statusText}`);
}
return response.json() as Promise<T>;
}
export function getSettings(): Promise<PipelineSettings> {
return request<PipelineSettings>("/settings");
}
export function patchSettings(settings: PipelineSettings): Promise<PipelineSettings> {
return request<PipelineSettings>("/settings", {
method: "PATCH",
body: JSON.stringify(settings),
});
}
export function getVideos(): Promise<Video[]> {
return request<Video[]>("/videos");
}
export function runVideo(videoId: number) {
return request(`/videos/${videoId}/run`, { method: "POST" });
}
export function getTranscript(videoId: number): Promise<TranscriptSegment[]> {
return request<TranscriptSegment[]>(`/videos/${videoId}/transcript`);
}
export function getClips(videoId: number): Promise<ClipSuggestion[]> {
return request<ClipSuggestion[]>(`/videos/${videoId}/clips`);
}
export function getClipArtifacts(clipId: number): Promise<Artifact[]> {
return request<Artifact[]>(`/clips/${clipId}/artifacts`);
}
export function approveClip(clipId: number): Promise<ClipSuggestion> {
return request<ClipSuggestion>(`/clips/${clipId}/approve`, { method: "POST" });
}
export function renderClip(clipId: number): Promise<ClipSuggestion> {
return request<ClipSuggestion>(`/clips/${clipId}/render`, { method: "POST" });
}
export function uploadClip(clipId: number): Promise<ClipSuggestion> {
return request<ClipSuggestion>(`/clips/${clipId}/upload`, { method: "POST" });
}
+11
View File
@@ -0,0 +1,11 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+434
View File
@@ -0,0 +1,434 @@
* {
box-sizing: border-box;
}
:root {
color: #1f252b;
background: #f4f6f8;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background: #f4f6f8;
}
button,
input {
font: inherit;
}
button {
white-space: nowrap;
}
.app-shell {
width: min(1440px, 100%);
margin: 0 auto;
padding: 20px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 8px 0 20px;
}
.topbar h1 {
margin: 0;
color: #18222b;
font-size: 28px;
font-weight: 720;
}
.topbar p,
.detail-header p,
.muted {
margin: 4px 0 0;
color: #66737f;
font-size: 13px;
overflow-wrap: anywhere;
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.toolbar.compact {
margin-top: 12px;
}
.icon-button,
.square-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
min-height: 36px;
border: 1px solid #b7c0ca;
border-radius: 6px;
background: #ffffff;
color: #1f252b;
cursor: pointer;
}
.icon-button {
padding: 0 12px;
}
.square-button {
width: 36px;
padding: 0;
}
.icon-button:hover,
.square-button:hover {
border-color: #21756b;
background: #e9f3f1;
}
.icon-button.primary {
border-color: #21756b;
background: #21756b;
color: #ffffff;
}
.icon-button:disabled,
.square-button:disabled {
cursor: progress;
opacity: 0.6;
}
.error-strip {
margin-bottom: 14px;
border-left: 4px solid #b42318;
background: #fff3f0;
color: #76180f;
padding: 10px 12px;
border-radius: 6px;
font-size: 14px;
overflow-wrap: anywhere;
}
.band {
border-top: 1px solid #d8dee6;
padding: 16px 0;
}
.controls-band {
display: flex;
justify-content: space-between;
align-items: center;
gap: 18px;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
color: #2f3b46;
}
.section-title h2,
.detail-header h2,
.transcript-pane h3,
.clips-pane h3 {
margin: 0;
font-size: 16px;
font-weight: 690;
}
.toggle-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.switch {
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 34px;
padding: 0 10px;
border: 1px solid #ccd3db;
border-radius: 6px;
background: #ffffff;
font-size: 13px;
color: #2f3b46;
}
.switch input {
width: 16px;
height: 16px;
accent-color: #21756b;
}
.workbench {
display: grid;
grid-template-columns: minmax(340px, 0.36fr) minmax(0, 1fr);
gap: 18px;
align-items: start;
}
.video-list,
.detail-pane {
min-width: 0;
}
.video-list {
border-top: 1px solid #d8dee6;
padding-top: 16px;
}
.table-scroll {
margin-top: 12px;
overflow: auto;
background: #ffffff;
border: 1px solid #d8dee6;
border-radius: 8px;
}
table {
width: 100%;
min-width: 520px;
border-collapse: collapse;
font-size: 13px;
}
th,
td {
padding: 10px;
border-bottom: 1px solid #e4e8ed;
text-align: left;
vertical-align: top;
}
th {
color: #586674;
font-weight: 650;
background: #fbfcfd;
}
tr.selected td {
background: #eef6f5;
}
.link-button {
display: inline;
border: 0;
padding: 0;
background: transparent;
color: #174b91;
cursor: pointer;
text-align: left;
overflow-wrap: anywhere;
}
.detail-pane {
border-top: 1px solid #d8dee6;
padding-top: 16px;
}
.detail-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
}
.metric-row {
display: flex;
gap: 10px;
}
.metric {
min-width: 88px;
border: 1px solid #d8dee6;
border-radius: 8px;
background: #ffffff;
padding: 8px 10px;
}
.metric span {
display: block;
color: #66737f;
font-size: 12px;
}
.metric strong {
display: block;
margin-top: 3px;
font-size: 20px;
font-weight: 720;
}
.split {
display: grid;
grid-template-columns: minmax(280px, 0.95fr) minmax(320px, 1.05fr);
gap: 18px;
}
.transcript-pane,
.clips-pane {
min-width: 0;
}
.transcript-lines,
.clip-list {
margin-top: 12px;
max-height: 68vh;
overflow: auto;
}
.transcript-lines {
border: 1px solid #d8dee6;
border-radius: 8px;
background: #ffffff;
}
.transcript-line {
display: grid;
grid-template-columns: 112px minmax(0, 1fr);
gap: 10px;
padding: 9px 10px;
border-bottom: 1px solid #edf0f3;
}
.transcript-line span {
color: #66737f;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.transcript-line p {
margin: 0;
overflow-wrap: anywhere;
line-height: 1.45;
}
.clip-list {
display: grid;
gap: 10px;
}
.clip-item {
border: 1px solid #d8dee6;
border-radius: 8px;
background: #ffffff;
padding: 12px;
}
.clip-main {
display: flex;
justify-content: space-between;
gap: 12px;
}
.clip-main h4 {
margin: 0;
color: #18222b;
font-size: 15px;
}
.clip-main p {
margin: 5px 0 0;
color: #4f5c68;
line-height: 1.45;
}
.clip-main strong {
display: grid;
place-items: center;
flex: 0 0 44px;
width: 44px;
height: 44px;
border-radius: 50%;
background: #eef6f5;
color: #155c54;
}
.clip-meta {
display: flex;
align-items: center;
gap: 7px;
flex-wrap: wrap;
margin-top: 10px;
color: #66737f;
font-size: 12px;
}
.status-pill {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 7px;
border-radius: 999px;
font-size: 12px;
font-variant-numeric: tabular-nums;
background: #edf0f3;
color: #4f5c68;
}
.status-pill.good {
background: #e5f5ec;
color: #17623b;
}
.status-pill.bad {
background: #fff0ec;
color: #a02717;
}
.status-pill.busy {
background: #fff4d6;
color: #6f4d00;
}
@media (max-width: 1100px) {
.workbench,
.split {
grid-template-columns: 1fr;
}
.transcript-lines,
.clip-list {
max-height: none;
}
}
@media (max-width: 720px) {
.app-shell {
padding: 14px;
}
.topbar,
.controls-band,
.detail-header {
align-items: stretch;
flex-direction: column;
}
.metric-row {
width: 100%;
}
.metric {
flex: 1;
}
.transcript-line {
grid-template-columns: 1fr;
}
}
+61
View File
@@ -0,0 +1,61 @@
export interface Video {
id: number;
source_url: string;
filename: string;
size_bytes: number | null;
duration_sec: number | null;
codec_metadata: Record<string, unknown>;
ingest_status: string;
processing_status: string;
created_at: string;
updated_at: string;
}
export interface TranscriptSegment {
id: number;
video_id: number;
start_sec: number;
end_sec: number;
text: string;
speaker: string | null;
confidence: number | null;
segment_metadata: Record<string, unknown>;
}
export interface ClipSuggestion {
id: number;
video_id: number;
start_sec: number;
end_sec: number;
title_zh: string;
summary_zh: string;
reason: string;
score: number;
tags: string[];
subtitle_priority: string;
approval_status: string;
render_status: string;
upload_status: string;
created_at: string;
updated_at: string;
}
export interface PipelineSettings {
suggest_enabled: boolean;
render_enabled: boolean;
upload_enabled: boolean;
preserve_final_artifacts: boolean;
bake_subtitles: boolean;
}
export interface Artifact {
id: number;
video_id: number | null;
clip_id: number | null;
artifact_type: string;
local_path: string;
webdav_url: string | null;
preserve: boolean;
artifact_metadata: Record<string, unknown>;
created_at: string;
}
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
interface Window {
__EVANESCERE_FRONTEND_CONFIG__?: {
apiBaseUrl?: string;
};
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
host: "0.0.0.0",
port: 5173,
},
});