mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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";
|
"use client";
|
||||||
|
|
||||||
import { Task } from "@/types";
|
import { Task, TaskStatus } from "@/types";
|
||||||
import { TaskDescription } from "./task-description";
|
import { TaskDescription } from "./task-description";
|
||||||
import { AcceptanceCriteria } from "./acceptance-criteria";
|
import { AcceptanceCriteria } from "./acceptance-criteria";
|
||||||
import { SubtasksList } from "./subtasks-list";
|
import { SubtasksList } from "./subtasks-list";
|
||||||
import { WorkSessionCard } from "./work-session-card";
|
import { WorkSessionCard } from "./work-session-card";
|
||||||
|
import { VideoPreviewCard } from "./video-preview-card";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Markdown } from "@/components/ui/markdown";
|
import { Markdown } from "@/components/ui/markdown";
|
||||||
@@ -50,6 +51,15 @@ export function TabOverview({ task }: TabOverviewProps) {
|
|||||||
{/* Work Session / Git Info */}
|
{/* Work Session / Git Info */}
|
||||||
<WorkSessionCard taskId={task.id} />
|
<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) */}
|
{/* Quick Context (for resumption) */}
|
||||||
{task.quick_context && (
|
{task.quick_context && (
|
||||||
<Card>
|
<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'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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -48,4 +48,6 @@ export type {
|
|||||||
VideoPostHistoryEntry,
|
VideoPostHistoryEntry,
|
||||||
VideoRequestResult,
|
VideoRequestResult,
|
||||||
TikTokCredentialsStatus,
|
TikTokCredentialsStatus,
|
||||||
|
PreviewFrame,
|
||||||
|
VideoPreviewFrames,
|
||||||
} from "./video";
|
} from "./video";
|
||||||
|
|||||||
@@ -87,6 +87,29 @@ export interface TikTokCredentialsStatus {
|
|||||||
has_credentials: boolean;
|
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
|
// GET /video/posts/{id}/media (roboco/api/routes/video.py) serves one
|
||||||
// rendered MP4 cut; VideoPost.mp4_paths (above) carries the server-side
|
// rendered MP4 cut; VideoPost.mp4_paths (above) carries the server-side
|
||||||
// paths per cut. This builds that route's URL — but a native
|
// 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}`;
|
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 = {
|
export const videoApi = {
|
||||||
listPosts: async (): Promise<VideoPost[]> => {
|
listPosts: async (): Promise<VideoPost[]> => {
|
||||||
const { data } = await api.get<VideoPost[]>("/video/posts");
|
const { data } = await api.get<VideoPost[]>("/video/posts");
|
||||||
@@ -174,6 +209,27 @@ export const videoApi = {
|
|||||||
rerender: async (authoringTaskId: string): Promise<void> => {
|
rerender: async (authoringTaskId: string): Promise<void> => {
|
||||||
await api.post(`/video/pipeline/${authoringTaskId}/rerender`);
|
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> => {
|
getCredentialsStatus: async (): Promise<TikTokCredentialsStatus> => {
|
||||||
const { data } = await api.get<TikTokCredentialsStatus>(
|
const { data } = await api.get<TikTokCredentialsStatus>(
|
||||||
"/tiktok/credentials",
|
"/tiktok/credentials",
|
||||||
|
|||||||
@@ -234,6 +234,9 @@ export interface Task {
|
|||||||
// MegaTask grouping: set on the umbrella (parent_task_id null) and every
|
// MegaTask grouping: set on the umbrella (parent_task_id null) and every
|
||||||
// root-subtask of a batch. null on ordinary tasks.
|
// root-subtask of a batch. null on ordinary tasks.
|
||||||
batch_id?: string | null;
|
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;
|
created_at: string;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
claimed_at: string | null;
|
claimed_at: string | null;
|
||||||
@@ -878,12 +881,7 @@ export interface CodeReviewResponse {
|
|||||||
export interface LearningRecordRequest {
|
export interface LearningRecordRequest {
|
||||||
content: string;
|
content: string;
|
||||||
category:
|
category:
|
||||||
| "error_handling"
|
"error_handling" | "performance" | "testing" | "pattern" | "tool" | "other";
|
||||||
| "performance"
|
|
||||||
| "testing"
|
|
||||||
| "pattern"
|
|
||||||
| "tool"
|
|
||||||
| "other";
|
|
||||||
team?: "backend" | "frontend" | "ux_ui";
|
team?: "backend" | "frontend" | "ux_ui";
|
||||||
shareable?: boolean;
|
shareable?: boolean;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
|||||||
+124
-3
@@ -6,6 +6,7 @@ API never returns plaintext)."""
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
from uuid import UUID
|
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.deps import CurrentAgentContext, DbSession, require_ceo_role
|
||||||
from roboco.api.schemas.project_fields import task_project_fields
|
from roboco.api.schemas.project_fields import task_project_fields
|
||||||
from roboco.api.schemas.video import (
|
from roboco.api.schemas.video import (
|
||||||
|
PreviewFrameResponse,
|
||||||
TikTokCredentialsSetRequest,
|
TikTokCredentialsSetRequest,
|
||||||
TikTokCredentialsStatus,
|
TikTokCredentialsStatus,
|
||||||
VideoPipelineItemResponse,
|
VideoPipelineItemResponse,
|
||||||
@@ -24,6 +26,7 @@ from roboco.api.schemas.video import (
|
|||||||
VideoPostHistoryResponse,
|
VideoPostHistoryResponse,
|
||||||
VideoPostRejectRequest,
|
VideoPostRejectRequest,
|
||||||
VideoPostResponse,
|
VideoPostResponse,
|
||||||
|
VideoPreviewFramesResponse,
|
||||||
VideoRequestBody,
|
VideoRequestBody,
|
||||||
VideoRequestResponse,
|
VideoRequestResponse,
|
||||||
)
|
)
|
||||||
@@ -50,7 +53,7 @@ from roboco.services.x_video_client import build_x_video_poster
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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
|
from roboco.services.video_post_service import VideoPostService
|
||||||
|
|
||||||
router = APIRouter()
|
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 —
|
anything that escapes it. A leading ``/`` is stripped before joining —
|
||||||
pathlib's ``/`` operator otherwise lets an absolute right operand
|
pathlib's ``/`` operator otherwise lets an absolute right operand
|
||||||
discard ``root`` entirely — then the joined path must resolve to an
|
discard ``root`` entirely — then the joined path must resolve to an
|
||||||
existing file still under ``root``. The sole confinement check for the
|
existing file still under ``root``. The confinement check shared by the
|
||||||
CEO preview proxy."""
|
CEO composition-HTML proxy and the preview-frame streamer below."""
|
||||||
candidate = (root / file_path.lstrip("/")).resolve()
|
candidate = (root / file_path.lstrip("/")).resolve()
|
||||||
if not candidate.is_relative_to(root) or not candidate.is_file():
|
if not candidate.is_relative_to(root) or not candidate.is_file():
|
||||||
return None
|
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]:
|
def _posted_ids(draft: dict[str, Any]) -> dict[str, str]:
|
||||||
"""Every ``{platform}_posted_id`` key stamped by approve, keyed by
|
"""Every ``{platform}_posted_id`` key stamped by approve, keyed by
|
||||||
platform (e.g. ``{"x": "..", "tiktok": ".."}``)."""
|
platform (e.g. ``{"x": "..", "tiktok": ".."}``)."""
|
||||||
|
|||||||
@@ -111,6 +111,33 @@ class VideoPipelineItemResponse(BaseModel):
|
|||||||
project_name: str | None = None
|
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):
|
class TikTokCredentialsStatus(BaseModel):
|
||||||
"""Whether the four OAuth2 secrets are stored. Never the secrets themselves."""
|
"""Whether the four OAuth2 secrets are stored. Never the secrets themselves."""
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, cast
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
from uuid import UUID, uuid4
|
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
|
UX_DEV_2_UUID = _foundation.AGENTS["ux-dev-2"].uuid
|
||||||
HISTORY_LIMIT = 2
|
HISTORY_LIMIT = 2
|
||||||
RETRY_ATTEMPTS = 2
|
RETRY_ATTEMPTS = 2
|
||||||
|
PREVIEW_DURATION_SECONDS = 6.0
|
||||||
|
|
||||||
|
|
||||||
async def _seed(session: AsyncSession) -> None:
|
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")
|
resp = await client.get(f"/api/video/preview/{task.id}/vertical.html")
|
||||||
assert resp.status_code == HTTPStatus.FORBIDDEN
|
assert resp.status_code == HTTPStatus.FORBIDDEN
|
||||||
app.dependency_overrides.clear()
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user