Add jobs timers
This commit is contained in:
@@ -443,8 +443,8 @@ def _seed() -> None:
|
||||
# --- DB snapshot (--data-dir) ------------------------------------------------
|
||||
|
||||
|
||||
def _decode_references(value: Any) -> list[dict[str, Any]]:
|
||||
"""Reference lists round-trip through the DB as JSON text."""
|
||||
def _decode_json_list(value: Any) -> list[dict[str, Any]]:
|
||||
"""References and edit_segments both round-trip through the DB as JSON text."""
|
||||
if not value:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
@@ -476,7 +476,8 @@ def _job_from_snapshot(row: dict[str, Any]) -> dict[str, Any]:
|
||||
status = "pending"
|
||||
job["queued_at"] = None
|
||||
job["status"] = status
|
||||
job["references"] = _decode_references(row.get("references"))
|
||||
job["references"] = _decode_json_list(row.get("references"))
|
||||
job["edit_segments"] = _decode_json_list(row.get("edit_segments"))
|
||||
job["progress"] = 100.0 if status == "completed" else 0.0
|
||||
job["progress_msg"] = "50/50 steps" if status == "completed" else ""
|
||||
job["phase"] = "done" if status == "completed" else status
|
||||
|
||||
@@ -4,10 +4,21 @@ import type { NextConfig } from 'next';
|
||||
|
||||
const configDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Where /api/* is proxied when the frontend is built with
|
||||
// NEXT_PUBLIC_API_BASE_URL=/api, so the web app and API share one origin --
|
||||
// e.g. behind a single ngrok tunnel. Read at build time.
|
||||
const apiProxyTarget = (process.env.STUDIO_API_PROXY_TARGET || 'http://127.0.0.1:8189').replace(/\/+$/, '');
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Point tracing at the monorepo root (apps/fastvideo_studio -> apps -> repo
|
||||
// root) so Next stops warning about multiple lockfiles in the workspace.
|
||||
outputFileTracingRoot: path.join(configDir, '..', '..'),
|
||||
// `next dev` blocks its dev-only assets/HMR for pages opened from a host
|
||||
// other than localhost; allow the ngrok tunnel's domains. No effect in prod.
|
||||
allowedDevOrigins: ['*.ngrok-free.app', '*.ngrok.app', '*.ngrok.io'],
|
||||
async rewrites() {
|
||||
return [{ source: '/api/:path*', destination: `${apiProxyTarget}/api/:path*` }];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Timer } from 'lucide-react';
|
||||
import CreateJobModal from '@/components/jobs/CreateJobModal';
|
||||
import { Badge, type BadgeProps } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useJobElapsed } from '@/hooks/useJobElapsed';
|
||||
import { useStore } from '@/hooks/useStore';
|
||||
import {
|
||||
deleteJob,
|
||||
@@ -25,32 +26,6 @@ interface JobCardProps {
|
||||
onJobUpdated?: () => void;
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const roundedSeconds = Math.round(seconds);
|
||||
if (roundedSeconds < 60) return `${roundedSeconds}s`;
|
||||
if (roundedSeconds < 3600) {
|
||||
const mins = Math.floor(roundedSeconds / 60);
|
||||
const secs = roundedSeconds % 60;
|
||||
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
|
||||
}
|
||||
const hours = Math.floor(roundedSeconds / 3600);
|
||||
const mins = Math.floor((roundedSeconds % 3600) / 60);
|
||||
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
||||
}
|
||||
|
||||
function computeElapsed(job: Job, currentTime: number): string | null {
|
||||
if (!job.started_at) return null;
|
||||
const endTime =
|
||||
job.status === 'running' ? currentTime : (job.finished_at ?? 0);
|
||||
if (!endTime && job.status !== 'running') return null;
|
||||
const startedAtMs =
|
||||
job.started_at < 1e12 ? job.started_at * 1000 : job.started_at;
|
||||
const endTimeMs = endTime < 1e12 ? endTime * 1000 : endTime;
|
||||
const elapsedSeconds = (endTimeMs - startedAtMs) / 1000;
|
||||
if (elapsedSeconds <= 0) return null;
|
||||
return formatDuration(elapsedSeconds);
|
||||
}
|
||||
|
||||
const BADGE_VARIANTS: Record<string, BadgeProps['variant']> = {
|
||||
pending: 'secondary',
|
||||
queued: 'default',
|
||||
@@ -69,15 +44,7 @@ export default function JobCard({ job, onJobUpdated }: JobCardProps) {
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const [isViewing, setIsViewing] = React.useState(false);
|
||||
const [currentTime, setCurrentTime] = React.useState(() => Date.now());
|
||||
|
||||
const elapsedTime = computeElapsed(job, currentTime);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (job.status !== 'running' || !job.started_at) return;
|
||||
const interval = setInterval(() => setCurrentTime(Date.now()), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [job.status, job.started_at]);
|
||||
const elapsedTime = useJobElapsed(job);
|
||||
|
||||
async function handleStart(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Timer } from 'lucide-react';
|
||||
|
||||
import { H3ShotPanel } from '@/components/jobs/H3ShotPanel';
|
||||
import { Badge, type BadgeProps } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useJobElapsed } from '@/hooks/useJobElapsed';
|
||||
import { NativeSelect } from '@/components/ui/native-select';
|
||||
import { getJobVideoUrl } from '@/lib/api';
|
||||
import { attachedLastFrames, whyCannotAttach } from '@/lib/continuation';
|
||||
import { formatCutTime } from '@/lib/h3Shots';
|
||||
import { computeElapsed } from '@/lib/jobTiming';
|
||||
import { QUEUEABLE_STATUSES } from '@/lib/sceneQueue';
|
||||
import { formatClock, type Scene, type SceneClip } from '@/lib/scenes';
|
||||
import type { Job } from '@/lib/types';
|
||||
@@ -45,7 +48,8 @@ function takeLabel(job: Job, index: number, total: number): string {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return `Take ${index + 1} of ${total} · ${job.status} · ${when}`;
|
||||
const ran = job.status === 'running' ? null : computeElapsed(job, 0);
|
||||
return `Take ${index + 1} of ${total} · ${job.status}${ran ? ` in ${ran}` : ''} · ${when}`;
|
||||
}
|
||||
|
||||
function scrollToClip(id: string) {
|
||||
@@ -132,6 +136,7 @@ function ClipRow({
|
||||
const [attaching, setAttaching] = React.useState(false);
|
||||
const [queueing, setQueueing] = React.useState(false);
|
||||
const { job } = clip;
|
||||
const elapsed = useJobElapsed(job);
|
||||
const canPreview = job.status === 'completed' && !!job.output_path;
|
||||
const editable = EDITABLE_STATUSES.includes(job.status);
|
||||
|
||||
@@ -176,6 +181,15 @@ function ClipRow({
|
||||
</span>
|
||||
<h3 className="min-w-0 truncate text-sm font-semibold text-foreground">{clipLabel(clip)}</h3>
|
||||
<Badge variant={BADGE_VARIANTS[job.status] ?? 'secondary'}>{job.status}</Badge>
|
||||
{elapsed && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground"
|
||||
title={job.status === 'running' ? 'Running for' : 'Took'}
|
||||
>
|
||||
<Timer className="size-3.5" aria-hidden />
|
||||
{elapsed}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{formatClock(clip.startSeconds)}–{formatClock(clip.startSeconds + clip.durationSeconds)}
|
||||
</span>
|
||||
|
||||
@@ -53,7 +53,7 @@ const NEUTRAL_SECTION: Omit<EditableSection, 'key'> = { end_seconds: null, ...NE
|
||||
/** The clip's last-applied sections, or one neutral section covering everything if it was never edited. */
|
||||
function sectionsFromJob(job: Job): EditableSection[] {
|
||||
const segments = job.edit_segments;
|
||||
if (segments && segments.length > 0) {
|
||||
if (Array.isArray(segments) && segments.length > 0) {
|
||||
return segments.map((s) => ({ ...s, key: makeSectionKey() }));
|
||||
}
|
||||
return [{ ...NEUTRAL_SECTION, key: makeSectionKey() }];
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { computeElapsed } from '@/lib/jobTiming';
|
||||
import type { Job } from '@/lib/types';
|
||||
|
||||
/** A job's formatted run time, ticking every second while it runs. */
|
||||
export function useJobElapsed(job: Job): string | null {
|
||||
const [currentTime, setCurrentTime] = React.useState(() => Date.now());
|
||||
|
||||
React.useEffect(() => {
|
||||
if (job.status !== 'running' || !job.started_at) return;
|
||||
const interval = setInterval(() => setCurrentTime(Date.now()), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [job.status, job.started_at]);
|
||||
|
||||
return computeElapsed(job, currentTime);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeElapsed, formatDuration } from "@/lib/jobTiming";
|
||||
import type { Job } from "@/lib/types";
|
||||
|
||||
function job(over: Partial<Job>): Job {
|
||||
return { id: "j", model_id: "m", prompt: "", status: "pending", created_at: 0, ...over } as Job;
|
||||
}
|
||||
|
||||
describe("formatDuration", () => {
|
||||
it("formats seconds, minutes and hours", () => {
|
||||
expect(formatDuration(42)).toBe("42s");
|
||||
expect(formatDuration(342)).toBe("5m 42s");
|
||||
expect(formatDuration(300)).toBe("5m");
|
||||
expect(formatDuration(3900)).toBe("1h 5m");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeElapsed", () => {
|
||||
it("is empty until the job leaves the queue", () => {
|
||||
expect(computeElapsed(job({ status: "pending" }), 5_000_000)).toBeNull();
|
||||
expect(computeElapsed(job({ status: "queued", started_at: null }), 5_000_000)).toBeNull();
|
||||
});
|
||||
|
||||
it("counts up from started_at while running", () => {
|
||||
const startedAt = 1_790_000_000; // server time, in seconds
|
||||
expect(computeElapsed(job({ status: "running", started_at: startedAt }), (startedAt + 90) * 1000)).toBe("1m 30s");
|
||||
});
|
||||
|
||||
it("freezes at finished_at once done, whatever the clock says", () => {
|
||||
const done = job({ status: "completed", started_at: 1000, finished_at: 1342 });
|
||||
expect(computeElapsed(done, 9_999_999_999)).toBe("5m 42s");
|
||||
expect(computeElapsed(done, 0)).toBe("5m 42s");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Job } from "@/lib/types";
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
const roundedSeconds = Math.round(seconds);
|
||||
if (roundedSeconds < 60) return `${roundedSeconds}s`;
|
||||
if (roundedSeconds < 3600) {
|
||||
const mins = Math.floor(roundedSeconds / 60);
|
||||
const secs = roundedSeconds % 60;
|
||||
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
|
||||
}
|
||||
const hours = Math.floor(roundedSeconds / 3600);
|
||||
const mins = Math.floor((roundedSeconds % 3600) / 60);
|
||||
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
||||
}
|
||||
|
||||
function toMs(time: number): number {
|
||||
return time < 1e12 ? time * 1000 : time;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a job has run, formatted: live while running (the server sets
|
||||
* `started_at` when it leaves the queue, so queue time isn't counted), then
|
||||
* frozen at `finished_at`. Null for a job that hasn't started.
|
||||
*/
|
||||
export function computeElapsed(job: Job, currentTime: number): string | null {
|
||||
if (!job.started_at) return null;
|
||||
const endTime = job.status === "running" ? currentTime : (job.finished_at ?? 0);
|
||||
if (!endTime) return null;
|
||||
const elapsedSeconds = (toMs(endTime) - toMs(job.started_at)) / 1000;
|
||||
if (elapsedSeconds <= 0) return null;
|
||||
return formatDuration(elapsedSeconds);
|
||||
}
|
||||
@@ -218,6 +218,31 @@ describe("takes of the same clip", () => {
|
||||
expect(scene.clips[0].job.id).toBe("p2");
|
||||
});
|
||||
|
||||
it.each(["pending", "queued", "running"])("shows a %s redo over the completed take it replaces", (status) => {
|
||||
const [scene] = buildScenes([
|
||||
take("done", 1, { created_at: 1, status: "completed", finished_at: 10 }),
|
||||
take("redo", 1, { created_at: 2, status }),
|
||||
]);
|
||||
expect(scene.clips[0].job.id).toBe("redo");
|
||||
});
|
||||
|
||||
it("shows the newest of several in-flight redos", () => {
|
||||
const [scene] = buildScenes([
|
||||
take("done", 1, { created_at: 1, status: "completed", finished_at: 10 }),
|
||||
take("r1", 1, { created_at: 2, status: "pending" }),
|
||||
take("r2", 1, { created_at: 3, status: "pending" }),
|
||||
]);
|
||||
expect(scene.clips[0].job.id).toBe("r2");
|
||||
});
|
||||
|
||||
it("doesn't let a stale pending take hide a take that completed after it was made", () => {
|
||||
const [scene] = buildScenes([
|
||||
take("stale", 1, { created_at: 1, status: "pending" }),
|
||||
take("done", 1, { created_at: 2, status: "completed", finished_at: 10 }),
|
||||
]);
|
||||
expect(scene.clips[0].job.id).toBe("done");
|
||||
});
|
||||
|
||||
it("shows the take the user chose, and lays the timeline out from it", () => {
|
||||
const jobs = [
|
||||
take("short", 1, { created_at: 1, status: "completed", num_frames: 120 }),
|
||||
|
||||
@@ -94,13 +94,25 @@ function createdAt(job: Job): number {
|
||||
return job.created_at ?? 0;
|
||||
}
|
||||
|
||||
/** The take to show: the one the user chose, else the most recent completed one, else the newest. */
|
||||
/** Statuses of a take that is still on its way to a result. */
|
||||
const IN_FLIGHT_STATUSES = ["running", "queued", "pending"];
|
||||
|
||||
/**
|
||||
* The take to show: the one the user chose, else the newest in-flight take created
|
||||
* after the latest completed one (a redo in progress), else the most recent
|
||||
* completed one, else the newest.
|
||||
*/
|
||||
function pickTake(takes: Job[], choice?: string): Job {
|
||||
const chosen = choice ? takes.find((j) => j.id === choice) : undefined;
|
||||
if (chosen) return chosen;
|
||||
const newest = (a: Job, b: Job) => createdAt(b) - createdAt(a);
|
||||
const recent = (a: Job, b: Job) => (b.finished_at ?? createdAt(b)) - (a.finished_at ?? createdAt(a));
|
||||
const completed = takes.filter((j) => j.status === "completed").sort(recent);
|
||||
return completed[0] ?? [...takes].sort((a, b) => createdAt(b) - createdAt(a))[0];
|
||||
const latestCompletedAt = Math.max(-Infinity, ...completed.map(createdAt));
|
||||
const redo = takes
|
||||
.filter((j) => IN_FLIGHT_STATUSES.includes(j.status) && createdAt(j) > latestCompletedAt)
|
||||
.sort(newest)[0];
|
||||
return redo ?? completed[0] ?? [...takes].sort(newest)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -426,3 +426,47 @@ def test_trimmed_frame_count_does_not_leak_the_internal_backup_key(client):
|
||||
res = client.post(f"/api/jobs/{job['id']}/trim", json={"start_seconds": 1.0, "end_seconds": 3.0})
|
||||
assert not any(k.startswith("_") for k in res.json())
|
||||
assert not any(k.startswith("_") for k in client.get(f"/api/jobs/{job['id']}").json())
|
||||
|
||||
|
||||
# --- loading a real server's db (--data-dir) -----------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def snapshot_db(tmp_path):
|
||||
"""A real server's db on disk; mock state the loader replaces is restored afterwards."""
|
||||
from fastvideo_studio.database import Database
|
||||
|
||||
saved = (dict(mock_server._settings), dict(mock_server._datasets), dict(mock_server._dataset_files))
|
||||
db = Database(tmp_path / "fastvideo_ui.db")
|
||||
yield tmp_path, db
|
||||
mock_server._settings.clear()
|
||||
mock_server._settings.update(saved[0])
|
||||
mock_server._datasets.clear()
|
||||
mock_server._datasets.update(saved[1])
|
||||
mock_server._dataset_files.clear()
|
||||
mock_server._dataset_files.update(saved[2])
|
||||
|
||||
|
||||
def _insert(db, job_id):
|
||||
db.insert_job({"id": job_id, "model_id": "m", "prompt": "p", "status": "completed", "created_at": 1.0,
|
||||
"output_path": f"/nope/{job_id}.mp4"})
|
||||
|
||||
|
||||
def test_snapshot_edit_segments_are_served_as_a_list_not_json_text(client, snapshot_db):
|
||||
# The db stores edit_segments as JSON text; serving that raw string made the
|
||||
# Edit video dialog call .map on a string and crash.
|
||||
data_dir, db = snapshot_db
|
||||
_insert(db, "edited")
|
||||
sections = [seg(brightness=20.0, end_seconds=2.0), seg(contrast=1.5)]
|
||||
db.update_job_config("edited", {"edit_segments": sections})
|
||||
assert mock_server._load_snapshot_from_db(data_dir)
|
||||
|
||||
assert client.get("/api/jobs/edited").json()["edit_segments"] == sections
|
||||
|
||||
|
||||
def test_snapshot_job_never_edited_has_an_empty_segment_list(client, snapshot_db):
|
||||
data_dir, db = snapshot_db
|
||||
_insert(db, "fresh")
|
||||
assert mock_server._load_snapshot_from_db(data_dir)
|
||||
|
||||
assert client.get("/api/jobs/fresh").json()["edit_segments"] == []
|
||||
|
||||
Reference in New Issue
Block a user