feat(video): CEO can preview a video authoring task's frames before approving (#608)

A source=video authoring task reaches awaiting_ceo_approval with no MP4
yet — rendering only happens after it completes — so the CEO had nothing
to review. Two CEO-gated routes now serve the request_render preview
frames: GET /video/preview-frames/{task_id} lists them per orientation
(parsed from the self-describing .previews/{task8}/{orientation}/ filenames
rather than the render_preview marker, which only holds the last call's
single orientation), and .../{orientation}/{filename} streams a frame's
PNG behind the existing path-confinement guard. The task-detail Overview
gains a Video preview card — a 9:16/1:1 toggle + prev/next/scrubber frame
stepper with composition id, duration, and a dirty badge — shown for a
video task with preview frames or awaiting CEO approval.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-20 10:51:43 +02:00
committed by GitHub
co-authored by Renn F
parent 57b9e76b12
commit a5d8c6bd5b
9 changed files with 790 additions and 11 deletions
@@ -0,0 +1,162 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { TaskStatus, Team, TaskType, type Task } from "@/types";
import type { VideoPreviewFrames } from "@/lib/api/video";
const { getPreviewFrames, getPreviewFrameBlob } = vi.hoisted(() => ({
getPreviewFrames: vi.fn<() => Promise<VideoPreviewFrames>>(),
getPreviewFrameBlob: vi.fn(
async () => new Blob(["fake-png-bytes"], { type: "image/png" }),
),
}));
vi.mock("@/lib/api", () => ({
videoApi: { getPreviewFrames, getPreviewFrameBlob },
}));
import { VideoPreviewCard } from "../video-preview-card";
function withQueryClient(ui: ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
}
function buildTask(overrides: Partial<Task> = {}): Task {
return {
id: "task-1",
title: "Video: launch teaser",
description: "d",
status: TaskStatus.AWAITING_CEO_APPROVAL,
team: Team.UX_UI,
task_type: TaskType.CODE,
acceptance_criteria: [],
source: "video",
...overrides,
} as unknown as Task;
}
const FRAMES: VideoPreviewFrames = {
task_id: "task-1",
composition_id: "Intro",
duration_seconds: 6.4,
head_sha: "abc1234",
dirty: false,
rendered_at: "2026-07-19T12:00:00Z",
frames: {
vertical: [
{ index: 1, file: "frame-01-of-2-at-1.5s.png", timestamp_seconds: 1.5 },
{ index: 2, file: "frame-02-of-2-at-4.5s.png", timestamp_seconds: 4.5 },
],
square: [
{ index: 1, file: "frame-01-of-1-at-3.0s.png", timestamp_seconds: 3.0 },
],
},
};
describe("VideoPreviewCard", () => {
beforeEach(() => {
getPreviewFrames.mockReset();
getPreviewFrameBlob.mockClear();
let objectUrlCount = 0;
globalThis.URL.createObjectURL = vi.fn(
() => `blob:mock-url-${++objectUrlCount}`,
);
globalThis.URL.revokeObjectURL = vi.fn();
});
afterEach(() => {
vi.clearAllMocks();
});
it("renders composition metadata and blob-fetches the first frame of the default cut", async () => {
getPreviewFrames.mockResolvedValueOnce(FRAMES);
render(withQueryClient(<VideoPreviewCard task={buildTask()} />));
expect(await screen.findByText("Intro")).toBeInTheDocument();
expect(screen.getByText("6.4s clip")).toBeInTheDocument();
await waitFor(() =>
expect(getPreviewFrameBlob).toHaveBeenCalledWith(
"task-1",
"vertical",
"frame-01-of-2-at-1.5s.png",
),
);
expect(screen.getByText(/Frame 1\/2/)).toBeInTheDocument();
expect(screen.getByText(/1\.5s into the clip/)).toBeInTheDocument();
});
it("steps to the next frame within a cut", async () => {
getPreviewFrames.mockResolvedValueOnce(FRAMES);
render(withQueryClient(<VideoPreviewCard task={buildTask()} />));
await screen.findByText("Intro");
await waitFor(() => expect(getPreviewFrameBlob).toHaveBeenCalledTimes(1));
expect(screen.getByLabelText("Previous frame")).toBeDisabled();
fireEvent.click(screen.getByLabelText("Next frame"));
await waitFor(() =>
expect(getPreviewFrameBlob).toHaveBeenCalledWith(
"task-1",
"vertical",
"frame-02-of-2-at-4.5s.png",
),
);
expect(screen.getByText(/Frame 2\/2/)).toBeInTheDocument();
expect(screen.getByText(/4\.5s into the clip/)).toBeInTheDocument();
expect(screen.getByLabelText("Next frame")).toBeDisabled();
});
it("switches cuts via the 9:16/1:1 toggle and re-fetches that orientation's first frame", async () => {
getPreviewFrames.mockResolvedValueOnce(FRAMES);
render(withQueryClient(<VideoPreviewCard task={buildTask()} />));
await screen.findByText("Intro");
await waitFor(() =>
expect(getPreviewFrameBlob).toHaveBeenCalledWith(
"task-1",
"vertical",
"frame-01-of-2-at-1.5s.png",
),
);
fireEvent.click(screen.getByRole("button", { name: "1:1" }));
await waitFor(() =>
expect(getPreviewFrameBlob).toHaveBeenCalledWith(
"task-1",
"square",
"frame-01-of-1-at-3.0s.png",
),
);
expect(screen.getByText(/Frame 1\/1/)).toBeInTheDocument();
});
it("flags an orientation with no rendered frames as missing and disables its toggle", async () => {
getPreviewFrames.mockResolvedValueOnce({
...FRAMES,
frames: { vertical: FRAMES.frames.vertical },
});
render(withQueryClient(<VideoPreviewCard task={buildTask()} />));
await screen.findByText("Intro");
const squareButton = screen.getByRole("button", { name: /1:1/ });
expect(squareButton).toHaveTextContent("(missing)");
expect(squareButton).toBeDisabled();
});
it("shows an uncommitted-changes badge when the render was dirty", async () => {
getPreviewFrames.mockResolvedValueOnce({ ...FRAMES, dirty: true });
render(withQueryClient(<VideoPreviewCard task={buildTask()} />));
expect(await screen.findByText("uncommitted changes")).toBeInTheDocument();
});
it("shows a muted empty state when nothing has been rendered yet (404)", async () => {
getPreviewFrames.mockRejectedValueOnce(new Error("not found"));
render(withQueryClient(<VideoPreviewCard task={buildTask()} />));
expect(
await screen.findByText(/No render preview yet/),
).toBeInTheDocument();
expect(getPreviewFrameBlob).not.toHaveBeenCalled();
});
});
@@ -1,10 +1,11 @@
"use client";
import { Task } from "@/types";
import { Task, TaskStatus } from "@/types";
import { TaskDescription } from "./task-description";
import { AcceptanceCriteria } from "./acceptance-criteria";
import { SubtasksList } from "./subtasks-list";
import { WorkSessionCard } from "./work-session-card";
import { VideoPreviewCard } from "./video-preview-card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Markdown } from "@/components/ui/markdown";
@@ -50,6 +51,15 @@ export function TabOverview({ task }: TabOverviewProps) {
{/* Work Session / Git Info */}
<WorkSessionCard taskId={task.id} />
{/* Video preview (source=video authoring tasks with a captured
render_preview, or already awaiting the CEO's approval — the state
with nothing else to show) */}
{task.source === "video" &&
(!!task.orchestration_markers?.render_preview ||
task.status === TaskStatus.AWAITING_CEO_APPROVAL) && (
<VideoPreviewCard task={task} />
)}
{/* Quick Context (for resumption) */}
{task.quick_context && (
<Card>
@@ -0,0 +1,213 @@
"use client";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { videoApi } from "@/lib/api";
import type { PreviewFrame, VideoCut } from "@/lib/api/video";
import { Task } from "@/types";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { HelpTip } from "@/components/ui/help-tip";
import { formatAbsoluteTimestamp } from "@/lib/utils";
import { ChevronLeft, ChevronRight, Clapperboard } from "lucide-react";
const CUT_LABELS: Record<VideoCut, string> = {
vertical: "9:16",
square: "1:1",
};
// One orientation's frame strip: blob-fetches the current frame (same
// auth-header workaround as the MP4 CutPlayer) and steps through the rest
// with a native range input — no scrubber library needed for N still frames.
// The caller remounts this via `key={cut}` on cut change, so `index`/`src`
// reset for free — no effect needed to clamp a stale index back to 0.
function FrameStepper({
taskId,
cut,
frames,
}: {
taskId: string;
cut: VideoCut;
frames: PreviewFrame[];
}) {
const [index, setIndex] = useState(0);
const [src, setSrc] = useState<string | null>(null);
const frame: PreviewFrame | undefined = frames[index];
useEffect(() => {
if (!frame) return; // src stays null (its initial value) — nothing to fetch
let cancelled = false;
let objectUrl: string | null = null;
videoApi
.getPreviewFrameBlob(taskId, cut, frame.file)
.then((blob) => {
if (cancelled) return;
objectUrl = URL.createObjectURL(blob);
setSrc(objectUrl);
})
.catch(() => {
if (!cancelled) setSrc(null);
});
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [taskId, cut, frame]);
if (frames.length === 0) {
return (
<div className="flex h-48 items-center justify-center rounded-md border border-dashed text-sm text-muted-foreground">
This cut hasn&apos;t rendered a preview yet
</div>
);
}
return (
<div className="space-y-2">
{src ? (
<img
src={src}
alt={`Frame ${frame?.index} of ${cut} cut, at ${frame?.timestamp_seconds}s`}
className="mx-auto max-h-96 w-full rounded-md border bg-black object-contain"
/>
) : (
<Skeleton className="mx-auto h-96 w-full rounded-md" />
)}
<div className="flex items-center gap-2">
<Button
type="button"
size="icon"
variant="outline"
disabled={index === 0}
onClick={() => setIndex((i) => Math.max(0, i - 1))}
aria-label="Previous frame"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<input
type="range"
aria-label="Frame scrubber"
min={0}
max={frames.length - 1}
value={index}
onChange={(e) => setIndex(Number(e.target.value))}
className="w-full"
/>
<Button
type="button"
size="icon"
variant="outline"
disabled={index === frames.length - 1}
onClick={() => setIndex((i) => Math.min(frames.length - 1, i + 1))}
aria-label="Next frame"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<p className="text-center text-xs text-muted-foreground">
Frame {index + 1}/{frames.length} {" "}
{frame?.timestamp_seconds.toFixed(1)}s into the clip
</p>
</div>
);
}
// The CEO's only look at a video-authoring task's rendered artifact before
// the post-completion render loop produces the real MP4 — awaiting_ceo_
// approval otherwise has nothing to watch. Assumes the caller already gated
// on task.source === "video"; a task that never called request_render (or
// whose frames 404 for any other reason) renders a muted empty state rather
// than nothing, since a CEO reviewing a video task with no preview at all is
// itself worth surfacing.
export function VideoPreviewCard({ task }: { task: Task }) {
const { data, isLoading, isError } = useQuery({
queryKey: ["video", "preview-frames", task.id],
queryFn: () => videoApi.getPreviewFrames(task.id),
enabled: !!task.id,
retry: false, // a 404 (nothing rendered yet) is an expected outcome, not a transient failure
});
const [selectedCut, setSelectedCut] = useState<VideoCut | null>(null);
const availableCuts = (Object.keys(data?.frames ?? {}) as VideoCut[]).filter(
(c) => (data?.frames[c]?.length ?? 0) > 0,
);
const cut =
selectedCut && availableCuts.includes(selectedCut)
? selectedCut
: availableCuts[0];
return (
<Card>
<CardHeader>
<HelpTip label="Preview frames from request_render — extracted before the real MP4 renders, so there's something to look at while this task awaits your approval">
<CardTitle className="flex w-fit items-center gap-2 text-lg">
<Clapperboard className="h-5 w-5" />
Video preview
</CardTitle>
</HelpTip>
{data && (
<CardDescription className="flex flex-wrap items-center gap-2">
{data.composition_id && (
<code className="text-xs">{data.composition_id}</code>
)}
{data.duration_seconds != null && (
<span>{data.duration_seconds.toFixed(1)}s clip</span>
)}
{data.rendered_at && (
<span>rendered {formatAbsoluteTimestamp(data.rendered_at)}</span>
)}
{data.dirty && (
<HelpTip label="The working tree had uncommitted changes when this was rendered — it may not exactly match what's pushed">
<Badge variant="outline" className="text-amber-700">
uncommitted changes
</Badge>
</HelpTip>
)}
</CardDescription>
)}
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-96 w-full rounded-md" />
) : isError || !data || !cut ? (
<p className="text-sm text-muted-foreground">
No render preview yet the developer hasn&apos;t called
request_render on this task.
</p>
) : (
<div className="space-y-3">
<div className="flex gap-2">
{(Object.keys(CUT_LABELS) as VideoCut[]).map((c) => (
<Button
key={c}
type="button"
size="sm"
variant={cut === c ? "default" : "outline"}
disabled={!availableCuts.includes(c)}
onClick={() => setSelectedCut(c)}
>
{CUT_LABELS[c]}
{!availableCuts.includes(c) && " (missing)"}
</Button>
))}
</div>
<FrameStepper
key={cut}
taskId={task.id}
cut={cut}
frames={data.frames[cut] ?? []}
/>
</div>
)}
</CardContent>
</Card>
);
}
+2
View File
@@ -48,4 +48,6 @@ export type {
VideoPostHistoryEntry,
VideoRequestResult,
TikTokCredentialsStatus,
PreviewFrame,
VideoPreviewFrames,
} from "./video";
+56
View File
@@ -87,6 +87,29 @@ export interface TikTokCredentialsStatus {
has_credentials: boolean;
}
// One extracted request_render preview frame — GET /video/preview-frames/
// {task_id} (roboco/api/routes/video.py). index/timestamp decoded server-side
// from the sidecar's self-describing filename.
export interface PreviewFrame {
index: number;
file: string;
timestamp_seconds: number;
}
// A video-authoring task's request_render preview — the CEO's only look at
// the rendered artifact before the post-completion render loop produces the
// real MP4 (an awaiting_ceo_approval task otherwise has nothing to watch).
// frames keyed by orientation; an absent/empty key was never rendered.
export interface VideoPreviewFrames {
task_id: string;
composition_id: string | null;
duration_seconds: number | null;
head_sha: string | null;
dirty: boolean | null;
rendered_at: string | null;
frames: Partial<Record<VideoCut, PreviewFrame[]>>;
}
// GET /video/posts/{id}/media (roboco/api/routes/video.py) serves one
// rendered MP4 cut; VideoPost.mp4_paths (above) carries the server-side
// paths per cut. This builds that route's URL — but a native
@@ -112,6 +135,18 @@ export function compositionPreviewUrl(
return `${API_URL}/video/preview/${authoringTaskId}/${filePath}`;
}
// GET /video/preview-frames/{task_id}/{orientation}/{filename} streams one
// frame's PNG bytes. Same auth-header problem as the MP4 media route (a
// plain <img src> GET carries none of axios's headers), so the panel fetches
// via videoApi.getPreviewFrameBlob instead — kept for direct-link use.
export function previewFrameUrl(
taskId: string,
cut: VideoCut,
file: string,
): string {
return `${API_URL}/video/preview-frames/${taskId}/${cut}/${encodeURIComponent(file)}`;
}
export const videoApi = {
listPosts: async (): Promise<VideoPost[]> => {
const { data } = await api.get<VideoPost[]>("/video/posts");
@@ -174,6 +209,27 @@ export const videoApi = {
rerender: async (authoringTaskId: string): Promise<void> => {
await api.post(`/video/pipeline/${authoringTaskId}/rerender`);
},
// The task's request_render preview frames, per orientation. Callers
// treat a 404 (nothing rendered yet) as "no preview" rather than an error.
getPreviewFrames: async (taskId: string): Promise<VideoPreviewFrames> => {
const { data } = await api.get<VideoPreviewFrames>(
`/video/preview-frames/${taskId}`,
);
return data;
},
// Fetches one frame's PNG bytes as a Blob (carrying the auth headers a
// plain <img src> GET can't) — mirrors getMediaBlob's object-URL pattern.
getPreviewFrameBlob: async (
taskId: string,
cut: VideoCut,
file: string,
): Promise<Blob> => {
const { data } = await api.get<Blob>(
`/video/preview-frames/${taskId}/${cut}/${encodeURIComponent(file)}`,
{ responseType: "blob" },
);
return data;
},
getCredentialsStatus: async (): Promise<TikTokCredentialsStatus> => {
const { data } = await api.get<TikTokCredentialsStatus>(
"/tiktok/credentials",
+4 -6
View File
@@ -234,6 +234,9 @@ export interface Task {
// MegaTask grouping: set on the umbrella (parent_task_id null) and every
// root-subtask of a batch. null on ordinary tasks.
batch_id?: string | null;
// Origin tag ("manual", "video", "video_post", "roadmap", ...) — gates the
// task-detail Video preview section for source=video authoring tasks.
source?: string;
created_at: string;
updated_at: string | null;
claimed_at: string | null;
@@ -878,12 +881,7 @@ export interface CodeReviewResponse {
export interface LearningRecordRequest {
content: string;
category:
| "error_handling"
| "performance"
| "testing"
| "pattern"
| "tool"
| "other";
"error_handling" | "performance" | "testing" | "pattern" | "tool" | "other";
team?: "backend" | "frontend" | "ux_ui";
shareable?: boolean;
tags?: string[];