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[];
+124 -3
View File
@@ -6,6 +6,7 @@ API never returns plaintext)."""
from __future__ import annotations
import asyncio
import re
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
from uuid import UUID
@@ -16,6 +17,7 @@ from fastapi.responses import FileResponse, StreamingResponse
from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
from roboco.api.schemas.project_fields import task_project_fields
from roboco.api.schemas.video import (
PreviewFrameResponse,
TikTokCredentialsSetRequest,
TikTokCredentialsStatus,
VideoPipelineItemResponse,
@@ -24,6 +26,7 @@ from roboco.api.schemas.video import (
VideoPostHistoryResponse,
VideoPostRejectRequest,
VideoPostResponse,
VideoPreviewFramesResponse,
VideoRequestBody,
VideoRequestResponse,
)
@@ -50,7 +53,7 @@ from roboco.services.x_video_client import build_x_video_poster
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.db.tables import TaskTable
from roboco.db.tables import ProjectTable, TaskTable
from roboco.services.video_post_service import VideoPostService
router = APIRouter()
@@ -244,8 +247,8 @@ def _resolve_preview_path(root: Path, file_path: str) -> Path | None:
anything that escapes it. A leading ``/`` is stripped before joining
pathlib's ``/`` operator otherwise lets an absolute right operand
discard ``root`` entirely then the joined path must resolve to an
existing file still under ``root``. The sole confinement check for the
CEO preview proxy."""
existing file still under ``root``. The confinement check shared by the
CEO composition-HTML proxy and the preview-frame streamer below."""
candidate = (root / file_path.lstrip("/")).resolve()
if not candidate.is_relative_to(root) or not candidate.is_file():
return None
@@ -296,6 +299,124 @@ async def get_video_preview(
)
# request_render's self-describing filename (video-renderer/render.js):
# frame-<idx>-of-<n>-at-<t>s.png — no manifest needed to recover order/timestamp.
_FRAME_NAME_RE = re.compile(r"^frame-(\d+)-of-\d+-at-([\d.]+)s\.png$")
def _previews_root(project_slug: str, task_id: UUID) -> Path:
"""The container-shared dir request_render extracts frames to — same
path every agent container mounts (content_actions._render_extract_frames),
so this resolves identically regardless of who rendered."""
return Path(settings.workspaces_root) / project_slug / ".previews" / task_id.hex[:8]
def _list_orientation_frames(dir_path: Path) -> list[PreviewFrameResponse]:
"""Sorted, filename-parsed frames for one orientation dir. Empty when
that orientation was never rendered (dir missing) the directory
listing is authoritative for both orientations at once; the
render_preview marker only ever reflects the last request_render call's
single orientation."""
if not dir_path.is_dir():
return []
frames = []
for p in dir_path.iterdir():
m = _FRAME_NAME_RE.match(p.name)
if m:
frames.append(
PreviewFrameResponse(
index=int(m.group(1)),
file=p.name,
timestamp_seconds=float(m.group(2)),
)
)
return sorted(frames, key=lambda f: f.index)
async def _resolve_video_task_project(
task_id: UUID, db: AsyncSession
) -> tuple[TaskTable, ProjectTable]:
"""Task + project resolution shared by the two preview-frame routes below
mirrors get_video_preview's inline checks (source=video, has a
project, project has a slug)."""
task = await get_task_service(db).get(task_id)
if task is None or task.source != VIDEO_SOURCE or task.project_id is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="No such video task"
)
project = await get_project_service(db).get(cast("UUID", task.project_id))
if project is None or not project.slug:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
)
return task, project
@router.get("/preview-frames/{task_id}", response_model=VideoPreviewFramesResponse)
async def get_video_preview_frames(
task_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
) -> VideoPreviewFramesResponse:
"""A video-authoring task's request_render preview — every extracted
frame per orientation, ordered, with its in-video timestamp. The only
thing the CEO has to look at before the post-completion render loop
produces the real MP4 an awaiting_ceo_approval task otherwise has
nothing to preview. 404s when there's no such video task, or nothing
was ever rendered.
"""
_require_ceo(agent)
task, project = await _resolve_video_task_project(task_id, db)
root = _previews_root(project.slug, task_id)
frames = {cut: _list_orientation_frames(root / cut) for cut in _VALID_CUTS}
if not any(frames.values()):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No render preview frames for this task",
)
preview = markers.get_render_preview(task) or {}
return VideoPreviewFramesResponse(
task_id=str(task.id),
composition_id=preview.get("composition_id"),
duration_seconds=preview.get("duration_seconds"),
head_sha=preview.get("head_sha"),
dirty=preview.get("dirty"),
rendered_at=preview.get("at"),
frames=frames,
)
@router.get("/preview-frames/{task_id}/{orientation}/{filename}", response_model=None)
async def get_video_preview_frame(
task_id: UUID,
orientation: str,
filename: str,
db: DbSession,
agent: CurrentAgentContext,
) -> FileResponse:
"""Stream one extracted preview-frame PNG. Confinement mirrors
``_resolve_preview_path`` (get_video_preview's composition-HTML proxy) —
same root-relative resolve + ``..``/escape rejection + is_file check,
scoped to this task's ``.previews/`` dir instead of its read-clone.
400 on an orientation outside {vertical, square}; 404 on a missing
task/frame.
"""
_require_ceo(agent)
if orientation not in _VALID_CUTS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"orientation must be one of {_VALID_CUTS!r}",
)
_task, project = await _resolve_video_task_project(task_id, db)
root = _previews_root(project.slug, task_id)
resolved = _resolve_preview_path(root, f"{orientation}/{filename}")
if resolved is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="No such preview frame"
)
return FileResponse(resolved, media_type="image/png")
def _posted_ids(draft: dict[str, Any]) -> dict[str, str]:
"""Every ``{platform}_posted_id`` key stamped by approve, keyed by
platform (e.g. ``{"x": "..", "tiktok": ".."}``)."""
+27
View File
@@ -111,6 +111,33 @@ class VideoPipelineItemResponse(BaseModel):
project_name: str | None = None
class PreviewFrameResponse(BaseModel):
"""One extracted request_render preview frame — index/timestamp decoded
from the sidecar's self-describing filename
(``frame-<idx>-of-<n>-at-<t>s.png``, video-renderer/render.js)."""
index: int
file: str
timestamp_seconds: float
class VideoPreviewFramesResponse(BaseModel):
"""A video-authoring task's request_render preview frames, keyed by
orientation the CEO's only look at the rendered artifact before the
post-completion render loop produces the real MP4 (awaiting_ceo_approval
has nothing else to show). composition_id/duration/head_sha/dirty/
rendered_at come from the render_preview marker; an orientation absent
or empty from ``frames`` was never rendered."""
task_id: str
composition_id: str | None = None
duration_seconds: float | None = None
head_sha: str | None = None
dirty: bool | None = None
rendered_at: str | None = None
frames: dict[str, list[PreviewFrameResponse]] = Field(default_factory=dict)
class TikTokCredentialsStatus(BaseModel):
"""Whether the four OAuth2 secrets are stored. Never the secrets themselves."""
+191 -1
View File
@@ -6,7 +6,7 @@ from __future__ import annotations
from http import HTTPStatus
from types import SimpleNamespace
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, cast
from unittest.mock import AsyncMock, patch
from uuid import UUID, uuid4
@@ -46,6 +46,7 @@ UX_DEV_1_UUID = _foundation.AGENTS["ux-dev-1"].uuid
UX_DEV_2_UUID = _foundation.AGENTS["ux-dev-2"].uuid
HISTORY_LIMIT = 2
RETRY_ATTEMPTS = 2
PREVIEW_DURATION_SECONDS = 6.0
async def _seed(session: AsyncSession) -> None:
@@ -1284,3 +1285,192 @@ async def test_preview_non_ceo_is_forbidden(db_session: AsyncSession) -> None:
resp = await client.get(f"/api/video/preview/{task.id}/vertical.html")
assert resp.status_code == HTTPStatus.FORBIDDEN
app.dependency_overrides.clear()
# --- preview frames (CEO-facing request_render surface) -----------------------
def _write_preview_frame(
root: Path, orientation: str, idx: int, count: int, timestamp: float
) -> Path:
"""One request_render-shaped frame file — filename encodes index/count/
timestamp exactly as video-renderer/render.js writes it."""
d = root / orientation
d.mkdir(parents=True, exist_ok=True)
path = d / f"frame-{idx:02d}-of-{count}-at-{timestamp:.1f}s.png"
path.write_bytes(b"fake-png-bytes")
return path
def _previews_dir(workspaces_root: Path, project_slug: str, task_id: UUID) -> Path:
return workspaces_root / project_slug / ".previews" / task_id.hex[:8]
@pytest.mark.asyncio
async def test_preview_frames_lists_both_orientations_with_marker_metadata(
db_session: AsyncSession,
ceo_client: AsyncClient,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "workspaces_root", str(tmp_path))
task = await _seed_authoring_task(
db_session,
status=TaskStatus.IN_PROGRESS,
draft_extra={"composition_id": "Intro"},
)
project = await db_session.get(ProjectTable, task.project_id)
assert project is not None
root = _previews_dir(tmp_path, project.slug, cast("UUID", task.id))
_write_preview_frame(root, "vertical", 1, 2, 1.5)
_write_preview_frame(root, "vertical", 2, 2, 4.5)
_write_preview_frame(root, "square", 1, 1, 3.0)
markers.set_render_preview(
task,
{
"at": "2026-07-20T00:00:00+00:00",
"composition_id": "Intro",
"orientation": "square",
"frame_count": 1,
"duration_seconds": PREVIEW_DURATION_SECONDS,
"frames": [],
"head_sha": "abc123",
"dirty": False,
},
)
await db_session.flush()
resp = await ceo_client.get(f"/api/video/preview-frames/{task.id}")
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert body["composition_id"] == "Intro"
assert body["duration_seconds"] == PREVIEW_DURATION_SECONDS
assert body["head_sha"] == "abc123"
assert body["dirty"] is False
assert body["rendered_at"] == "2026-07-20T00:00:00+00:00"
vertical = body["frames"]["vertical"]
assert [f["index"] for f in vertical] == [1, 2]
assert [f["timestamp_seconds"] for f in vertical] == [1.5, 4.5]
assert body["frames"]["square"][0]["file"].startswith("frame-01-of-1-at-3.0s")
@pytest.mark.asyncio
async def test_preview_frames_no_render_yet_is_404(
db_session: AsyncSession,
ceo_client: AsyncClient,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A source=video task with no request_render call yet has nothing under
.previews/ 404, not an empty 200 the panel would render as a blank
section."""
monkeypatch.setattr(cfg, "workspaces_root", str(tmp_path))
task = await _seed_authoring_task(db_session, status=TaskStatus.IN_PROGRESS)
resp = await ceo_client.get(f"/api/video/preview-frames/{task.id}")
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_preview_frames_missing_task_is_404(ceo_client: AsyncClient) -> None:
resp = await ceo_client.get(f"/api/video/preview-frames/{uuid4()}")
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_preview_frames_non_video_task_is_404(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_draft(db_session) # source=video_post, not video
resp = await ceo_client.get(f"/api/video/preview-frames/{task.id}")
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_preview_frames_non_ceo_is_forbidden(db_session: AsyncSession) -> None:
task = await _seed_authoring_task(db_session, status=TaskStatus.IN_PROGRESS)
app = _build_app(db_session, AgentRole.DEVELOPER, uuid4())
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get(f"/api/video/preview-frames/{task.id}")
assert resp.status_code == HTTPStatus.FORBIDDEN
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_preview_frame_streams_png_bytes(
db_session: AsyncSession,
ceo_client: AsyncClient,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "workspaces_root", str(tmp_path))
task = await _seed_authoring_task(db_session, status=TaskStatus.IN_PROGRESS)
project = await db_session.get(ProjectTable, task.project_id)
assert project is not None
root = _previews_dir(tmp_path, project.slug, cast("UUID", task.id))
frame_path = _write_preview_frame(root, "vertical", 1, 1, 0.5)
resp = await ceo_client.get(
f"/api/video/preview-frames/{task.id}/vertical/{frame_path.name}"
)
assert resp.status_code == HTTPStatus.OK
assert resp.headers["content-type"] == "image/png"
assert resp.content == b"fake-png-bytes"
@pytest.mark.asyncio
async def test_preview_frame_bad_orientation_is_400(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_authoring_task(db_session, status=TaskStatus.IN_PROGRESS)
resp = await ceo_client.get(
f"/api/video/preview-frames/{task.id}/diagonal/frame-01-of-1-at-0.5s.png"
)
assert resp.status_code == HTTPStatus.BAD_REQUEST
@pytest.mark.asyncio
async def test_preview_frame_missing_file_is_404(
db_session: AsyncSession,
ceo_client: AsyncClient,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "workspaces_root", str(tmp_path))
task = await _seed_authoring_task(db_session, status=TaskStatus.IN_PROGRESS)
resp = await ceo_client.get(
f"/api/video/preview-frames/{task.id}/vertical/frame-01-of-1-at-0.5s.png"
)
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_preview_frame_non_ceo_is_forbidden(db_session: AsyncSession) -> None:
task = await _seed_authoring_task(db_session, status=TaskStatus.IN_PROGRESS)
app = _build_app(db_session, AgentRole.DEVELOPER, uuid4())
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get(
f"/api/video/preview-frames/{task.id}/vertical/frame-01-of-1-at-0.5s.png"
)
assert resp.status_code == HTTPStatus.FORBIDDEN
app.dependency_overrides.clear()
def test_resolve_preview_path_confines_frame_orientation_traversal(
tmp_path: Path,
) -> None:
"""The preview-frame route composes ``f"{orientation}/{filename}"`` before
calling ``_resolve_preview_path`` same confinement the composition-HTML
proxy uses, applied to a task's .previews/ dir instead of its read-clone."""
root = (tmp_path / "roboco-x" / ".previews" / "abcd1234").resolve()
(root / "vertical").mkdir(parents=True)
frame = root / "vertical" / "frame-01-of-1-at-0.5s.png"
frame.write_bytes(b"png")
secret = tmp_path / "secret.png"
secret.write_bytes(b"nope")
assert (
video_module._resolve_preview_path(root, "vertical/frame-01-of-1-at-0.5s.png")
== frame.resolve()
)
assert video_module._resolve_preview_path(root, "vertical/../../secret.png") is None
assert video_module._resolve_preview_path(root, "../secret.png") is None