Video pipeline fixes: visibility strip, rich briefs, spotlight timing + fps (#369)

* feat(video): pipeline visibility — strip, state-aware queue, render-error capture

Task 1 of the 2026-07-09 video-pipeline review. New CEO-gated GET
/video/pipeline lists every in-flight video item (authoring statuses,
rendering attempt n/max, terminal failures with the error — now stamped
onto the video_draft marker instead of dying as a log line).
source_task_id exposed on both video schemas. Panel: pipeline strip on
the Social page, state-aware queue empty copy, title/script on queue
rows, missing cuts disabled instead of a blank player, notifications
deep-link related_task_id. MAX_VIDEO_RENDER_ATTEMPTS moved to the
markers policy layer (single source of truth).

* feat(video): rich authoring briefs — changelog section, brand voice, kit pointer

Task 2 of the 2026-07-09 video-pipeline review. The release brief is
now a structured block (full CHANGELOG section capped at 4000 chars +
highlights) instead of one LLM-compressed sentence; brand_voice and a
motion/kit design-bar pointer are appended centrally in open_video_task
so release, spotlight, and on-demand paths all inherit them.
suggested_input_props seeded on the video_draft marker; third
acceptance criterion pins the design bar; propose_video docstring
points at the kit.

* fix(video): spotlight video drafts on CEO approval, renderer honors data-fps

Task 4 of the 2026-07-09 video-pipeline review. The companion-video
hook moves from propose_feature_spotlight (HoM authoring time) to
XPostService approve's posted-success branch for x_feature drafts,
mirroring the release-publish seam — a rejected spotlight no longer
burns a ux-dev cycle; wants_video/video_script ride the x_feature_ref
marker. Best-effort: a video-engine failure never breaks the post.
render.js reads data-fps from the composition HTML (clamped 24-60,
fallback 30) instead of hardcoding 30; parseFps covered by node --test.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-09 08:31:08 +02:00
committed by GitHub
co-authored by Renn F
parent 12b91d17a2
commit 18998c4a42
28 changed files with 1677 additions and 190 deletions
@@ -1,6 +1,7 @@
"use client";
import { Suspense, useEffect } from "react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import {
useNotifications,
@@ -106,6 +107,19 @@ function NotificationCard({
Needs Ack
</Badge>
)}
{notification.related_task_id && (
<Link
href={`/tasks/${notification.related_task_id}`}
prefetch={false}
>
<Badge
variant="outline"
className="text-xs hover:bg-muted cursor-pointer"
>
Task #{notification.related_task_id.slice(0, 8)}
</Badge>
</Link>
)}
</div>
<div className="text-sm text-muted-foreground mt-1">
<Markdown>{notification.body}</Markdown>
@@ -10,6 +10,9 @@ import type { VideoPostHistoryEntry } from "@/lib/api/video";
vi.mock("@/components/dashboard/x-post-queue", () => ({
XPostQueue: () => <div>XPostQueueStub</div>,
}));
vi.mock("@/components/dashboard/video-pipeline-strip", () => ({
VideoPipelineStrip: () => <div>VideoPipelineStripStub</div>,
}));
vi.mock("@/components/dashboard/video-post-queue", () => ({
VideoPostQueue: () => <div>VideoPostQueueStub</div>,
}));
@@ -82,6 +85,7 @@ describe("SocialPage", () => {
);
expect(screen.getByRole("heading", { name: "Social" })).toBeInTheDocument();
expect(screen.getByText("XPostQueueStub")).toBeInTheDocument();
expect(screen.getByText("VideoPipelineStripStub")).toBeInTheDocument();
expect(screen.getByText("VideoPostQueueStub")).toBeInTheDocument();
});
@@ -1,6 +1,7 @@
"use client";
import { XPostQueue } from "@/components/dashboard/x-post-queue";
import { VideoPipelineStrip } from "@/components/dashboard/video-pipeline-strip";
import { VideoPostQueue } from "@/components/dashboard/video-post-queue";
import { SocialHistorySection } from "@/components/dashboard/social-history-section";
@@ -16,6 +17,7 @@ export default function SocialPage() {
</div>
<XPostQueue />
<VideoPipelineStrip />
<VideoPostQueue />
<SocialHistorySection />
</div>
@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { 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(() => ({
listPipeline: vi.fn(async (): Promise<VideoPipelineItem[]> => []),
}));
vi.mock("@/lib/api", () => ({
videoApi: { listPipeline },
}));
import { VideoPipelineStrip } from "../video-pipeline-strip";
function withQueryClient(ui: ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
}
const AUTHORING: VideoPipelineItem = {
task_id: "vp-1",
title: "Video: launch teaser",
occasion: "launch",
status: "in_progress",
pr_number: null,
composition_id: null,
render_status: null,
render_attempts: 0,
max_attempts: 5,
render_error: null,
};
const AWAITING_APPROVAL: VideoPipelineItem = {
...AUTHORING,
task_id: "vp-2",
status: "awaiting_ceo_approval",
};
const RENDERING: VideoPipelineItem = {
...AUTHORING,
task_id: "vp-3",
status: "completed",
composition_id: "Intro",
render_attempts: 2,
};
const FAILED: VideoPipelineItem = {
...AUTHORING,
task_id: "vp-4",
status: "completed",
composition_id: "Intro",
render_status: "failed",
render_attempts: 5,
render_error: "sidecar timeout",
};
describe("VideoPipelineStrip", () => {
beforeEach(() => {
listPipeline.mockClear();
});
afterEach(() => {
vi.clearAllMocks();
});
it("renders nothing when the pipeline is empty", async () => {
render(withQueryClient(<VideoPipelineStrip />));
await waitFor(() => expect(listPipeline).toHaveBeenCalled());
expect(screen.queryByText("Video Pipeline")).not.toBeInTheDocument();
});
it("shows a stage chip per in-flight item, including the failure reason", async () => {
listPipeline.mockResolvedValueOnce([
AUTHORING,
AWAITING_APPROVAL,
RENDERING,
FAILED,
]);
render(withQueryClient(<VideoPipelineStrip />));
expect(await screen.findByText("Video Pipeline")).toBeInTheDocument();
expect(screen.getByText("Authoring")).toBeInTheDocument();
expect(screen.getByText("Awaiting your approval")).toBeInTheDocument();
expect(screen.getByText("Rendering (attempt 2/5)")).toBeInTheDocument();
expect(
screen.getByText("Render failed: sidecar timeout"),
).toBeInTheDocument();
});
it("deep-links only the awaiting-approval row to the task", async () => {
listPipeline.mockResolvedValueOnce([AUTHORING, AWAITING_APPROVAL]);
render(withQueryClient(<VideoPipelineStrip />));
await screen.findByText("Video Pipeline");
const reviewLinks = screen.getAllByRole("link", { name: "Review" });
expect(reviewLinks).toHaveLength(1);
expect(reviewLinks[0]).toHaveAttribute("href", "/tasks/vp-2");
});
});
@@ -0,0 +1,115 @@
import { describe, it, expect } from "vitest";
import {
derivePipelineStage,
pipelineStageLabel,
pipelineStageColor,
} from "../video-pipeline-utils";
const BASE = {
status: "in_progress",
render_status: null,
render_attempts: 0,
max_attempts: 5,
render_error: null,
};
describe("derivePipelineStage", () => {
it.each([
"backlog",
"pending",
"claimed",
"in_progress",
"blocked",
"paused",
"verifying",
"needs_revision",
])("maps %s to authoring", (status) => {
expect(derivePipelineStage({ ...BASE, status })).toEqual({
kind: "authoring",
});
});
it.each([
"awaiting_qa",
"awaiting_documentation",
"awaiting_pr_review",
"awaiting_pm_review",
])("maps %s to in_review", (status) => {
expect(derivePipelineStage({ ...BASE, status })).toEqual({
kind: "in_review",
});
});
it("maps awaiting_ceo_approval to awaiting_approval", () => {
expect(
derivePipelineStage({ ...BASE, status: "awaiting_ceo_approval" }),
).toEqual({ kind: "awaiting_approval" });
});
it("maps a completed task with no render_status to rendering, carrying attempts", () => {
expect(
derivePipelineStage({
...BASE,
status: "completed",
render_status: null,
render_attempts: 2,
max_attempts: 5,
}),
).toEqual({ kind: "rendering", attempt: 2, maxAttempts: 5 });
});
it("maps a completed task with render_status='failed' to render_failed, carrying the reason", () => {
expect(
derivePipelineStage({
...BASE,
status: "completed",
render_status: "failed",
render_error: "sidecar timeout",
}),
).toEqual({ kind: "render_failed", reason: "sidecar timeout" });
});
it("render_failed carries a null reason when the marker holds none", () => {
expect(
derivePipelineStage({
...BASE,
status: "completed",
render_status: "failed",
render_error: null,
}),
).toEqual({ kind: "render_failed", reason: null });
});
});
describe("pipelineStageLabel", () => {
it("labels every stage kind", () => {
expect(pipelineStageLabel({ kind: "authoring" })).toBe("Authoring");
expect(pipelineStageLabel({ kind: "in_review" })).toBe("In review");
expect(pipelineStageLabel({ kind: "awaiting_approval" })).toBe(
"Awaiting your approval",
);
expect(
pipelineStageLabel({ kind: "rendering", attempt: 2, maxAttempts: 5 }),
).toBe("Rendering (attempt 2/5)");
expect(
pipelineStageLabel({ kind: "render_failed", reason: "boom" }),
).toBe("Render failed: boom");
expect(pipelineStageLabel({ kind: "render_failed", reason: null })).toBe(
"Render failed",
);
});
});
describe("pipelineStageColor", () => {
it("returns a distinct class per stage kind", () => {
const stages: Parameters<typeof pipelineStageColor>[0][] = [
{ kind: "authoring" },
{ kind: "in_review" },
{ kind: "awaiting_approval" },
{ kind: "rendering", attempt: 1, maxAttempts: 5 },
{ kind: "render_failed", reason: null },
];
const colors = stages.map(pipelineStageColor);
expect(new Set(colors).size).toBe(colors.length);
});
});
@@ -2,51 +2,68 @@ 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 type { VideoPost } from "@/lib/api/video";
import type { VideoPipelineItem, VideoPost } from "@/lib/api/video";
const { resolveApproveRef } = vi.hoisted(() => ({
resolveApproveRef: { current: null as null | ((v: unknown) => void) },
}));
const { listPosts, approve, reject, requestVideo, getMediaBlob } = vi.hoisted(
() => ({
listPosts: vi.fn(
async () =>
[
{
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!",
const {
listPosts,
listPipeline,
approve,
reject,
requestVideo,
getMediaBlob,
} = vi.hoisted(() => ({
listPosts: vi.fn(
async () =>
[
{
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",
square: "/fake/square.mp4",
},
] as VideoPost[],
),
// Deferred so the test can freeze the approve mid-flight.
approve: vi.fn(
() =>
new Promise((r) => {
resolveApproveRef.current = r as (v: unknown) => void;
}),
),
reject: vi.fn(async () => ({})),
requestVideo: vi.fn(async () => ({
status: "opened",
task_id: "v-2",
detail: "Video-authoring task opened.",
})),
getMediaBlob: vi.fn(
async () => new Blob(["fake-mp4-bytes"], { type: "video/mp4" }),
),
}),
);
},
] as VideoPost[],
),
listPipeline: vi.fn(async (): Promise<VideoPipelineItem[]> => []),
// Deferred so the test can freeze the approve mid-flight.
approve: vi.fn(
() =>
new Promise((r) => {
resolveApproveRef.current = r as (v: unknown) => void;
}),
),
reject: vi.fn(async () => ({})),
requestVideo: vi.fn(async () => ({
status: "opened",
task_id: "v-2",
detail: "Video-authoring task opened.",
})),
getMediaBlob: vi.fn(
async () => new Blob(["fake-mp4-bytes"], { type: "video/mp4" }),
),
}));
vi.mock("@/lib/api", () => ({
videoApi: { listPosts, approve, reject, requestVideo, getMediaBlob },
videoApi: {
listPosts,
listPipeline,
approve,
reject,
requestVideo,
getMediaBlob,
},
}));
import { VideoPostQueue } from "../video-post-queue";
@@ -61,6 +78,7 @@ function withQueryClient(ui: ReactNode) {
describe("VideoPostQueue", () => {
beforeEach(() => {
listPosts.mockClear();
listPipeline.mockClear();
approve.mockClear();
reject.mockClear();
requestVideo.mockClear();
@@ -87,6 +105,46 @@ describe("VideoPostQueue", () => {
expect(screen.getByDisplayValue("New RoboCo drop!")).toBeInTheDocument();
});
it("shows the fetched title and script instead of dropping them", async () => {
render(withQueryClient(<VideoPostQueue />));
expect(
await screen.findByText("Video: release v0.19.0"),
).toBeInTheDocument();
expect(
screen.getByText("RoboCo v0.19.0 just shipped!"),
).toBeInTheDocument();
});
it("flags a missing cut as disabled instead of silently blanking the player", 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" }, // square never rendered
},
] as VideoPost[]);
render(withQueryClient(<VideoPostQueue />));
await screen.findByText("release");
const squareButton = screen.getByRole("button", { name: /1:1/ });
expect(squareButton).toHaveTextContent("(missing)");
expect(squareButton).toBeDisabled();
// The present cut is unaffected — no "(missing)" suffix, not disabled.
const verticalButton = screen.getByRole("button", { name: /9:16/ });
expect(verticalButton).not.toHaveTextContent("(missing)");
expect(verticalButton).not.toBeDisabled();
await waitFor(() =>
expect(getMediaBlob).toHaveBeenCalledWith("v-1", "vertical"),
);
});
// H15: the 30s refetchInterval produces a new `post` prop, but useState
// initializes once — so a server-side re-draft between the CEO opening the
// card and approving would be silently overwritten by the stale initial
@@ -242,12 +300,36 @@ describe("VideoPostQueue", () => {
);
});
it("shows an empty-state card (with the request action) when there are no drafts", async () => {
it("shows the keys/engine empty copy when nothing is in the pipeline either", async () => {
listPosts.mockResolvedValueOnce([]);
listPipeline.mockResolvedValueOnce([]);
render(withQueryClient(<VideoPostQueue />));
expect(await screen.findByText(/No drafts yet/)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Request a video/ }),
).toBeInTheDocument();
});
it("shows an in-flight count instead of the keys/engine copy when the pipeline is non-empty", async () => {
listPosts.mockResolvedValueOnce([]);
listPipeline.mockResolvedValueOnce([
{
task_id: "vp-1",
title: "Video: launch teaser",
occasion: "launch",
status: "in_progress",
pr_number: null,
composition_id: null,
render_status: null,
render_attempts: 0,
max_attempts: 5,
render_error: null,
},
] as VideoPipelineItem[]);
render(withQueryClient(<VideoPostQueue />));
expect(
await screen.findByText(/1 video in flight — nothing rendered yet/),
).toBeInTheDocument();
expect(screen.queryByText(/No drafts yet/)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,87 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { videoApi } from "@/lib/api";
import type { VideoPipelineItem } from "@/lib/api/video";
import {
derivePipelineStage,
pipelineStageColor,
pipelineStageLabel,
} from "./video-pipeline-utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Film } from "lucide-react";
// One row: title + occasion + a colored stage chip. The stage chip is
// 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.
function PipelineRow({ item }: { item: VideoPipelineItem }) {
const stage = derivePipelineStage(item);
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" />
<span className="font-medium">{item.title}</span>
{item.occasion && <Badge variant="outline">{item.occasion}</Badge>}
<Badge className={`${pipelineStageColor(stage)} text-white`}>
{pipelineStageLabel(stage)}
</Badge>
{stage.kind === "awaiting_approval" && (
<Link
href={`/tasks/${item.task_id}`}
prefetch={false}
className="ml-auto"
>
<Button variant="outline" size="sm">
Review
</Button>
</Link>
)}
</div>
);
}
// Social page visibility strip: every source=video item still moving
// through authoring/review/rendering, above the Video Post Queue. Renders
// nothing (not even an empty-state card) when the pipeline is empty — the
// queue's own state-aware copy already covers "nothing in flight". Polls
// on the same 30s cadence as the queue.
export function VideoPipelineStrip({ className }: { className?: string }) {
const { data: items, isLoading } = useQuery({
queryKey: ["video", "pipeline"],
queryFn: () => videoApi.listPipeline(),
refetchInterval: 30000,
});
if (isLoading || !items || items.length === 0) return null;
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Film className="h-5 w-5" />
Video Pipeline
<Badge variant="secondary">{items.length}</Badge>
</CardTitle>
<CardDescription>
Every video in flight authoring, review, or rendering before it
lands in the queue below.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{items.map((item) => (
<PipelineRow key={item.task_id} item={item} />
))}
</CardContent>
</Card>
);
}
@@ -0,0 +1,84 @@
/**
* Pure stage derivation for the video pipeline strip (extracted for direct
* unit testing). A VideoPipelineItem's `status` covers the pre-render
* delivery lifecycle; once it reaches COMPLETED, `render_status` /
* `render_attempts` (from the orchestration_markers.video_draft JSON) take
* over to describe the render loop's own retry/failure states.
*/
import type { VideoPipelineItem } from "@/lib/api/video";
export type PipelineStage =
| { kind: "authoring" }
| { kind: "in_review" }
| { kind: "awaiting_approval" }
| { kind: "rendering"; attempt: number; maxAttempts: number }
| { kind: "render_failed"; reason: string | null };
// Statuses between "self-verified" and "PM merges" — the dev's work is done,
// a reviewer/PM is looking at it.
const IN_REVIEW_STATUSES = new Set([
"awaiting_qa",
"awaiting_documentation",
"awaiting_pr_review",
"awaiting_pm_review",
]);
/**
* Derives the pipeline strip's stage chip from an item's raw fields. Only
* reads the subset of VideoPipelineItem the derivation needs, so callers
* (and tests) can pass a partial fixture.
*/
export function derivePipelineStage(
item: Pick<
VideoPipelineItem,
"status" | "render_status" | "render_attempts" | "max_attempts" | "render_error"
>,
): PipelineStage {
if (item.status === "completed") {
if (item.render_status === "failed") {
return { kind: "render_failed", reason: item.render_error };
}
// render_status unset (or any non-terminal value) — still retrying.
return {
kind: "rendering",
attempt: item.render_attempts,
maxAttempts: item.max_attempts,
};
}
if (item.status === "awaiting_ceo_approval") return { kind: "awaiting_approval" };
if (IN_REVIEW_STATUSES.has(item.status)) return { kind: "in_review" };
return { kind: "authoring" };
}
/** Human-readable label for a stage — the chip's display text. */
export function pipelineStageLabel(stage: PipelineStage): string {
switch (stage.kind) {
case "authoring":
return "Authoring";
case "in_review":
return "In review";
case "awaiting_approval":
return "Awaiting your approval";
case "rendering":
return `Rendering (attempt ${stage.attempt}/${stage.maxAttempts})`;
case "render_failed":
return stage.reason ? `Render failed: ${stage.reason}` : "Render failed";
}
}
/** Tailwind bg class for the stage badge — mirrors task-status-badge.tsx. */
export function pipelineStageColor(stage: PipelineStage): string {
switch (stage.kind) {
case "authoring":
return "bg-blue-600";
case "in_review":
return "bg-teal-600";
case "awaiting_approval":
return "bg-amber-600";
case "rendering":
return "bg-purple-500";
case "render_failed":
return "bg-red-600";
}
}
@@ -83,7 +83,11 @@ function VideoPostRow({
onReject: (post: VideoPost) => void;
approving: boolean;
}) {
const [cut, setCut] = useState<VideoCut>("vertical");
// Default to whichever cut actually rendered — a missing vertical cut
// (mp4_paths lacks the key) must not open on a guaranteed-blank player.
const [cut, setCut] = useState<VideoCut>(() =>
post.mp4_paths?.vertical ? "vertical" : "square",
);
const [editX, setEditX] = useState(post.platforms.includes("x"));
const [editTiktok, setEditTiktok] = useState(
post.platforms.includes("tiktok"),
@@ -101,7 +105,13 @@ function VideoPostRow({
// A native <video src> GET doesn't carry axios's auth headers, so fetch
// the cut as a Blob (through axios) and drive <video> off an object URL
// instead. Re-fetches on cut change; always revokes the previous URL.
// Skips the fetch entirely when mp4_paths has no entry for this cut — the
// JSX below never renders <video> in that case (falls back to a missing-
// cut placeholder instead), so a stale videoSrc is simply never read.
useEffect(() => {
if (!post.mp4_paths?.[cut]) {
return;
}
let cancelled = false;
let objectUrl: string | null = null;
videoApi
@@ -118,7 +128,7 @@ function VideoPostRow({
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [post.task_id, cut]);
}, [post.task_id, cut, post.mp4_paths]);
const xOverLimit = editX && xCaption.length > MAX_X_CAPTION_CHARS;
const tiktokOverLimit =
@@ -140,33 +150,52 @@ function VideoPostRow({
{post.occasion && <Badge variant="outline">{post.occasion}</Badge>}
</div>
<p className="mb-1 text-sm font-medium">{post.title}</p>
{post.script && (
<p className="mb-3 line-clamp-2 text-sm text-muted-foreground">
{post.script}
</p>
)}
<div className="mb-3 space-y-2">
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant={cut === "vertical" ? "default" : "outline"}
disabled={!post.mp4_paths?.vertical}
title={
post.mp4_paths?.vertical ? undefined : "9:16 hasn't rendered yet"
}
onClick={() => setCut("vertical")}
>
9:16
9:16{!post.mp4_paths?.vertical && " (missing)"}
</Button>
<Button
type="button"
size="sm"
variant={cut === "square" ? "default" : "outline"}
disabled={!post.mp4_paths?.square}
title={post.mp4_paths?.square ? undefined : "1:1 hasn't rendered yet"}
onClick={() => setCut("square")}
>
1:1
1:1{!post.mp4_paths?.square && " (missing)"}
</Button>
</div>
<video
key={`${post.task_id}-${cut}`}
controls
className="mx-auto max-h-96 w-full rounded-md bg-black object-contain"
src={videoSrc ?? undefined}
>
Your browser does not support embedded video.
</video>
{post.mp4_paths?.[cut] ? (
<video
key={`${post.task_id}-${cut}`}
controls
className="mx-auto max-h-96 w-full rounded-md bg-black object-contain"
src={videoSrc ?? undefined}
>
Your browser does not support embedded video.
</video>
) : (
<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 yet
</div>
)}
</div>
<div className="space-y-3">
@@ -388,6 +417,14 @@ export function VideoPostQueue({ className }: { className?: string }) {
queryFn: () => videoApi.listPosts(),
refetchInterval: 30000,
});
// Only consulted for the empty-state copy below — tells apart "nothing
// rendered yet, but N videos are moving through the pipeline" from a
// truly idle engine. Same 30s cadence as the queue + the pipeline strip.
const { data: pipeline } = useQuery({
queryKey: ["video", "pipeline"],
queryFn: () => videoApi.listPipeline(),
refetchInterval: 30000,
});
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ["video", "posts"] });
@@ -476,9 +513,9 @@ export function VideoPostQueue({ className }: { className?: string }) {
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
No drafts yet. Set your keys in Settings X (Twitter) / TikTok
Credentials and enable the video engine or request one on demand
above.
{pipeline && pipeline.length > 0
? `${pipeline.length} video${pipeline.length === 1 ? "" : "s"} in flight — nothing rendered yet. Check the pipeline above for status.`
: "No drafts yet. Set your keys in Settings → X (Twitter) / TikTok Credentials and enable the video engine — or request one on demand above."}
</p>
</CardContent>
</Card>
+1
View File
@@ -41,6 +41,7 @@ export { videoApi, videoMediaUrl } from "./video";
export type {
VideoCut,
VideoPost,
VideoPipelineItem,
VideoPostExecuteResult,
VideoPostHistoryEntry,
VideoRequestResult,
+24
View File
@@ -22,6 +22,24 @@ export interface VideoPost {
tiktok_caption?: string | null;
reject_reason?: string | null;
mp4_paths?: Record<string, string>;
source_task_id?: string | null; // the authoring task this draft rendered from
}
// One in-flight source=video authoring task — GET /video/pipeline
// (roboco/api/routes/video.py). Spans claim through the render loop's
// retry/failure states; a rendered task drops out (it's visible via
// VideoPost/listPosts instead).
export interface VideoPipelineItem {
task_id: string;
title: string;
occasion: string;
status: string;
pr_number: number | null;
composition_id: string | null;
render_status: string | null; // null (pending/retrying) | "rendered" | "failed"
render_attempts: number;
max_attempts: number;
render_error: string | null;
}
export interface VideoPostExecuteResult {
@@ -44,6 +62,7 @@ export interface VideoPostHistoryEntry {
reject_reason?: string | null;
posted: Record<string, string>; // platform -> posted id
acted_at: string;
source_task_id?: string | null; // the authoring task this draft rendered from
}
export interface VideoRequestResult {
@@ -72,6 +91,11 @@ export const videoApi = {
const { data } = await api.get<VideoPost[]>("/video/posts");
return data;
},
// Every in-flight source=video authoring task — the pipeline strip's basis.
listPipeline: async (): Promise<VideoPipelineItem[]> => {
const { data } = await api.get<VideoPipelineItem[]>("/video/pipeline");
return data;
},
// Posted or rejected drafts, newest-acted-first. Fixed default limit (50);
// no "load more" — pass a higher limit if the panel ever needs it.
listHistory: async (limit = 50): Promise<VideoPostHistoryEntry[]> => {