[e4ed92d6] Video pipeline per-project requests, re-render action, composition preview (#403)

* [7f2c881a] Project-scope video pipeline + re-render + preview proxy (#386) (#396)

* [7f2c881a] feat(video): scope on-demand video requests + render loop to project_id

Require project_id on VideoRequestBody (404 when unresolvable or not
opted into the video engine), thread it through VideoEngine.open_video_task
via a shared resolve_authoring_project helper, and resolve the render
loop's motion/ workspace from the authoring task's own project_id instead
of the hardcoded self_heal_project_slug.

* [7f2c881a] fix(video): cast task.id to UUID before VideoEngine.rerender calls

mypy flagged task.id as sqlalchemy.sql.sqltypes.UUID[Any] rather than
uuid.UUID in the three rerender tests; cast to UUID per the codebase's
established idiom (cast("UUID", obj.id)) used elsewhere for the same
SQLAlchemy Mapped-attribute inference gap.

* [7f2c881a] docs(video): API endpoints for project-scoped requests, re-render, and preview proxy

Add comprehensive API documentation for the new project-scoped video engine endpoints:
- POST /api/video/request: on-demand video authoring scoped to project_id (breaking change)
- POST /api/video/pipeline/{task_id}/rerender: CEO-triggered re-render with idempotency key clearing
- GET /api/video/preview/{task_id}/{file_path}: CEO preview proxy with path-traversal confinement

Document project-scoping architecture: authoring tasks and render loop now resolve from task's own project_id instead of hardcoded self_heal_project_slug.

Add migration guide covering breaking change to VideoRequestBody schema (project_id now required), error handling changes (404 on unresolvable/non-opted-in projects), and client migration steps.

---------

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>

* [8f959c3b] docs(ux_ui): add project picker, re-render control, and composition preview panel spec (#381) (#398)

Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech>

* [1fb5b5cb] Project picker, re-render button, and composition preview panel (#397) (#402)

* [1fb5b5cb] feat(video): project picker, re-render button, and composition preview panel

* [1fb5b5cb] docs(video): add comprehensive guide for project picker, re-render button, and composition preview panel

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>

* [a512f364] Add video_engine_enabled to ProjectSummaryResponse (#412) (#414)

* [a512f364] feat(api): surface video_engine_enabled on ProjectSummaryResponse

* [a512f364] docs(api): document video_engine_enabled on ProjectSummaryResponse

---------

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>

* [03607ab9] Fix re-render control gating/placement and project picker filter (#434)

* [f2f3e89f] Fix RerenderControl gating/placement across queue and strip views (#431)

* [f2f3e89f] feat(video): widen RerenderControl gating and share it across queue/strip views

Extracts RerenderControl into a shared panel/src/components/dashboard/
video-rerender-control.tsx component, widens its gate from
render_status === 'failed' to source_task_id + composition_id present
(matching what the backend rerender endpoint actually requires), adds a
confirm dialog before firing the mutation, and wires the same component
into video-pipeline-strip.tsx for still-in-flight rendering/render_failed
rows.

* [f2f3e89f] docs(video): enhance RerenderControl JSDoc with gating logic and usage examples

Add comprehensive JSDoc to the RerenderControl component covering its
purpose, gating logic (render for any source_task_id + composition_id,
regardless of render_status), three visual button states (idle/loading/
error), confirm-dialog guard behavior, and usage examples for both
video-post-queue.tsx and video-pipeline-strip.tsx contexts. Explains
why the backend's rerender endpoint doesn't require a failed render and
how the component prevents accidental re-renders.

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>

* [404d8ed3] Filter project picker to video-engine-enabled projects (#432)

* [404d8ed3] feat(panel): filter video-request project picker to opted-in projects

Add video_engine_enabled to the client ProjectSummary type, give
ProjectSelector a videoEngineOnly filter prop, default RequestVideoDialog's
picker to the current video-enabled project with a friendly empty-state
when none exist, and cover the filter with a new project-selector test.

* [404d8ed3] docs(panel): add ProjectSelector component API reference with videoEngineOnly filter

Document the reusable ProjectSelector component with its props, filtering behavior,
and new videoEngineOnly filter for video-engine-enabled projects. Follows the
existing component documentation pattern from page-refresh-provider.

---------

Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>

* [519a4088] fix(panel): import missing RerenderControl in video-post-queue and correct stale doc (#436)

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>
Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>

* [8e912c3e] Reflow hard-wrapped video UX design doc to pass quality gate (#439)

* [ccfe2015] docs(ux_ui): reflow video request composition-controls spec to one line per paragraph (#438)

Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech>

* [99c3ed9c] docs(backend): reflow hard-wrapped prose in video-engine-endpoints.md and video-project-scoping.md (#442)

Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech>

* [c2e98fc0] docs(backend): strip stray trailing whitespace in video-engine-endpoints.md fence (#451)

Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech>

* [9c7bc11a] Reflow all 3 hard-wrapped docs on this branch and verify quality gate (#457)

* [9c7bc11a] test(scripts): guard reflow_md.py --check wiring into make quality

* [9c7bc11a] docs(standards): document markdown reflow quality gate workflow and verification

Added comprehensive guide explaining the one-logical-unit-per-line markdown prose standard, how the reflow check integrates into make quality, the three reflowed files (video-engine-endpoints.md, video-project-scoping.md, composition-controls spec), and the regression test added to ensure wiring stability. This task verifies all three ACs are satisfied: reflow_md.py --check exits 0, make quality passes (non-DB portions), and the three files are whitespace-only reflowed.

---------

Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech>
Co-authored-by: UX/UI Documenter <ux-doc@roboco.tech>

---------

Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech>
Co-authored-by: UX/UI Documenter <ux-doc@roboco.tech>

* [002f0cdd] docs(rag): document reflow-check zero-diff troubleshooting path (#459) (#460)

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>

* [e4ed92d6] fix rerender missing-task test — assert the empty queue it creates

The test never seeds; the trailing assertion expected a phantom video
post. Broken since the branch's first commit but never executed — every
earlier CI run short-circuited at a pre-pytest gate step.

---------

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech>
Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>
Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>
Co-authored-by: UX/UI Documenter <ux-doc@roboco.tech>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-11 07:39:17 +02:00
committed by GitHub
co-authored by Backend Developer 1 Backend Documenter UX/UI Developer 1 Frontend Developer 1 Frontend Documenter Frontend Developer 2 UX/UI Documenter Renn F
parent 4d52f6ff59
commit 7f138d3bf5
29 changed files with 2707 additions and 107 deletions
@@ -1,15 +1,16 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import type { VideoPipelineItem } from "@/lib/api/video";
const { listPipeline } = vi.hoisted(() => ({
const { listPipeline, rerender } = vi.hoisted(() => ({
listPipeline: vi.fn(async (): Promise<VideoPipelineItem[]> => []),
rerender: vi.fn(async () => undefined),
}));
vi.mock("@/lib/api", () => ({
videoApi: { listPipeline },
videoApi: { listPipeline, rerender },
}));
import { VideoPipelineStrip } from "../video-pipeline-strip";
@@ -61,6 +62,7 @@ const FAILED: VideoPipelineItem = {
describe("VideoPipelineStrip", () => {
beforeEach(() => {
listPipeline.mockClear();
rerender.mockClear();
});
afterEach(() => {
vi.clearAllMocks();
@@ -99,4 +101,35 @@ describe("VideoPipelineStrip", () => {
expect(reviewLinks).toHaveLength(1);
expect(reviewLinks[0]).toHaveAttribute("href", "/tasks/vp-2");
});
it("shows a re-render button on rendering and render_failed rows with a composition, but not on authoring/awaiting-approval rows", async () => {
listPipeline.mockResolvedValueOnce([
AUTHORING,
AWAITING_APPROVAL,
RENDERING,
FAILED,
]);
render(withQueryClient(<VideoPipelineStrip />));
await screen.findByText("Video Pipeline");
const rerenderButtons = screen.getAllByRole("button", {
name: /Re-render/,
});
expect(rerenderButtons).toHaveLength(2);
});
it("triggers the backend re-render action for the clicked row's authoring task id, behind a confirm dialog", async () => {
listPipeline.mockResolvedValueOnce([FAILED]);
render(withQueryClient(<VideoPipelineStrip />));
await screen.findByText("Video Pipeline");
fireEvent.click(screen.getByRole("button", { name: /Re-render/ }));
expect(rerender).not.toHaveBeenCalled();
const confirmButton = await screen.findByRole("button", {
name: /Re-render/,
});
fireEvent.click(confirmButton);
await waitFor(() => expect(rerender).toHaveBeenCalledWith("vp-4"));
});
});
@@ -8,6 +8,30 @@ const { resolveApproveRef } = vi.hoisted(() => ({
resolveApproveRef: { current: null as null | ((v: unknown) => void) },
}));
const { useProjects } = vi.hoisted(() => ({
// One video-enabled project by default ("p-1", matching the mocked
// ProjectSelector's onChange value below) so the dialog can default-select
// it — tests that need the empty-state override this per-test.
useProjects: vi.fn(() => ({
data: [
{
id: "p-1",
name: "roboco-panel",
slug: "roboco-panel",
git_url: "https://example.com/roboco-panel.git",
assigned_cell: "frontend",
is_active: true,
has_workspace: true,
has_git_token: true,
video_engine_enabled: true,
},
],
isLoading: false,
})),
}));
vi.mock("@/hooks/use-projects", () => ({ useProjects }));
const {
listPosts,
listPipeline,
@@ -15,6 +39,7 @@ const {
reject,
requestVideo,
getMediaBlob,
rerender,
} = vi.hoisted(() => ({
listPosts: vi.fn(
async () =>
@@ -53,6 +78,7 @@ const {
getMediaBlob: vi.fn(
async () => new Blob(["fake-mp4-bytes"], { type: "video/mp4" }),
),
rerender: vi.fn(async () => undefined),
}));
vi.mock("@/lib/api", () => ({
@@ -63,8 +89,18 @@ vi.mock("@/lib/api", () => ({
reject,
requestVideo,
getMediaBlob,
rerender,
},
}));
// ProjectSelector: a button that sets the project, mirroring
// create-task-dialog.test.tsx — bypasses the data-fetching combobox.
vi.mock("@/components/projects/project-selector", () => ({
ProjectSelector: ({ onChange }: { onChange: (v: string | null) => void }) => (
<button type="button" onClick={() => onChange("p-1")}>
Set Project
</button>
),
}));
import { VideoPostQueue } from "../video-post-queue";
@@ -277,11 +313,12 @@ describe("VideoPostQueue", () => {
);
});
it("requests an on-demand video with the chosen occasion, brief, and platforms", async () => {
it("requests an on-demand video with the chosen project, occasion, brief, and platforms", async () => {
render(withQueryClient(<VideoPostQueue />));
await screen.findByText("release");
fireEvent.click(screen.getByRole("button", { name: /Request a video/ }));
fireEvent.click(screen.getByRole("button", { name: "Set Project" }));
fireEvent.change(screen.getByLabelText("Occasion"), {
target: { value: "Founder's Day" },
});
@@ -296,10 +333,70 @@ describe("VideoPostQueue", () => {
occasion: "Founder's Day",
brief: "Celebrate the founding.",
platforms: ["x", "tiktok"],
project_id: "p-1",
}),
);
});
it("defaults the project to the current (first) video-enabled project so Request enables without an explicit pick", async () => {
render(withQueryClient(<VideoPostQueue />));
await screen.findByText("release");
fireEvent.click(screen.getByRole("button", { name: /Request a video/ }));
// Request still needs occasion/brief filled...
expect(screen.getByRole("button", { name: "Request" })).toBeDisabled();
fireEvent.change(screen.getByLabelText("Occasion"), {
target: { value: "Founder's Day" },
});
fireEvent.change(screen.getByLabelText("Brief"), {
target: { value: "Celebrate the founding." },
});
// ...but never a manual "Set Project" click — the picker already
// defaulted to the sole video-enabled project.
expect(screen.getByRole("button", { name: "Request" })).not.toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Request" }));
await waitFor(() =>
expect(requestVideo).toHaveBeenCalledWith(
expect.objectContaining({ project_id: "p-1" }),
),
);
});
it("shows a friendly empty-state when no project has the video engine enabled", async () => {
// mockReturnValue (not -Once): RequestVideoDialog re-renders more than
// once before the assertions run, and a -Once override would only cover
// the first of those renders.
useProjects.mockReturnValue({
data: [
{
id: "p-2",
name: "not-opted-in",
slug: "not-opted-in",
git_url: "https://example.com/not-opted-in.git",
assigned_cell: "backend",
is_active: true,
has_workspace: true,
has_git_token: true,
video_engine_enabled: false,
},
],
isLoading: false,
});
render(withQueryClient(<VideoPostQueue />));
await screen.findByText("release");
fireEvent.click(screen.getByRole("button", { name: /Request a video/ }));
expect(
screen.getByText(/No projects have the video engine enabled/),
).toBeInTheDocument();
expect(screen.queryByLabelText("Occasion")).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Request" }),
).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
});
it("shows the keys/engine empty copy when nothing is in the pipeline either", async () => {
listPosts.mockResolvedValueOnce([]);
listPipeline.mockResolvedValueOnce([]);
@@ -332,4 +429,107 @@ describe("VideoPostQueue", () => {
).toBeInTheDocument();
expect(screen.queryByText(/No drafts yet/)).not.toBeInTheDocument();
});
it("does not show a re-render button when the draft has no source_task_id/composition_id", async () => {
render(withQueryClient(<VideoPostQueue />));
await screen.findByText("release");
expect(
screen.queryByRole("button", { name: /Re-render/ }),
).not.toBeInTheDocument();
});
it("shows a re-render button on a draft with a composition regardless of render_status, behind a confirm dialog", async () => {
listPosts.mockResolvedValueOnce([
{
task_id: "v-1",
source: "video_post",
title: "Video: release v0.19.0",
status: "pending",
occasion: "release",
script: "RoboCo v0.19.0 just shipped!",
platforms: ["x", "tiktok"],
x_caption: "RoboCo v0.19.0 is here!",
tiktok_caption: "New RoboCo drop!",
mp4_paths: { vertical: "/fake/vertical.mp4" },
source_task_id: "auth-1",
composition_id: "release-recap",
render_status: "failed",
},
] as VideoPost[]);
render(withQueryClient(<VideoPostQueue />));
await screen.findByText("release");
const rerenderButton = screen.getByRole("button", { name: /Re-render/ });
fireEvent.click(rerenderButton);
// The action is gated behind a confirm dialog — clicking the trigger
// alone must not call the backend.
expect(rerender).not.toHaveBeenCalled();
const confirmButton = await screen.findByRole("button", {
name: /Re-render/,
});
fireEvent.click(confirmButton);
await waitFor(() => expect(rerender).toHaveBeenCalledWith("auth-1"));
});
it("renders the re-render button for a post with render_status='rendered'", async () => {
listPosts.mockResolvedValueOnce([
{
task_id: "v-1",
source: "video_post",
title: "Video: release v0.19.0",
status: "pending",
occasion: "release",
script: "RoboCo v0.19.0 just shipped!",
platforms: ["x", "tiktok"],
x_caption: "RoboCo v0.19.0 is here!",
tiktok_caption: "New RoboCo drop!",
mp4_paths: { vertical: "/fake/vertical.mp4" },
source_task_id: "auth-1",
composition_id: "release-recap",
render_status: "rendered",
},
] as VideoPost[]);
render(withQueryClient(<VideoPostQueue />));
await screen.findByText("release");
expect(
screen.getByRole("button", { name: /Re-render/ }),
).toBeInTheDocument();
});
it("shows the live composition preview iframe with captions when composition_id is present", async () => {
listPosts.mockResolvedValueOnce([
{
task_id: "v-1",
source: "video_post",
title: "Video: release v0.19.0",
status: "pending",
occasion: "release",
script: "RoboCo v0.19.0 just shipped!",
platforms: ["x", "tiktok"],
x_caption: "RoboCo v0.19.0 is here!",
tiktok_caption: "New RoboCo drop!",
mp4_paths: { vertical: "/fake/vertical.mp4" },
source_task_id: "auth-1",
composition_id: "release-recap",
},
] as VideoPost[]);
render(withQueryClient(<VideoPostQueue />));
await screen.findByText("release");
const iframe = document.querySelector("iframe");
expect(iframe).toBeInTheDocument();
expect(iframe?.getAttribute("src")).toContain(
"/video/preview/auth-1/motion/compositions/release-recap/vertical.html",
);
expect(screen.getByText("Captions as they will post")).toBeInTheDocument();
});
it("hides the composition preview panel when the draft carries no composition_id", async () => {
render(withQueryClient(<VideoPostQueue />));
await screen.findByText("release");
expect(document.querySelector("iframe")).not.toBeInTheDocument();
});
});
@@ -9,6 +9,7 @@ import {
pipelineStageColor,
pipelineStageLabel,
} from "./video-pipeline-utils";
import { RerenderControl } from "@/components/dashboard/video-rerender-control";
import {
Card,
CardContent,
@@ -24,9 +25,17 @@ import { Film } from "lucide-react";
// derived (never fetched) from status + render_status/render_attempts —
// see video-pipeline-utils.ts, unit-tested directly there. Only the
// "awaiting your approval" stage deep-links out (the CEO decision the
// pipeline is surfacing); every other stage is just visibility.
// pipeline is surfacing); every other stage is just visibility. The
// re-render control (shared with video-post-queue.tsx) shows for any item
// carrying a proposed composition — the "rendering" and "render_failed"
// stages are the only ones where the item's own task_id doubles as the
// authoring task id the rerender endpoint needs, regardless of whether the
// render is currently retrying or has failed outright.
function PipelineRow({ item }: { item: VideoPipelineItem }) {
const stage = derivePipelineStage(item);
const canRerender =
(stage.kind === "rendering" || stage.kind === "render_failed") &&
!!item.composition_id;
return (
<div className="flex flex-wrap items-center gap-2 rounded-lg border p-3 text-sm">
<Film className="h-4 w-4 shrink-0 text-muted-foreground" />
@@ -46,6 +55,11 @@ function PipelineRow({ item }: { item: VideoPipelineItem }) {
</Button>
</Link>
)}
{canRerender && (
<div className="ml-auto">
<RerenderControl authoringTaskId={item.task_id} />
</div>
)}
</div>
);
}
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { videoApi } from "@/lib/api";
import { compositionPreviewUrl } from "@/lib/api/video";
import type {
VideoCut,
VideoPost,
@@ -30,6 +31,9 @@ import {
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ProjectSelector } from "@/components/projects/project-selector";
import { useProjects } from "@/hooks/use-projects";
import { RerenderControl } from "@/components/dashboard/video-rerender-control";
import { CheckCircle2, Film, Sparkles, XCircle } from "lucide-react";
import { toast } from "sonner";
@@ -64,6 +68,53 @@ function describeExecuteResult(result: VideoPostExecuteResult): string {
return `${result.status}: ${result.detail}`;
}
// Live composition preview: the authoring task's actual HyperFrames HTML for
// the currently-selected cut, embedded via the backend's composition-HTML
// proxy (iframe-permitting headers, so a direct <iframe src> works — no
// blob-fetch workaround needed, unlike the MP4 player below). Read-only
// captions sit alongside so the CEO can compare the live composition against
// what will actually post, before approving. Renders nothing when the draft
// carries no composition_id (older drafts / backend not yet exposing it).
function CompositionPreviewPanel({
post,
cut,
}: {
post: VideoPost;
cut: VideoCut;
}) {
if (!post.composition_id || !post.source_task_id) return null;
return (
<div className="mb-3 grid gap-3 rounded-md border p-3 sm:grid-cols-2">
<iframe
src={compositionPreviewUrl(
post.source_task_id,
post.composition_id,
cut,
)}
title={`${post.title} — live composition preview`}
sandbox="allow-scripts"
loading="lazy"
className="aspect-video w-full rounded-md border bg-black"
/>
<div className="space-y-2 text-sm">
<p className="font-medium text-muted-foreground">
Captions as they will post
</p>
{post.x_caption && (
<p>
<span className="font-medium">X:</span> {post.x_caption}
</p>
)}
{post.tiktok_caption && (
<p>
<span className="font-medium">TikTok:</span> {post.tiktok_caption}
</p>
)}
</div>
</div>
);
}
// One row of the queue: an MP4 preview (9:16 / 1:1 cut switcher) + per-
// platform editable captions + approve/reject. Mirrors XPostRow. Unchecking
// a platform's "Edit ... caption" box leaves it disabled (shown, not sent) —
@@ -134,6 +185,11 @@ function VideoPostRow({
const tiktokOverLimit =
editTiktok && tiktokCaption.length > MAX_TIKTOK_CAPTION_CHARS;
const overLimit = xOverLimit || tiktokOverLimit;
// The re-render endpoint only requires a completed authoring task with a
// proposed composition — it doesn't require a failed render, so the
// button shows for ANY draft that carries both, regardless of
// render_status (a healthy render can be deliberately redone too).
const canRerender = !!post.source_task_id && !!post.composition_id;
const handleApprove = () => {
onApprove(post.task_id, {
@@ -148,6 +204,11 @@ function VideoPostRow({
<meta.icon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{meta.label}</span>
{post.occasion && <Badge variant="outline">{post.occasion}</Badge>}
{canRerender && (
<div className="ml-auto">
<RerenderControl authoringTaskId={post.source_task_id as string} />
</div>
)}
</div>
<p className="mb-1 text-sm font-medium">{post.title}</p>
@@ -157,6 +218,8 @@ function VideoPostRow({
</p>
)}
<CompositionPreviewPanel post={post} cut={cut} />
<div className="mb-3 space-y-2">
<div className="flex gap-2">
<Button
@@ -176,7 +239,9 @@ function VideoPostRow({
size="sm"
variant={cut === "square" ? "default" : "outline"}
disabled={!post.mp4_paths?.square}
title={post.mp4_paths?.square ? undefined : "1:1 hasn't rendered yet"}
title={
post.mp4_paths?.square ? undefined : "1:1 hasn't rendered yet"
}
onClick={() => setCut("square")}
>
1:1{!post.mp4_paths?.square && " (missing)"}
@@ -294,6 +359,14 @@ function RequestVideoDialog({
const [occasion, setOccasion] = useState("");
const [brief, setBrief] = useState("");
const [platforms, setPlatforms] = useState<string[]>(["x", "tiktok"]);
// null means "no explicit pick yet" — derived below to the current (first)
// video-enabled project, mirroring the caption-tracking pattern elsewhere
// in this file (`editedX ?? post.x_caption`) rather than syncing via effect.
const [projectId, setProjectId] = useState<string | null>(null);
const { data: allProjects = [] } = useProjects();
const videoProjects = allProjects.filter((p) => p.video_engine_enabled);
const hasVideoProjects = videoProjects.length > 0;
const effectiveProjectId = projectId ?? videoProjects[0]?.id ?? null;
const requestMutation = useMutation({
mutationFn: () =>
@@ -301,6 +374,7 @@ function RequestVideoDialog({
occasion: occasion.trim(),
brief: brief.trim(),
platforms,
project_id: effectiveProjectId as string,
}),
onSuccess: (result) => {
if (result.status === "opened") {
@@ -309,6 +383,7 @@ function RequestVideoDialog({
setOccasion("");
setBrief("");
setPlatforms(["x", "tiktok"]);
setProjectId(null);
} else {
toast.warning(result.detail);
}
@@ -328,6 +403,7 @@ function RequestVideoDialog({
};
const canSubmit =
!!effectiveProjectId &&
occasion.trim().length > 0 &&
brief.trim().length > 0 &&
platforms.length > 0;
@@ -343,58 +419,85 @@ function RequestVideoDialog({
rendering finishes.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="video-request-occasion">Occasion</Label>
<Input
id="video-request-occasion"
placeholder="e.g. v0.19.0 launch, Founder's Day..."
value={occasion}
onChange={(e) => setOccasion(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-request-brief">Brief</Label>
<Textarea
id="video-request-brief"
placeholder="What should this video cover?"
value={brief}
onChange={(e) => setBrief(e.target.value)}
rows={4}
/>
</div>
<div className="space-y-2">
<Label>Platforms</Label>
<div className="flex gap-4">
{REQUEST_PLATFORMS.map((platform) => (
<div key={platform} className="flex items-center gap-2">
<Checkbox
id={`video-request-${platform}`}
checked={platforms.includes(platform)}
onCheckedChange={() => togglePlatform(platform)}
/>
<Label
htmlFor={`video-request-${platform}`}
className="text-sm font-normal"
>
{PLATFORM_LABELS[platform]}
</Label>
{hasVideoProjects ? (
<>
<div className="space-y-4">
<div className="space-y-2">
<Label>Project</Label>
<ProjectSelector
value={effectiveProjectId}
onChange={setProjectId}
placeholder="Select the project this video is about..."
allowClear={false}
videoEngineOnly
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-request-occasion">Occasion</Label>
<Input
id="video-request-occasion"
placeholder="e.g. v0.19.0 launch, Founder's Day..."
value={occasion}
onChange={(e) => setOccasion(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="video-request-brief">Brief</Label>
<Textarea
id="video-request-brief"
placeholder="What should this video cover?"
value={brief}
onChange={(e) => setBrief(e.target.value)}
rows={4}
/>
</div>
<div className="space-y-2">
<Label>Platforms</Label>
<div className="flex gap-4">
{REQUEST_PLATFORMS.map((platform) => (
<div key={platform} className="flex items-center gap-2">
<Checkbox
id={`video-request-${platform}`}
checked={platforms.includes(platform)}
onCheckedChange={() => togglePlatform(platform)}
/>
<Label
htmlFor={`video-request-${platform}`}
className="text-sm font-normal"
>
{PLATFORM_LABELS[platform]}
</Label>
</div>
))}
</div>
))}
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={() => requestMutation.mutate()}
disabled={!canSubmit || requestMutation.isPending}
>
{requestMutation.isPending ? "Requesting..." : "Request"}
</Button>
</DialogFooter>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={() => requestMutation.mutate()}
disabled={!canSubmit || requestMutation.isPending}
>
{requestMutation.isPending ? "Requesting..." : "Request"}
</Button>
</DialogFooter>
</>
) : (
<>
<p className="text-sm text-muted-foreground">
No projects have the video engine enabled yet. Turn it on for a
project in its edit dialog (Projects Edit) before requesting a
video.
</p>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
@@ -0,0 +1,138 @@
"use client";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { videoApi } from "@/lib/api";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { RefreshCw } from "lucide-react";
import { toast } from "sonner";
/**
* Shared re-render control — renders a button + confirm dialog for re-triggering
* video render operations. Used by both video-post-queue.tsx (a rendered draft)
* and video-pipeline-strip.tsx (a still-in-flight authoring task).
*
* @component
*
* Gating Logic:
* - Shows for any task with source_task_id + composition_id, regardless of
* render_status. The backend's rerender endpoint only requires a completed
* authoring task with a proposed composition, not a failed render, so a
* healthy render can be deliberately redone too.
*
* Behavior:
* - Trigger button opens a Dialog with Cancel and Re-render buttons (guards
* against accidental clicks, since re-rendering discards whatever already
* rendered).
* - Only the confirm click calls videoApi.rerender(authoringTaskId).
* - Three visual states on the trigger button:
* 1. Idle: "Re-render" (ready to click)
* 2. Loading: "Re-rendering..." with spinning icon (mutation in-flight)
* 3. Error: "Retry re-render" in red (the retry itself failed; button stays
* enabled so the CEO can try again).
*
* @example
* ```tsx
* // Usage in video-post-queue for a rendered draft:
* {canRerender && (
* <div className="ml-auto">
* <RerenderControl authoringTaskId={post.source_task_id as string} />
* </div>
* )}
* ```
*
* @example
* ```tsx
* // Usage in video-pipeline-strip for an in-flight authoring task:
* {canRerender && (
* <div className="ml-auto">
* <RerenderControl authoringTaskId={item.task_id} />
* </div>
* )}
* ```
*/
export function RerenderControl({
authoringTaskId,
}: {
/**
* The authoring task ID to re-render. Passed to videoApi.rerender(authoringTaskId).
* For video-post-queue: the post's source_task_id.
* For video-pipeline-strip: the pipeline item's own task_id (a pipeline item IS
* the authoring task).
*/
authoringTaskId: string;
}) {
const queryClient = useQueryClient();
const [confirmOpen, setConfirmOpen] = useState(false);
const rerenderMutation = useMutation({
mutationFn: () => videoApi.rerender(authoringTaskId),
onSuccess: () => {
toast.success("Re-render queued — it will re-pick up on the next cycle.");
queryClient.invalidateQueries({ queryKey: ["video", "pipeline"] });
queryClient.invalidateQueries({ queryKey: ["video", "posts"] });
},
onError: (e) =>
toast.error(
`Re-render failed: ${e instanceof Error ? e.message : "error"}`,
),
});
return (
<>
<Button
type="button"
variant="outline"
size="sm"
disabled={rerenderMutation.isPending}
onClick={() => setConfirmOpen(true)}
className={
rerenderMutation.isError
? "border-destructive text-destructive"
: undefined
}
>
<RefreshCw
className={`mr-1 h-4 w-4 ${rerenderMutation.isPending ? "animate-spin" : ""}`}
/>
{rerenderMutation.isPending
? "Re-rendering..."
: rerenderMutation.isError
? "Retry re-render"
: "Re-render"}
</Button>
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Re-render this video?</DialogTitle>
<DialogDescription>
This clears the current render and queues a fresh one from the
same composition. Any already-rendered cuts stay visible until
the new render finishes.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
Cancel
</Button>
<Button
onClick={() => {
setConfirmOpen(false);
rerenderMutation.mutate();
}}
>
Re-render
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,92 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import React from "react";
import { Team } from "@/types";
import type { ProjectSummary } from "@/types";
const { useProjects } = vi.hoisted(() => ({ useProjects: vi.fn() }));
vi.mock("@/hooks/use-projects", () => ({ useProjects }));
// Make the Select testable without Radix's portal/pointer machinery — mirrors
// a2a-reply-composer.test.tsx: SelectContent always renders its children, so
// the assertions below can query rendered project names directly.
vi.mock("@/components/ui/select", () => ({
Select: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectValue: ({ placeholder }: { placeholder?: string }) => (
<span>{placeholder}</span>
),
SelectContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectGroup: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectLabel: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectItem: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
}));
import { ProjectSelector } from "../project-selector";
function project(overrides: Partial<ProjectSummary>): ProjectSummary {
return {
id: "p-id",
name: "project",
slug: "project",
git_url: "https://example.com/project.git",
assigned_cell: Team.FRONTEND,
is_active: true,
has_workspace: true,
has_git_token: true,
video_engine_enabled: false,
...overrides,
};
}
describe("ProjectSelector", () => {
it("videoEngineOnly excludes projects that have not opted into the video engine", () => {
useProjects.mockReturnValue({
data: [
project({ id: "p-1", name: "Video Ready", video_engine_enabled: true }),
project({
id: "p-2",
name: "Not Opted In",
video_engine_enabled: false,
}),
],
isLoading: false,
});
render(<ProjectSelector value={null} onChange={vi.fn()} videoEngineOnly />);
expect(screen.getByText("Video Ready")).toBeInTheDocument();
expect(screen.queryByText("Not Opted In")).not.toBeInTheDocument();
});
it("shows every project when videoEngineOnly is not set", () => {
useProjects.mockReturnValue({
data: [
project({ id: "p-1", name: "Video Ready", video_engine_enabled: true }),
project({
id: "p-2",
name: "Not Opted In",
video_engine_enabled: false,
}),
],
isLoading: false,
});
render(<ProjectSelector value={null} onChange={vi.fn()} />);
expect(screen.getByText("Video Ready")).toBeInTheDocument();
expect(screen.getByText("Not Opted In")).toBeInTheDocument();
});
});
@@ -22,6 +22,10 @@ interface ProjectSelectorProps {
filterByTeam?: Team;
disabled?: boolean;
allowClear?: boolean;
// Restrict the list to projects with the video engine opted in
// (project.video_engine_enabled) — for pickers scoped to video-authoring
// flows (e.g. RequestVideoDialog).
videoEngineOnly?: boolean;
}
// Team display names for project cells
@@ -38,6 +42,7 @@ export function ProjectSelector({
filterByTeam,
disabled = false,
allowClear = true,
videoEngineOnly = false,
}: ProjectSelectorProps) {
const { data: projects = [], isLoading } = useProjects();
@@ -45,6 +50,11 @@ export function ProjectSelector({
const groupedProjects = useMemo(() => {
let filtered = projects;
// Restrict to video-engine-opted-in projects
if (videoEngineOnly) {
filtered = filtered.filter((p) => p.video_engine_enabled);
}
// Apply team filter
if (filterByTeam) {
filtered = filtered.filter((p) => p.assigned_cell === filterByTeam);
@@ -71,7 +81,7 @@ export function ProjectSelector({
}
return groups;
}, [projects, filterByTeam]);
}, [projects, filterByTeam, videoEngineOnly]);
// Find selected project for display
const selectedProject = useMemo(() => {