ui video selector
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
143
ui/timeline/src/JobPanel.tsx
Normal file
143
ui/timeline/src/JobPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
ui/timeline/src/Timeline.tsx
Normal file
121
ui/timeline/src/Timeline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user