From 2cf6c89fbb1e627a289bfc851d7223c96784325f Mon Sep 17 00:00:00 2001 From: buenosairesam Date: Fri, 6 Feb 2026 09:41:50 -0300 Subject: [PATCH] ui video selector --- modelgen/generator/typescript.py | 17 +- modelgen/loader/schema.py | 6 + ui/timeline/src/App.css | 264 ++++++++++++++++++++++++++++--- ui/timeline/src/App.tsx | 87 +++++++--- ui/timeline/src/JobPanel.tsx | 143 +++++++++++++++++ ui/timeline/src/Timeline.tsx | 121 ++++++++++++++ ui/timeline/src/types.ts | 22 +++ 7 files changed, 613 insertions(+), 47 deletions(-) create mode 100644 ui/timeline/src/JobPanel.tsx create mode 100644 ui/timeline/src/Timeline.tsx diff --git a/modelgen/generator/typescript.py b/modelgen/generator/typescript.py index fcca18f..16112c3 100644 --- a/modelgen/generator/typescript.py +++ b/modelgen/generator/typescript.py @@ -28,7 +28,9 @@ class TypeScriptGenerator(BaseGenerator): if hasattr(models, "models"): # SchemaLoader content = self._generate_from_definitions( - models.models, getattr(models, "enums", []) + models.models, + getattr(models, "enums", []), + api_models=getattr(models, "api_models", []), ) elif isinstance(models, tuple): # (models, enums) tuple @@ -42,7 +44,10 @@ class TypeScriptGenerator(BaseGenerator): output_path.write_text(content) def _generate_from_definitions( - self, models: List[ModelDefinition], enums: List[EnumDefinition] + self, + models: List[ModelDefinition], + enums: List[EnumDefinition], + api_models: List[ModelDefinition] = None, ) -> str: """Generate from ModelDefinition objects.""" lines = self._generate_header() @@ -58,6 +63,14 @@ class TypeScriptGenerator(BaseGenerator): lines.extend(self._generate_interface_from_definition(model_def)) lines.append("") + # Generate API request/response interfaces + if api_models: + lines.append("// API request/response types") + lines.append("") + for model_def in api_models: + lines.extend(self._generate_interface_from_definition(model_def)) + lines.append("") + return "\n".join(lines) def _generate_from_dataclasses(self, dataclasses: List[type]) -> str: diff --git a/modelgen/loader/schema.py b/modelgen/loader/schema.py index 360ca2b..bd22c39 100644 --- a/modelgen/loader/schema.py +++ b/modelgen/loader/schema.py @@ -60,6 +60,7 @@ class SchemaLoader: def __init__(self, schema_path: Path): self.schema_path = Path(schema_path) self.models: List[ModelDefinition] = [] + self.api_models: List[ModelDefinition] = [] self.enums: List[EnumDefinition] = [] self.grpc_messages: List[ModelDefinition] = [] self.grpc_service: Optional[GrpcServiceDefinition] = None @@ -79,6 +80,11 @@ class SchemaLoader: for cls in dataclasses: self.models.append(self._parse_dataclass(cls)) + # Extract API_MODELS (TypeScript-only request/response types) + api_models = getattr(module, "API_MODELS", []) + for cls in api_models: + self.api_models.append(self._parse_dataclass(cls)) + # Extract ENUMS enums = getattr(module, "ENUMS", []) for enum_cls in enums: diff --git a/ui/timeline/src/App.css b/ui/timeline/src/App.css index 1d413d7..708cfdb 100644 --- a/ui/timeline/src/App.css +++ b/ui/timeline/src/App.css @@ -177,41 +177,259 @@ body { } .timeline-container { - height: 120px; background: #252525; border-top: 1px solid #333; + padding: 0.75rem 1rem; } -.timeline-placeholder { +/* Timeline component */ + +.timeline { + user-select: none; +} + +.timeline-times { display: flex; - align-items: center; - justify-content: center; - height: 100%; - color: #666; + justify-content: space-between; + font-size: 0.75rem; + color: #aaa; + margin-bottom: 0.5rem; + font-variant-numeric: tabular-nums; } -.info { - padding: 1rem; +.timeline-track { + position: relative; + height: 40px; + background: #333; + border-radius: 4px; + cursor: pointer; + overflow: hidden; +} + +.timeline-dim { + position: absolute; + top: 0; + height: 100%; + background: rgba(0, 0, 0, 0.5); + pointer-events: none; +} + +.timeline-selection { + position: absolute; + top: 0; + height: 100%; + background: rgba(59, 130, 246, 0.15); + pointer-events: none; +} + +.timeline-playhead { + position: absolute; + top: 0; + width: 2px; + height: 100%; + background: #fff; + pointer-events: none; + transform: translateX(-1px); + z-index: 2; +} + +.timeline-handle { + position: absolute; + top: 0; + width: 12px; + height: 100%; + cursor: ew-resize; + transform: translateX(-6px); + z-index: 3; + border-radius: 2px; + transition: background 0.1s; +} + +.timeline-handle::after { + content: ""; + position: absolute; + top: 0; + left: 5px; + width: 2px; + height: 100%; + background: #3b82f6; +} + +.timeline-handle:hover, +.timeline-handle.dragging { + background: rgba(59, 130, 246, 0.3); +} + +.timeline-handle.dragging { + cursor: grabbing; +} + +.timeline-duration { + display: flex; + justify-content: space-between; + font-size: 0.625rem; + color: #666; + margin-top: 0.25rem; +} + +/* Job panel */ + +.job-panel { + padding: 0.75rem 1rem; background: #202020; border-top: 1px solid #333; } -.info h3 { - margin-bottom: 0.5rem; - font-size: 1rem; +.job-controls { + display: flex; + gap: 0.5rem; + align-items: center; } -.info dl { - display: grid; - grid-template-columns: auto 1fr; - gap: 0.25rem 1rem; - font-size: 0.875rem; -} - -.info dt { - color: #888; -} - -.info dd { +.preset-select { + flex: 1; + padding: 0.375rem 0.5rem; + font-size: 0.8rem; + background: #333; color: #e0e0e0; + border: 1px solid #444; + border-radius: 4px; + cursor: pointer; +} + +.preset-select:focus { + outline: none; + border-color: #3b82f6; +} + +.enqueue-button { + padding: 0.375rem 1rem; + font-size: 0.8rem; + background: #10b981; + color: #000; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: 500; + white-space: nowrap; + transition: background 0.2s; +} + +.enqueue-button:hover:not(:disabled) { + background: #059669; +} + +.enqueue-button:disabled { + background: #4b5563; + color: #888; + cursor: not-allowed; +} + +/* Job list */ + +.job-list { + margin-top: 0.75rem; + border-top: 1px solid #333; + padding-top: 0.5rem; +} + +.job-list h3 { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #888; + margin-bottom: 0.5rem; +} + +.job-item { + padding: 0.5rem; + background: #2a2a2a; + border-radius: 4px; + margin-bottom: 0.375rem; + font-size: 0.8rem; +} + +.job-item-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.job-filename { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #ccc; +} + +.job-status { + font-size: 0.7rem; + padding: 0.125rem 0.375rem; + border-radius: 3px; + text-transform: uppercase; + font-weight: 500; + flex-shrink: 0; + margin-left: 0.5rem; +} + +.job-status.pending { + background: #f59e0b; + color: #000; +} + +.job-status.processing { + background: #3b82f6; + color: #fff; +} + +.job-status.completed { + background: #10b981; + color: #000; +} + +.job-status.failed { + background: #ef4444; + color: #fff; +} + +.job-status.cancelled { + background: #6b7280; + color: #fff; +} + +.job-progress-bar { + height: 4px; + background: #444; + border-radius: 2px; + margin-top: 0.375rem; + overflow: hidden; +} + +.job-progress-fill { + height: 100%; + background: #3b82f6; + border-radius: 2px; + transition: width 0.3s; +} + +.job-cancel { + margin-top: 0.375rem; + padding: 0.125rem 0.5rem; + font-size: 0.7rem; + background: transparent; + color: #888; + border: 1px solid #555; + border-radius: 3px; + cursor: pointer; +} + +.job-cancel:hover { + color: #ef4444; + border-color: #ef4444; +} + +.job-error { + margin-top: 0.25rem; + font-size: 0.7rem; + color: #ef4444; } diff --git a/ui/timeline/src/App.tsx b/ui/timeline/src/App.tsx index 52a94b5..63db769 100644 --- a/ui/timeline/src/App.tsx +++ b/ui/timeline/src/App.tsx @@ -1,6 +1,8 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { getAssets, getSystemStatus, scanMediaFolder } from "./api"; import type { MediaAsset, SystemStatus } from "./types"; +import Timeline from "./Timeline"; +import JobPanel from "./JobPanel"; import "./App.css"; function App() { @@ -11,6 +13,13 @@ function App() { const [error, setError] = useState(null); const [scanning, setScanning] = useState(false); + // Video sync state + const videoRef = useRef(null); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [trimStart, setTrimStart] = useState(0); + const [trimEnd, setTrimEnd] = useState(0); + useEffect(() => { async function load() { try { @@ -31,6 +40,41 @@ function App() { load(); }, []); + // Reset trim state when asset changes + useEffect(() => { + setTrimStart(0); + setTrimEnd(0); + setCurrentTime(0); + setDuration(0); + }, [selectedAsset?.id]); + + // Video event handlers + const handleTimeUpdate = useCallback(() => { + if (videoRef.current) { + setCurrentTime(videoRef.current.currentTime); + } + }, []); + + const handleLoadedMetadata = useCallback(() => { + if (videoRef.current) { + const dur = videoRef.current.duration; + setDuration(dur); + setTrimEnd(dur); + } + }, []); + + const handleSeek = useCallback((time: number) => { + if (videoRef.current) { + videoRef.current.currentTime = time; + setCurrentTime(time); + } + }, []); + + const handleTrimChange = useCallback((start: number, end: number) => { + setTrimStart(start); + setTrimEnd(end); + }, []); + async function handleScan() { setScanning(true); setError(null); @@ -40,7 +84,6 @@ function App() { `Scan complete!\nFound: ${result.found}\nRegistered: ${result.registered}\nSkipped: ${result.skipped}`, ); - // Reload assets const assetsData = await getAssets(); setAssets( assetsData.sort((a, b) => a.filename.localeCompare(b.filename)), @@ -101,29 +144,29 @@ function App() { {selectedAsset ? (
-
- {/* Timeline component will go here */} -
- Timeline: {selectedAsset.duration?.toFixed(1)}s -
-
-
-

