From b7d4b37efd1742a37a02edaef1bf97b2839086ed Mon Sep 17 00:00:00 2001 From: kevin314 Date: Wed, 14 Oct 2026 01:05:13 +0000 Subject: [PATCH] Add jobs timers --- apps/fastvideo_studio/mock_server.py | 7 +-- apps/fastvideo_studio/next.config.ts | 11 +++++ .../src/components/jobs/JobCard.tsx | 37 +--------------- .../src/components/scenes/SceneBoard.tsx | 16 ++++++- .../src/components/scenes/TrimClipDialog.tsx | 2 +- .../src/hooks/useJobElapsed.ts | 19 ++++++++ .../src/lib/jobTiming.test.ts | 34 ++++++++++++++ apps/fastvideo_studio/src/lib/jobTiming.ts | 32 ++++++++++++++ apps/fastvideo_studio/src/lib/scenes.test.ts | 25 +++++++++++ apps/fastvideo_studio/src/lib/scenes.ts | 16 ++++++- .../tests/test_mock_server_jobs.py | 44 +++++++++++++++++++ 11 files changed, 201 insertions(+), 42 deletions(-) create mode 100644 apps/fastvideo_studio/src/hooks/useJobElapsed.ts create mode 100644 apps/fastvideo_studio/src/lib/jobTiming.test.ts create mode 100644 apps/fastvideo_studio/src/lib/jobTiming.ts diff --git a/apps/fastvideo_studio/mock_server.py b/apps/fastvideo_studio/mock_server.py index 657e90f35..98c309adf 100644 --- a/apps/fastvideo_studio/mock_server.py +++ b/apps/fastvideo_studio/mock_server.py @@ -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 diff --git a/apps/fastvideo_studio/next.config.ts b/apps/fastvideo_studio/next.config.ts index a8552d49d..6e0e93a35 100644 --- a/apps/fastvideo_studio/next.config.ts +++ b/apps/fastvideo_studio/next.config.ts @@ -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; diff --git a/apps/fastvideo_studio/src/components/jobs/JobCard.tsx b/apps/fastvideo_studio/src/components/jobs/JobCard.tsx index db74194ec..54379029e 100644 --- a/apps/fastvideo_studio/src/components/jobs/JobCard.tsx +++ b/apps/fastvideo_studio/src/components/jobs/JobCard.tsx @@ -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 = { 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(); diff --git a/apps/fastvideo_studio/src/components/scenes/SceneBoard.tsx b/apps/fastvideo_studio/src/components/scenes/SceneBoard.tsx index 1dbf216fa..8caf5cd56 100644 --- a/apps/fastvideo_studio/src/components/scenes/SceneBoard.tsx +++ b/apps/fastvideo_studio/src/components/scenes/SceneBoard.tsx @@ -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({

{clipLabel(clip)}

{job.status} + {elapsed && ( + + + {elapsed} + + )} {formatClock(clip.startSeconds)}–{formatClock(clip.startSeconds + clip.durationSeconds)} diff --git a/apps/fastvideo_studio/src/components/scenes/TrimClipDialog.tsx b/apps/fastvideo_studio/src/components/scenes/TrimClipDialog.tsx index 9d90d6b79..3db4ee51d 100644 --- a/apps/fastvideo_studio/src/components/scenes/TrimClipDialog.tsx +++ b/apps/fastvideo_studio/src/components/scenes/TrimClipDialog.tsx @@ -53,7 +53,7 @@ const NEUTRAL_SECTION: Omit = { 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() }]; diff --git a/apps/fastvideo_studio/src/hooks/useJobElapsed.ts b/apps/fastvideo_studio/src/hooks/useJobElapsed.ts new file mode 100644 index 000000000..d763ebc11 --- /dev/null +++ b/apps/fastvideo_studio/src/hooks/useJobElapsed.ts @@ -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); +} diff --git a/apps/fastvideo_studio/src/lib/jobTiming.test.ts b/apps/fastvideo_studio/src/lib/jobTiming.test.ts new file mode 100644 index 000000000..ccb583631 --- /dev/null +++ b/apps/fastvideo_studio/src/lib/jobTiming.test.ts @@ -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 { + 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"); + }); +}); diff --git a/apps/fastvideo_studio/src/lib/jobTiming.ts b/apps/fastvideo_studio/src/lib/jobTiming.ts new file mode 100644 index 000000000..f55794b3e --- /dev/null +++ b/apps/fastvideo_studio/src/lib/jobTiming.ts @@ -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); +} diff --git a/apps/fastvideo_studio/src/lib/scenes.test.ts b/apps/fastvideo_studio/src/lib/scenes.test.ts index 0b1f41559..0fbd0c98c 100644 --- a/apps/fastvideo_studio/src/lib/scenes.test.ts +++ b/apps/fastvideo_studio/src/lib/scenes.test.ts @@ -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 }), diff --git a/apps/fastvideo_studio/src/lib/scenes.ts b/apps/fastvideo_studio/src/lib/scenes.ts index eb0eb62f1..73af2edb2 100644 --- a/apps/fastvideo_studio/src/lib/scenes.ts +++ b/apps/fastvideo_studio/src/lib/scenes.ts @@ -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]; } /** diff --git a/apps/fastvideo_studio/tests/test_mock_server_jobs.py b/apps/fastvideo_studio/tests/test_mock_server_jobs.py index 36473e637..5737bb34b 100644 --- a/apps/fastvideo_studio/tests/test_mock_server_jobs.py +++ b/apps/fastvideo_studio/tests/test_mock_server_jobs.py @@ -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"] == []