Compare commits
3 Commits
40ab840a71
...
5e941e6e6a
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e941e6e6a | |||
| 4a7d1e6663 | |||
| da898ef6f8 |
@@ -320,6 +320,18 @@ def replace_markers(video_id: str, markers: list[float], source: str) -> list[fl
|
||||
return sorted(markers)
|
||||
|
||||
|
||||
def list_segment_edits(video_id: str) -> list[dict]:
|
||||
with _DB_LOCK:
|
||||
conn = get_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT * FROM segment_edits WHERE video_id = ? ORDER BY segment_key",
|
||||
(video_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
conn.close()
|
||||
return [_row_to_dict(row) for row in rows]
|
||||
|
||||
def replace_segment_edits(video_id: str, segments: list[dict]) -> list[dict]:
|
||||
now = _now()
|
||||
with _DB_LOCK:
|
||||
|
||||
+20
-4
@@ -236,6 +236,11 @@ def replace_markers(video_id: str, payload: schemas.MarkersUpdate) -> dict:
|
||||
return {"markers": db.replace_markers(video_id, markers, source="manual")}
|
||||
|
||||
|
||||
@app.get("/api/videos/{video_id}/segment-edits", response_model=dict[str, list[schemas.SegmentEditOut]])
|
||||
def list_segment_edits(video_id: str) -> dict:
|
||||
_get_video_or_404(video_id)
|
||||
return {"segments": db.list_segment_edits(video_id)}
|
||||
|
||||
@app.put("/api/videos/{video_id}/segment-edits", response_model=dict[str, list[schemas.SegmentEditOut]])
|
||||
def replace_segment_edits(video_id: str, payload: schemas.SegmentEditsUpdate) -> dict:
|
||||
_get_video_or_404(video_id)
|
||||
@@ -306,6 +311,11 @@ def split_video(
|
||||
job = job_manager.create_job("split")
|
||||
job_id = job["id"]
|
||||
|
||||
segment_edits = db.list_segment_edits(video_id)
|
||||
custom_segments = None
|
||||
if segment_edits:
|
||||
custom_segments = [(se["start_seconds"], se["end_seconds"]) for se in segment_edits if se["segment_key"].startswith("segment_")]
|
||||
|
||||
def run_job() -> dict:
|
||||
project_dir = _project_dir(project["id"])
|
||||
output_dir = project_dir / "outputs" / video_id
|
||||
@@ -328,6 +338,7 @@ def split_video(
|
||||
ffmpeg_pass2_template=project["ffmpeg_pass2_template"],
|
||||
progress_cb=progress,
|
||||
log_cb=lambda message: job_manager.log(job_id, message),
|
||||
custom_segments=custom_segments,
|
||||
)
|
||||
return {"outputs": outputs, "output_dir": str(output_dir)}
|
||||
|
||||
@@ -346,7 +357,7 @@ def split_all_videos(payload: schemas.SplitAllRequest | None = Body(default=None
|
||||
if not videos:
|
||||
raise HTTPException(status_code=400, detail="No videos available to export")
|
||||
|
||||
ready: list[tuple[dict, list[float]]] = []
|
||||
ready: list[tuple[dict, list[float], list[tuple[float, float]] | None]] = []
|
||||
skipped: list[dict] = []
|
||||
for video in videos:
|
||||
try:
|
||||
@@ -355,10 +366,15 @@ def split_all_videos(payload: schemas.SplitAllRequest | None = Body(default=None
|
||||
skipped.append({"video_id": video["id"], "filename": video["filename"], "reason": exc.detail})
|
||||
continue
|
||||
markers = sorted(db.list_markers(video["id"]))
|
||||
if not markers:
|
||||
skipped.append({"video_id": video["id"], "filename": video["filename"], "reason": "No saved markers"})
|
||||
segment_edits = db.list_segment_edits(video["id"])
|
||||
custom_segments = None
|
||||
if segment_edits:
|
||||
custom_segments = [(se["start_seconds"], se["end_seconds"]) for se in segment_edits if se["segment_key"].startswith("segment_")]
|
||||
|
||||
if not markers and not custom_segments:
|
||||
skipped.append({"video_id": video["id"], "filename": video["filename"], "reason": "No saved markers or segments"})
|
||||
continue
|
||||
ready.append((video, markers))
|
||||
ready.append((video, markers, custom_segments))
|
||||
|
||||
if not ready:
|
||||
raise HTTPException(status_code=400, detail="No videos have saved markers ready to export")
|
||||
|
||||
+29
-21
@@ -348,35 +348,43 @@ def build_episodes(
|
||||
ffmpeg_pass2_template: str | None = None,
|
||||
progress_cb: ProgressCallback | None = None,
|
||||
log_cb: LogCallback | None = None,
|
||||
custom_segments: list[tuple[float, float]] | None = None,
|
||||
) -> list[str]:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
min_segment = 0.001
|
||||
|
||||
core_end = max(intro_seconds, total_duration - outro_seconds)
|
||||
boundaries = [p for p in sorted(cut_points) if intro_seconds < p < core_end]
|
||||
boundaries.append(core_end)
|
||||
|
||||
outputs: list[str] = []
|
||||
prev = intro_seconds
|
||||
safe_boundaries: list[float] = []
|
||||
|
||||
for end in boundaries:
|
||||
if end - prev <= min_segment:
|
||||
continue
|
||||
safe_boundaries.append(end)
|
||||
prev = end
|
||||
|
||||
if not safe_boundaries:
|
||||
raise RuntimeError("No valid segments after filtering short ranges")
|
||||
|
||||
prev = intro_seconds
|
||||
total_segments = len(safe_boundaries)
|
||||
core_ranges: list[tuple[int, float, float]] = []
|
||||
for index, end in enumerate(safe_boundaries, start=1):
|
||||
core_ranges.append((index, prev, end))
|
||||
prev = end
|
||||
min_segment = 0.001
|
||||
|
||||
if custom_segments and len(custom_segments) > 0:
|
||||
total_segments = len(custom_segments)
|
||||
for index, (start, end) in enumerate(custom_segments, start=1):
|
||||
core_ranges.append((index, start, end))
|
||||
else:
|
||||
core_end = max(intro_seconds, total_duration - outro_seconds)
|
||||
boundaries = [p for p in sorted(cut_points) if intro_seconds < p < core_end]
|
||||
boundaries.append(core_end)
|
||||
|
||||
prev = intro_seconds
|
||||
safe_boundaries: list[float] = []
|
||||
|
||||
for end in boundaries:
|
||||
if end - prev <= min_segment:
|
||||
continue
|
||||
safe_boundaries.append(end)
|
||||
prev = end
|
||||
|
||||
if not safe_boundaries:
|
||||
raise RuntimeError("No valid segments after filtering short ranges")
|
||||
|
||||
prev = intro_seconds
|
||||
total_segments = len(safe_boundaries)
|
||||
for index, end in enumerate(safe_boundaries, start=1):
|
||||
core_ranges.append((index, prev, end))
|
||||
prev = end
|
||||
|
||||
intro_work = intro_seconds if intro_seconds > min_segment else 0.0
|
||||
outro_start = max(0.0, total_duration - outro_seconds)
|
||||
|
||||
+187
-30
@@ -27,6 +27,7 @@ import {
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
Tooltip,
|
||||
Toolbar,
|
||||
Typography,
|
||||
@@ -36,8 +37,11 @@ import {
|
||||
Brightness4,
|
||||
Brightness7,
|
||||
CallSplit,
|
||||
ContentPaste,
|
||||
ContentCopy,
|
||||
Delete,
|
||||
Link,
|
||||
LinkOff,
|
||||
MyLocation,
|
||||
Settings,
|
||||
SkipNext,
|
||||
@@ -53,6 +57,7 @@ import {
|
||||
getJob,
|
||||
getVideoMetadata,
|
||||
listMarkers,
|
||||
listSegmentEdits,
|
||||
listVideos,
|
||||
replaceMarkers,
|
||||
replaceSegmentEdits,
|
||||
@@ -133,8 +138,22 @@ export default function App() {
|
||||
const [job, setJob] = useState<Job | null>(null);
|
||||
const [outputs, setOutputs] = useState<string[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
type SegmentRow = {
|
||||
kind: "intro" | "core" | "outro";
|
||||
label: string;
|
||||
start: number;
|
||||
end: number;
|
||||
startEditable: boolean;
|
||||
endEditable: boolean;
|
||||
coreIndex?: number;
|
||||
};
|
||||
|
||||
const [isLinked, setIsLinked] = useState(true);
|
||||
const [customSegmentRows, setCustomSegmentRows] = useState<SegmentRow[]>([]);
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -340,16 +359,6 @@ export default function App() {
|
||||
return Array.from(markMap.values()).sort((a, b) => a.value - b.value);
|
||||
}, [markers, project, duration]);
|
||||
|
||||
type SegmentRow = {
|
||||
kind: "intro" | "core" | "outro";
|
||||
label: string;
|
||||
start: number;
|
||||
end: number;
|
||||
startEditable: boolean;
|
||||
endEditable: boolean;
|
||||
coreIndex?: number;
|
||||
};
|
||||
|
||||
const introBoundary = useMemo(() => {
|
||||
if (!project) return 0;
|
||||
const maxDuration = duration || project.intro_seconds;
|
||||
@@ -394,7 +403,7 @@ export default function App() {
|
||||
});
|
||||
}, [duration, coreBoundaries, frameStep]);
|
||||
|
||||
const segmentRows = useMemo(() => {
|
||||
const derivedSegmentRows = useMemo(() => {
|
||||
if (!duration || !project || coreBoundaries.length < 2) return [] as SegmentRow[];
|
||||
const introEnd = Math.max(0, introBoundary - frameStep);
|
||||
const outroStart = clamp(alignedOutroBoundary, 0, duration);
|
||||
@@ -430,6 +439,8 @@ export default function App() {
|
||||
return rows;
|
||||
}, [duration, project, coreBoundaries, introBoundary, alignedOutroBoundary, lastFrameTime, frameStep, segments]);
|
||||
|
||||
const segmentRows = isLinked ? derivedSegmentRows : customSegmentRows;
|
||||
|
||||
useEffect(() => {
|
||||
if (!segmentRows.length) {
|
||||
setSegmentDrafts([]);
|
||||
@@ -520,7 +531,7 @@ export default function App() {
|
||||
}, [segments, selectedFrameTime]);
|
||||
|
||||
const frameHighlight = useMemo(() => {
|
||||
const tolerance = frameStep / 2;
|
||||
const tolerance = Math.max(frameStep / 2, 0.005);
|
||||
for (let i = 0; i < segments.length; i += 1) {
|
||||
const segment = segments[i];
|
||||
if (Math.abs(selectedFrameTime - segment.start) <= tolerance) {
|
||||
@@ -602,9 +613,12 @@ export default function App() {
|
||||
if (videoFiles.length === 0) return;
|
||||
setError(null);
|
||||
setIsUploading(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
const uploadedItems = await uploadVideos(videoFiles);
|
||||
const uploadedItems = await uploadVideos(videoFiles, (index, total, progress) => {
|
||||
setUploadProgress((index + progress) / total);
|
||||
});
|
||||
const uploaded = uploadedItems[0];
|
||||
await refreshVideos();
|
||||
if (uploaded) {
|
||||
@@ -616,6 +630,7 @@ export default function App() {
|
||||
setOutputs([]);
|
||||
setJob(null);
|
||||
setOutputPrefix(uploaded.filename.replace(/\.[^.]+$/, ""));
|
||||
setIsLinked(true);
|
||||
}
|
||||
setVideoFiles([]);
|
||||
setPreviewUrl("");
|
||||
@@ -639,9 +654,29 @@ export default function App() {
|
||||
setOutputPrefix(item.filename.replace(/\.[^.]+$/, ""));
|
||||
setVideoFiles([]);
|
||||
setPreviewUrl("");
|
||||
setIsLinked(true);
|
||||
try {
|
||||
const response = await listMarkers(item.id);
|
||||
setMarkers(normalizeMarkers(response.markers ?? []));
|
||||
|
||||
const editsResponse = await listSegmentEdits(item.id);
|
||||
if (editsResponse.segments && editsResponse.segments.length > 0) {
|
||||
setIsLinked(false);
|
||||
setCustomSegmentRows(
|
||||
editsResponse.segments.map((se) => ({
|
||||
kind: se.segment_key.startsWith("segment_") ? "core" : (se.segment_key as any),
|
||||
label: se.segment_key,
|
||||
start: se.start_seconds,
|
||||
end: se.end_seconds,
|
||||
startEditable: true,
|
||||
endEditable: true,
|
||||
coreIndex: se.segment_key.startsWith("segment_") ? parseInt(se.segment_key.split("_")[1]) - 1 : undefined,
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
setIsLinked(true);
|
||||
setCustomSegmentRows([]);
|
||||
}
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
@@ -798,7 +833,18 @@ export default function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
const snapped = snapToFrame(parsed);
|
||||
const snapped = parsed;
|
||||
|
||||
if (!isLinked) {
|
||||
setCustomSegmentRows((prev) => {
|
||||
const next = [...prev];
|
||||
next[rowIndex] = { ...next[rowIndex], [field]: snapped };
|
||||
return next;
|
||||
});
|
||||
setPendingSegmentEditPersist(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let boundaryIndex = -1;
|
||||
let boundaryValue = snapped;
|
||||
|
||||
@@ -840,10 +886,17 @@ export default function App() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddMarker = () => {
|
||||
if (!duration) return;
|
||||
const handleAddMarker = async () => {
|
||||
if (!video || !duration) return;
|
||||
const snapped = snapToFrame(cursorTime);
|
||||
setMarkers((prev) => normalizeMarkers([...prev, snapped]));
|
||||
const nextMarkers = normalizeMarkers([...markers, snapped]);
|
||||
setMarkers(nextMarkers);
|
||||
try {
|
||||
await replaceMarkers(video.id, nextMarkers);
|
||||
setPendingSegmentEditPersist(true);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoToTime = (value: number) => {
|
||||
@@ -854,6 +907,12 @@ export default function App() {
|
||||
|
||||
const handleDeleteSegment = async (row: SegmentRow) => {
|
||||
setError(null);
|
||||
if (!isLinked) {
|
||||
setCustomSegmentRows((prev) => prev.filter((r) => r !== row));
|
||||
setPendingSegmentEditPersist(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (row.kind === "intro") {
|
||||
await saveProject({ intro_seconds: 0 });
|
||||
setPendingSegmentEditPersist(true);
|
||||
@@ -1014,6 +1073,14 @@ export default function App() {
|
||||
Project settings
|
||||
</Button>
|
||||
</Box>
|
||||
{isUploading && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<LinearProgress variant="determinate" value={uploadProgress * 100} />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
Uploading... {Math.round(uploadProgress * 100)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Box>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||
<Typography variant="subtitle2">Recent uploads</Typography>
|
||||
@@ -1352,9 +1419,8 @@ export default function App() {
|
||||
<Button variant="outlined" onClick={handleAddMarker} disabled={!duration}>
|
||||
Add marker at playhead
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={handleSaveMarkers} disabled={!video || markers.length === 0}>
|
||||
Save markers
|
||||
</Button>
|
||||
|
||||
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
@@ -1549,17 +1615,108 @@ export default function App() {
|
||||
</Button>
|
||||
</Box>
|
||||
{(job?.kind === "split" || job?.kind === "split_all") && job.status !== "completed" && (
|
||||
<Box>
|
||||
<LinearProgress variant="determinate" value={Math.round(job.progress * 100)} />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
{job.message || `Status: ${job.status}`}
|
||||
<Box sx={{ p: 2, bgcolor: "background.paper", borderRadius: 4, border: "1px solid", borderColor: "divider" }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{job.kind === "split_all" ? "Bulk Export Progress" : "Export Progress"}
|
||||
</Typography>
|
||||
{job.details && (
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} sx={{ mt: 1 }}>
|
||||
<Chip size="small" label={`Stage: ${job.details.stage ?? "working"}`} />
|
||||
<Chip size="small" label={`Stage ETA: ${job.details.stage_eta ?? "calculating"}`} />
|
||||
<Chip size="small" label={`Total ETA: ${job.details.total_eta ?? "calculating"}`} />
|
||||
</Stack>
|
||||
|
||||
{job.kind === "split_all" && job.details?.label && (() => {
|
||||
const match = /^(\d+)\/(\d+) (.+)$/.exec(job.details.label);
|
||||
if (match) {
|
||||
const index = Number(match[1]);
|
||||
const total = Number(match[2]);
|
||||
const filename = match[3];
|
||||
const videoProgress = Math.max(0, Math.min(100, (job.progress - (index - 1) / total) * total * 100));
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Overall Bulk Progress ({index}/{total} videos)
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={Math.round(job.progress * 100)}
|
||||
sx={{ height: 12, borderRadius: 6, mb: 2, bgcolor: "primary.light" }}
|
||||
/>
|
||||
|
||||
<Typography variant="body2" color="text.primary" sx={{ fontWeight: "bold" }}>
|
||||
Current Video: {filename}
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={Math.round(videoProgress)}
|
||||
color="secondary"
|
||||
sx={{ height: 8, borderRadius: 4, mb: 2 }}
|
||||
/>
|
||||
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Current Stage: {job.details.stage || "working"}
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={job.details.stage_percent || 0}
|
||||
color="info"
|
||||
sx={{ height: 4, borderRadius: 2 }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{job.kind === "split" && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" color="text.primary" gutterBottom>
|
||||
Video Progress
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={Math.round(job.progress * 100)}
|
||||
color="secondary"
|
||||
sx={{ height: 8, borderRadius: 4, mb: 2 }}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Current Stage: {job.details?.stage || "working"}
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={job.details?.stage_percent || 0}
|
||||
color="info"
|
||||
sx={{ height: 4, borderRadius: 2 }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} sx={{ mt: 2 }}>
|
||||
{job.details?.stage_eta && <Chip size="small" label={`Stage ETA: ${job.details.stage_eta}`} />}
|
||||
{job.details?.total_eta && <Chip size="small" label={`Total ETA: ${job.details.total_eta}`} />}
|
||||
<Chip size="small" color={job.status === "running" ? "primary" : "default"} label={`Status: ${job.status}`} />
|
||||
</Stack>
|
||||
|
||||
{job.kind === "split_all" && (
|
||||
<Box sx={{ mt: 3, p: 2, bgcolor: "background.default", borderRadius: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Queue Queue
|
||||
</Typography>
|
||||
<Stack spacing={0.5}>
|
||||
{videoList.map((v, i) => {
|
||||
const match = job.details?.label ? /^(\d+)\/(\d+) (.+)$/.exec(job.details.label) : null;
|
||||
const currentIndex = match ? Number(match[1]) - 1 : -1;
|
||||
const status = i < currentIndex ? "completed" : i === currentIndex ? "running" : "pending";
|
||||
return (
|
||||
<Typography
|
||||
key={v.id}
|
||||
variant="body2"
|
||||
color={status === "completed" ? "success.main" : status === "running" ? "primary.main" : "text.disabled"}
|
||||
sx={{ fontWeight: status === "running" ? "bold" : "normal" }}
|
||||
>
|
||||
{status === "completed" ? "✓ " : status === "running" ? "▶ " : "• "}
|
||||
{v.filename}
|
||||
</Typography>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
+51
-3
@@ -116,14 +116,62 @@ export async function replaceMarkers(
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadVideos(files: File[]): Promise<Video[]> {
|
||||
export async function uploadVideoWithProgress(file: File, onProgress?: (progress: number) => void): Promise<Video> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", `${apiBase}/api/videos/upload`);
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable && onProgress) {
|
||||
onProgress(event.loaded / event.total);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
resolve(JSON.parse(xhr.responseText));
|
||||
} catch {
|
||||
reject(new ApiError(xhr.status, "Invalid response"));
|
||||
}
|
||||
} else {
|
||||
let message = `Request failed (${xhr.status})`;
|
||||
try {
|
||||
const data = JSON.parse(xhr.responseText);
|
||||
if (data?.detail) message = data.detail;
|
||||
else if (data?.message) message = data.message;
|
||||
} catch {}
|
||||
reject(new ApiError(xhr.status, message));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => reject(new ApiError(0, "Network error"));
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadVideos(
|
||||
files: File[],
|
||||
onProgress?: (index: number, total: number, fileProgress: number) => void
|
||||
): Promise<Video[]> {
|
||||
const uploaded: Video[] = [];
|
||||
for (const file of files) {
|
||||
uploaded.push(await uploadVideo(file));
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const video = await uploadVideoWithProgress(file, (progress) => {
|
||||
if (onProgress) onProgress(i, files.length, progress);
|
||||
});
|
||||
uploaded.push(video);
|
||||
}
|
||||
return uploaded;
|
||||
}
|
||||
|
||||
export async function listSegmentEdits(videoId: string): Promise<{ segments: SegmentEdit[] }> {
|
||||
return request(`/api/videos/${videoId}/segment-edits`);
|
||||
}
|
||||
|
||||
export async function replaceSegmentEdits(
|
||||
videoId: string,
|
||||
segments: { segment_key: string; start_seconds: number; end_seconds: number }[]
|
||||
|
||||
Reference in New Issue
Block a user