ui video selector

This commit is contained in:
2026-02-06 09:41:50 -03:00
parent daabd15c19
commit 2cf6c89fbb
7 changed files with 613 additions and 47 deletions

View File

@@ -28,7 +28,9 @@ class TypeScriptGenerator(BaseGenerator):
if hasattr(models, "models"): if hasattr(models, "models"):
# SchemaLoader # SchemaLoader
content = self._generate_from_definitions( 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): elif isinstance(models, tuple):
# (models, enums) tuple # (models, enums) tuple
@@ -42,7 +44,10 @@ class TypeScriptGenerator(BaseGenerator):
output_path.write_text(content) output_path.write_text(content)
def _generate_from_definitions( def _generate_from_definitions(
self, models: List[ModelDefinition], enums: List[EnumDefinition] self,
models: List[ModelDefinition],
enums: List[EnumDefinition],
api_models: List[ModelDefinition] = None,
) -> str: ) -> str:
"""Generate from ModelDefinition objects.""" """Generate from ModelDefinition objects."""
lines = self._generate_header() lines = self._generate_header()
@@ -58,6 +63,14 @@ class TypeScriptGenerator(BaseGenerator):
lines.extend(self._generate_interface_from_definition(model_def)) lines.extend(self._generate_interface_from_definition(model_def))
lines.append("") 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) return "\n".join(lines)
def _generate_from_dataclasses(self, dataclasses: List[type]) -> str: def _generate_from_dataclasses(self, dataclasses: List[type]) -> str:

View File

@@ -60,6 +60,7 @@ class SchemaLoader:
def __init__(self, schema_path: Path): def __init__(self, schema_path: Path):
self.schema_path = Path(schema_path) self.schema_path = Path(schema_path)
self.models: List[ModelDefinition] = [] self.models: List[ModelDefinition] = []
self.api_models: List[ModelDefinition] = []
self.enums: List[EnumDefinition] = [] self.enums: List[EnumDefinition] = []
self.grpc_messages: List[ModelDefinition] = [] self.grpc_messages: List[ModelDefinition] = []
self.grpc_service: Optional[GrpcServiceDefinition] = None self.grpc_service: Optional[GrpcServiceDefinition] = None
@@ -79,6 +80,11 @@ class SchemaLoader:
for cls in dataclasses: for cls in dataclasses:
self.models.append(self._parse_dataclass(cls)) 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 # Extract ENUMS
enums = getattr(module, "ENUMS", []) enums = getattr(module, "ENUMS", [])
for enum_cls in enums: for enum_cls in enums:

View File

@@ -177,41 +177,259 @@ body {
} }
.timeline-container { .timeline-container {
height: 120px;
background: #252525; background: #252525;
border-top: 1px solid #333; border-top: 1px solid #333;
padding: 0.75rem 1rem;
} }
.timeline-placeholder { /* Timeline component */
.timeline {
user-select: none;
}
.timeline-times {
display: flex; display: flex;
align-items: center; justify-content: space-between;
justify-content: center; font-size: 0.75rem;
height: 100%; color: #aaa;
color: #666; margin-bottom: 0.5rem;
font-variant-numeric: tabular-nums;
} }
.info { .timeline-track {
padding: 1rem; 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; background: #202020;
border-top: 1px solid #333; border-top: 1px solid #333;
} }
.info h3 { .job-controls {
margin-bottom: 0.5rem; display: flex;
font-size: 1rem; gap: 0.5rem;
align-items: center;
} }
.info dl { .preset-select {
display: grid; flex: 1;
grid-template-columns: auto 1fr; padding: 0.375rem 0.5rem;
gap: 0.25rem 1rem; font-size: 0.8rem;
font-size: 0.875rem; background: #333;
}
.info dt {
color: #888;
}
.info dd {
color: #e0e0e0; 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;
} }

View File