{selectedAsset.filename}

-
-
Duration
-
{selectedAsset.duration?.toFixed(2)}s
-
Resolution
-
- {selectedAsset.width}x{selectedAsset.height} -
-
Video
-
{selectedAsset.video_codec}
-
Audio
-
{selectedAsset.audio_codec}
-
+
+
) : (
Select an asset to begin
diff --git a/ui/timeline/src/JobPanel.tsx b/ui/timeline/src/JobPanel.tsx new file mode 100644 index 0000000..690f2cb --- /dev/null +++ b/ui/timeline/src/JobPanel.tsx @@ -0,0 +1,143 @@ +import { useState, useEffect } from "react"; +import { getPresets, getJobs, createJob, cancelJob } from "./api"; +import type { MediaAsset, TranscodePreset, TranscodeJob } from "./types"; + +interface JobPanelProps { + asset: MediaAsset; + trimStart: number; + trimEnd: number; +} + +export default function JobPanel({ asset, trimStart, trimEnd }: JobPanelProps) { + const [presets, setPresets] = useState([]); + const [jobs, setJobs] = useState([]); + const [selectedPresetId, setSelectedPresetId] = useState(""); + const [submitting, setSubmitting] = useState(false); + + // Load presets on mount + useEffect(() => { + getPresets().then(setPresets).catch(console.error); + }, []); + + // Poll jobs for this asset + useEffect(() => { + let active = true; + + const fetchJobs = () => { + getJobs() + .then((allJobs) => { + if (active) { + setJobs( + allJobs.filter((j) => j.source_asset_id === asset.id), + ); + } + }) + .catch(console.error); + }; + + fetchJobs(); + const interval = setInterval(fetchJobs, 3000); + return () => { + active = false; + clearInterval(interval); + }; + }, [asset.id]); + + const hasTrim = trimStart > 0 || (asset.duration != null && trimEnd < asset.duration); + const hasPreset = selectedPresetId !== ""; + const canSubmit = hasTrim || hasPreset; + + const buttonLabel = hasPreset + ? "Transcode" + : hasTrim + ? "Trim (Copy)" + : "Select trim or preset"; + + async function handleSubmit() { + setSubmitting(true); + try { + await createJob({ + source_asset_id: asset.id, + preset_id: selectedPresetId || null, + trim_start: hasTrim ? trimStart : null, + trim_end: hasTrim ? trimEnd : null, + }); + // Refresh jobs immediately + const allJobs = await getJobs(); + setJobs(allJobs.filter((j) => j.source_asset_id === asset.id)); + } catch (e) { + alert(e instanceof Error ? e.message : "Failed to create job"); + } finally { + setSubmitting(false); + } + } + + async function handleCancel(jobId: string) { + try { + await cancelJob(jobId); + const allJobs = await getJobs(); + setJobs(allJobs.filter((j) => j.source_asset_id === asset.id)); + } catch (e) { + console.error("Cancel failed:", e); + } + } + + return ( +
+
+ + +
+ + {jobs.length > 0 && ( +
+

Jobs

+ {jobs.map((job) => ( +
+
+ {job.output_filename} + {job.status} +
+ {job.status === "processing" && ( +
+
+
+ )} + {(job.status === "pending" || job.status === "processing") && ( + + )} + {job.status === "failed" && job.error_message && ( +
{job.error_message}
+ )} +
+ ))} +
+ )} +
+ ); +} diff --git a/ui/timeline/src/Timeline.tsx b/ui/timeline/src/Timeline.tsx new file mode 100644 index 0000000..d8919ca --- /dev/null +++ b/ui/timeline/src/Timeline.tsx @@ -0,0 +1,121 @@ +import { useRef, useCallback, useState, useEffect } from "react"; + +interface TimelineProps { + duration: number; + currentTime: number; + trimStart: number; + trimEnd: number; + onTrimChange: (start: number, end: number) => void; + onSeek: (time: number) => void; +} + +function formatTime(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + const ms = Math.floor((seconds % 1) * 10); + return `${m}:${s.toString().padStart(2, "0")}.${ms}`; +} + +export default function Timeline({ + duration, + currentTime, + trimStart, + trimEnd, + onTrimChange, + onSeek, +}: TimelineProps) { + const trackRef = useRef(null); + const [dragging, setDragging] = useState<"in" | "out" | null>(null); + + const timeToPercent = (t: number) => (duration > 0 ? (t / duration) * 100 : 0); + + const positionToTime = useCallback( + (clientX: number) => { + const track = trackRef.current; + if (!track || duration <= 0) return 0; + const rect = track.getBoundingClientRect(); + const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + return ratio * duration; + }, + [duration], + ); + + const handleTrackClick = (e: React.MouseEvent) => { + if (dragging) return; + onSeek(positionToTime(e.clientX)); + }; + + const handleMouseDown = (handle: "in" | "out") => (e: React.MouseEvent) => { + e.stopPropagation(); + setDragging(handle); + }; + + useEffect(() => { + if (!dragging) return; + + const minGap = 0.1; + + const handleMove = (e: MouseEvent) => { + const time = positionToTime(e.clientX); + if (dragging === "in") { + onTrimChange(Math.min(time, trimEnd - minGap), trimEnd); + } else { + onTrimChange(trimStart, Math.max(time, trimStart + minGap)); + } + }; + + const handleUp = () => setDragging(null); + + document.addEventListener("mousemove", handleMove); + document.addEventListener("mouseup", handleUp); + return () => { + document.removeEventListener("mousemove", handleMove); + document.removeEventListener("mouseup", handleUp); + }; + }, [dragging, trimStart, trimEnd, positionToTime, onTrimChange]); + + const inPct = timeToPercent(trimStart); + const outPct = timeToPercent(trimEnd); + const playheadPct = timeToPercent(currentTime); + const selectionDuration = trimEnd - trimStart; + + return ( +
+
+ In: {formatTime(trimStart)} + Selection: {formatTime(selectionDuration)} + Out: {formatTime(trimEnd)} +
+
+ {/* Dimmed regions */} +
+
+ + {/* Selection highlight */} +
+ + {/* Playhead */} +
+ + {/* Handles */} +
+
+
+
+ 0:00 + {formatTime(duration)} +
+
+ ); +} diff --git a/ui/timeline/src/types.ts b/ui/timeline/src/types.ts index 195e4af..608fd30 100644 --- a/ui/timeline/src/types.ts +++ b/ui/timeline/src/types.ts @@ -71,3 +71,25 @@ export interface TranscodeJob { started_at: string | null; completed_at: string | null; } + +// API request/response types + +export interface CreateJobRequest { + source_asset_id: string; + preset_id: string | null; + trim_start: number | null; + trim_end: number | null; + output_filename: string | null; +} + +export interface SystemStatus { + status: string; + version: string; +} + +export interface WorkerStatus { + available: boolean; + active_jobs: number; + supported_codecs: string[]; + gpu_available: boolean; +}