mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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'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>
|
||||
|
||||
Reference in New Issue
Block a user