@@ -1,6 +1,8 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { getAssets, getSystemStatus, scanMediaFolder } from "./api"; import { getAssets, getSystemStatus, scanMediaFolder } from "./api";
import type { MediaAsset, SystemStatus } from "./types"; import type { MediaAsset, SystemStatus } from "./types";
import Timeline from "./Timeline";
import JobPanel from "./JobPanel";
import "./App.css"; import "./App.css";
function App() { function App() {
@@ -11,6 +13,13 @@ function App() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [scanning, setScanning] = useState(false); const [scanning, setScanning] = useState(false);
// Video sync state
const videoRef = useRef<HTMLVideoElement>(null);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [trimStart, setTrimStart] = useState(0);
const [trimEnd, setTrimEnd] = useState(0);
useEffect(() => { useEffect(() => {
async function load() { async function load() {
try { try {
@@ -31,6 +40,41 @@ function App() {
load(); 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() { async function handleScan() {
setScanning(true); setScanning(true);
setError(null); setError(null);
@@ -40,7 +84,6 @@ function App() {
`Scan complete!\nFound: ${result.found}\nRegistered: ${result.registered}\nSkipped: ${result.skipped}`, `Scan complete!\nFound: ${result.found}\nRegistered: ${result.registered}\nSkipped: ${result.skipped}`,
); );
// Reload assets
const assetsData = await getAssets(); const assetsData = await getAssets();
setAssets( setAssets(
assetsData.sort((a, b) => a.filename.localeCompare(b.filename)), assetsData.sort((a, b) => a.filename.localeCompare(b.filename)),
@@ -101,29 +144,29 @@ function App() {
{selectedAsset ? ( {selectedAsset ? (
<div className="editor"> <div className="editor">
<div className="video-container"> <div className="video-container">
<video controls src={`/media/${selectedAsset.file_path}`} /> <video
ref={videoRef}
controls
src={`/media/${selectedAsset.file_path}`}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
/>
</div> </div>
<div className="timeline-container"> <div className="timeline-container">
{/* Timeline component will go here */} <Timeline
<div className="timeline-placeholder"> duration={duration}
Timeline: {selectedAsset.duration?.toFixed(1)}s currentTime={currentTime}
</div> trimStart={trimStart}
</div> trimEnd={trimEnd}
<div className="info"> onTrimChange={handleTrimChange}
<h3>{selectedAsset.filename}</h3> onSeek={handleSeek}
<dl> />
<dt>Duration</dt>
<dd>{selectedAsset.duration?.toFixed(2)}s</dd>
<dt>Resolution</dt>
<dd>
{selectedAsset.width}x{selectedAsset.height}
</dd>
<dt>Video</dt>
<dd>{selectedAsset.video_codec}</dd>
<dt>Audio</dt>
<dd>{selectedAsset.audio_codec}</dd>
</dl>
</div> </div>
<JobPanel
asset={selectedAsset}
trimStart={trimStart}
trimEnd={trimEnd}
/>
</div> </div>
) : ( ) : (
<div className="empty">Select an asset to begin</div> <div className="empty">Select an asset to begin</div>

View File

@@ -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<TranscodePreset[]>([]);
const [jobs, setJobs] = useState<TranscodeJob[]>([]);
const [selectedPresetId, setSelectedPresetId] = useState<string>("");
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 (
<div className="job-panel">
<div className="job-controls">
<select
value={selectedPresetId}
onChange={(e) => setSelectedPresetId(e.target.value)}
className="preset-select"
>
<option value="">No preset (trim only)</option>
{presets.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<button
onClick={handleSubmit}
disabled={!canSubmit || submitting}
className="enqueue-button"
>
{submitting ? "Submitting..." : buttonLabel}
</button>
</div>
{jobs.length > 0 && (
<div className="job-list">
<h3>Jobs</h3>
{jobs.map((job) => (
<div key={job.id} className="job-item">
<div className="job-item-header">
<span className="job-filename">{job.output_filename}</span>
<span className={`job-status ${job.status}`}>{job.status}</span>
</div>
{job.status === "processing" && (
<div className="job-progress-bar">
<div
className="job-progress-fill"
style={{ width: `${job.progress}%` }}
/>
</div>
)}
{(job.status === "pending" || job.status === "processing") && (
<button
className="job-cancel"
onClick={() => handleCancel(job.id)}
>
Cancel
</button>
)}
{job.status === "failed" && job.error_message && (
<div className="job-error">{job.error_message}</div>
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -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<HTMLDivElement>(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 (
<div className="timeline">
<div className="timeline-times">
<span>In: {formatTime(trimStart)}</span>
<span>Selection: {formatTime(selectionDuration)}</span>
<span>Out: {formatTime(trimEnd)}</span>
</div>
<div className="timeline-track" ref={trackRef} onClick={handleTrackClick}>
{/* Dimmed regions */}
<div className="timeline-dim" style={{ left: 0, width: `${inPct}%` }} />
<div className="timeline-dim" style={{ left: `${outPct}%`, width: `${100 - outPct}%` }} />
{/* Selection highlight */}
<div
className="timeline-selection"
style={{ left: `${inPct}%`, width: `${outPct - inPct}%` }}
/>
{/* Playhead */}
<div className="timeline-playhead" style={{ left: `${playheadPct}%` }} />
{/* Handles */}
<div
className={`timeline-handle timeline-handle-in ${dragging === "in" ? "dragging" : ""}`}
style={{ left: `${inPct}%` }}
onMouseDown={handleMouseDown("in")}
/>
<div
className={`timeline-handle timeline-handle-out ${dragging === "out" ? "dragging" : ""}`}
style={{ left: `${outPct}%` }}
onMouseDown={handleMouseDown("out")}
/>
</div>
<div className="timeline-duration">
<span>0:00</span>
<span>{formatTime(duration)}</span>
</div>
</div>
);
}

View File

@@ -71,3 +71,25 @@ export interface TranscodeJob {
started_at: string | null; started_at: string | null;
completed_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;
}