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"; "use client";
import { Suspense, useEffect } from "react"; import { Suspense, useEffect } from "react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation"; import { useSearchParams, useRouter } from "next/navigation";
import { import {
useNotifications, useNotifications,
@@ -106,6 +107,19 @@ function NotificationCard({
Needs Ack Needs Ack
</Badge> </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>
<div className="text-sm text-muted-foreground mt-1"> <div className="text-sm text-muted-foreground mt-1">
<Markdown>{notification.body}</Markdown> <Markdown>{notification.body}</Markdown>
@@ -10,6 +10,9 @@ import type { VideoPostHistoryEntry } from "@/lib/api/video";
vi.mock("@/components/dashboard/x-post-queue", () => ({ vi.mock("@/components/dashboard/x-post-queue", () => ({
XPostQueue: () => <div>XPostQueueStub</div>, XPostQueue: () => <div>XPostQueueStub</div>,
})); }));
vi.mock("@/components/dashboard/video-pipeline-strip", () => ({
VideoPipelineStrip: () => <div>VideoPipelineStripStub</div>,
}));
vi.mock("@/components/dashboard/video-post-queue", () => ({ vi.mock("@/components/dashboard/video-post-queue", () => ({
VideoPostQueue: () => <div>VideoPostQueueStub</div>, VideoPostQueue: () => <div>VideoPostQueueStub</div>,
})); }));
@@ -82,6 +85,7 @@ describe("SocialPage", () => {
); );
expect(screen.getByRole("heading", { name: "Social" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Social" })).toBeInTheDocument();
expect(screen.getByText("XPostQueueStub")).toBeInTheDocument(); expect(screen.getByText("XPostQueueStub")).toBeInTheDocument();
expect(screen.getByText("VideoPipelineStripStub")).toBeInTheDocument();
expect(screen.getByText("VideoPostQueueStub")).toBeInTheDocument(); expect(screen.getByText("VideoPostQueueStub")).toBeInTheDocument();
}); });
@@ -1,6 +1,7 @@
"use client"; "use client";
import { XPostQueue } from "@/components/dashboard/x-post-queue"; 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 { VideoPostQueue } from "@/components/dashboard/video-post-queue";
import { SocialHistorySection } from "@/components/dashboard/social-history-section"; import { SocialHistorySection } from "@/components/dashboard/social-history-section";
@@ -16,6 +17,7 @@ export default function SocialPage() {
</div> </div>
<XPostQueue /> <XPostQueue />
<VideoPipelineStrip />
<VideoPostQueue /> <VideoPostQueue />
<SocialHistorySection /> <SocialHistorySection />
</div> </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 { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { VideoPost } from "@/lib/api/video"; import type { VideoPipelineItem, VideoPost } from "@/lib/api/video";
const { resolveApproveRef } = vi.hoisted(() => ({ const { resolveApproveRef } = vi.hoisted(() => ({
resolveApproveRef: { current: null as null | ((v: unknown) => void) }, resolveApproveRef: { current: null as null | ((v: unknown) => void) },
})); }));
const { listPosts, approve, reject, requestVideo, getMediaBlob } = vi.hoisted( const {
() => ({ listPosts,
listPosts: vi.fn( listPipeline,
async () => approve,
[ reject,
{ requestVideo,
task_id: "v-1", getMediaBlob,
source: "video_post", } = vi.hoisted(() => ({
title: "Video: release v0.19.0", listPosts: vi.fn(
status: "pending", async () =>
occasion: "release", [
script: "RoboCo v0.19.0 just shipped!", {
platforms: ["x", "tiktok"], task_id: "v-1",
x_caption: "RoboCo v0.19.0 is here!", source: "video_post",
tiktok_caption: "New RoboCo drop!", 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[], },
), ] as VideoPost[],
// Deferred so the test can freeze the approve mid-flight. ),
approve: vi.fn( listPipeline: vi.fn(async (): Promise<VideoPipelineItem[]> => []),
() => // Deferred so the test can freeze the approve mid-flight.
new Promise((r) => { approve: vi.fn(
resolveApproveRef.current = r as (v: unknown) => void; () =>
}), new Promise((r) => {
), resolveApproveRef.current = r as (v: unknown) => void;
reject: vi.fn(async () => ({})), }),
requestVideo: vi.fn(async () => ({ ),
status: "opened", reject: vi.fn(async () => ({})),
task_id: "v-2", requestVideo: vi.fn(async () => ({
detail: "Video-authoring task opened.", status: "opened",
})), task_id: "v-2",
getMediaBlob: vi.fn( detail: "Video-authoring task opened.",
async () => new Blob(["fake-mp4-bytes"], { type: "video/mp4" }), })),
), getMediaBlob: vi.fn(
}), async () => new Blob(["fake-mp4-bytes"], { type: "video/mp4" }),
); ),
}));
vi.mock("@/lib/api", () => ({ vi.mock("@/lib/api", () => ({
videoApi: { listPosts, approve, reject, requestVideo, getMediaBlob }, videoApi: {
listPosts,
listPipeline,
approve,
reject,
requestVideo,
getMediaBlob,
},
})); }));
import { VideoPostQueue } from "../video-post-queue"; import { VideoPostQueue } from "../video-post-queue";
@@ -61,6 +78,7 @@ function withQueryClient(ui: ReactNode) {
describe("VideoPostQueue", () => { describe("VideoPostQueue", () => {
beforeEach(() => { beforeEach(() => {
listPosts.mockClear(); listPosts.mockClear();
listPipeline.mockClear();
approve.mockClear(); approve.mockClear();
reject.mockClear(); reject.mockClear();
requestVideo.mockClear(); requestVideo.mockClear();
@@ -87,6 +105,46 @@ describe("VideoPostQueue", () => {
expect(screen.getByDisplayValue("New RoboCo drop!")).toBeInTheDocument(); 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 // H15: the 30s refetchInterval produces a new `post` prop, but useState
// initializes once — so a server-side re-draft between the CEO opening the // initializes once — so a server-side re-draft between the CEO opening the
// card and approving would be silently overwritten by the stale initial // 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([]); listPosts.mockResolvedValueOnce([]);
listPipeline.mockResolvedValueOnce([]);
render(withQueryClient(<VideoPostQueue />)); render(withQueryClient(<VideoPostQueue />));
expect(await screen.findByText(/No drafts yet/)).toBeInTheDocument(); expect(await screen.findByText(/No drafts yet/)).toBeInTheDocument();
expect( expect(
screen.getByRole("button", { name: /Request a video/ }), screen.getByRole("button", { name: /Request a video/ }),
).toBeInTheDocument(); ).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; onReject: (post: VideoPost) => void;
approving: boolean; 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 [editX, setEditX] = useState(post.platforms.includes("x"));
const [editTiktok, setEditTiktok] = useState( const [editTiktok, setEditTiktok] = useState(
post.platforms.includes("tiktok"), post.platforms.includes("tiktok"),
@@ -101,7 +105,13 @@ function VideoPostRow({
// A native <video src> GET doesn't carry axios's auth headers, so fetch // 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 // 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. // 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(() => { useEffect(() => {
if (!post.mp4_paths?.[cut]) {
return;
}
let cancelled = false; let cancelled = false;
let objectUrl: string | null = null; let objectUrl: string | null = null;
videoApi videoApi
@@ -118,7 +128,7 @@ function VideoPostRow({
cancelled = true; cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl); 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 xOverLimit = editX && xCaption.length > MAX_X_CAPTION_CHARS;
const tiktokOverLimit = const tiktokOverLimit =
@@ -140,33 +150,52 @@ function VideoPostRow({
{post.occasion && <Badge variant="outline">{post.occasion}</Badge>} {post.occasion && <Badge variant="outline">{post.occasion}</Badge>}
</div> </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="mb-3 space-y-2">
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
type="button" type="button"
size="sm" size="sm"
variant={cut === "vertical" ? "default" : "outline"} 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")} onClick={() => setCut("vertical")}
> >
9:16 9:16{!post.mp4_paths?.vertical && " (missing)"}
</Button> </Button>
<Button <Button
type="button" type="button"
size="sm" size="sm"
variant={cut === "square" ? "default" : "outline"} 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")} onClick={() => setCut("square")}
> >
1:1 1:1{!post.mp4_paths?.square && " (missing)"}
</Button> </Button>
</div> </div>
<video {post.mp4_paths?.[cut] ? (
key={`${post.task_id}-${cut}`} <video
controls key={`${post.task_id}-${cut}`}
className="mx-auto max-h-96 w-full rounded-md bg-black object-contain" controls
src={videoSrc ?? undefined} 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> 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>
<div className="space-y-3"> <div className="space-y-3">
@@ -388,6 +417,14 @@ export function VideoPostQueue({ className }: { className?: string }) {
queryFn: () => videoApi.listPosts(), queryFn: () => videoApi.listPosts(),
refetchInterval: 30000, 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 = () => const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ["video", "posts"] }); queryClient.invalidateQueries({ queryKey: ["video", "posts"] });
@@ -476,9 +513,9 @@ export function VideoPostQueue({ className }: { className?: string }) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No drafts yet. Set your keys in Settings X (Twitter) / TikTok {pipeline && pipeline.length > 0
Credentials and enable the video engine or request one on demand ? `${pipeline.length} video${pipeline.length === 1 ? "" : "s"} in flight — nothing rendered yet. Check the pipeline above for status.`
above. : "No drafts yet. Set your keys in Settings → X (Twitter) / TikTok Credentials and enable the video engine — or request one on demand above."}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
+1
View File
@@ -41,6 +41,7 @@ export { videoApi, videoMediaUrl } from "./video";
export type { export type {
VideoCut, VideoCut,
VideoPost, VideoPost,
VideoPipelineItem,
VideoPostExecuteResult, VideoPostExecuteResult,
VideoPostHistoryEntry, VideoPostHistoryEntry,
VideoRequestResult, VideoRequestResult,
+24
View File
@@ -22,6 +22,24 @@ export interface VideoPost {
tiktok_caption?: string | null; tiktok_caption?: string | null;
reject_reason?: string | null; reject_reason?: string | null;
mp4_paths?: Record<string, string>; 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 { export interface VideoPostExecuteResult {
@@ -44,6 +62,7 @@ export interface VideoPostHistoryEntry {
reject_reason?: string | null; reject_reason?: string | null;
posted: Record<string, string>; // platform -> posted id posted: Record<string, string>; // platform -> posted id
acted_at: string; acted_at: string;
source_task_id?: string | null; // the authoring task this draft rendered from
} }
export interface VideoRequestResult { export interface VideoRequestResult {
@@ -72,6 +91,11 @@ export const videoApi = {
const { data } = await api.get<VideoPost[]>("/video/posts"); const { data } = await api.get<VideoPost[]>("/video/posts");
return data; 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); // Posted or rejected drafts, newest-acted-first. Fixed default limit (50);
// no "load more" — pass a higher limit if the panel ever needs it. // no "load more" — pass a higher limit if the panel ever needs it.
listHistory: async (limit = 50): Promise<VideoPostHistoryEntry[]> => { listHistory: async (limit = 50): Promise<VideoPostHistoryEntry[]> => {
+31
View File
@@ -17,6 +17,7 @@ from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
from roboco.api.schemas.video import ( from roboco.api.schemas.video import (
TikTokCredentialsSetRequest, TikTokCredentialsSetRequest,
TikTokCredentialsStatus, TikTokCredentialsStatus,
VideoPipelineItemResponse,
VideoPostApproveRequest, VideoPostApproveRequest,
VideoPostExecuteResponse, VideoPostExecuteResponse,
VideoPostHistoryResponse, VideoPostHistoryResponse,
@@ -139,6 +140,7 @@ def _to_response(task: TaskTable) -> VideoPostResponse:
tiktok_caption=draft.get("tiktok_caption"), tiktok_caption=draft.get("tiktok_caption"),
reject_reason=markers.get_video_reject_reason(task), reject_reason=markers.get_video_reject_reason(task),
mp4_paths=dict(draft.get("mp4_paths") or {}), mp4_paths=dict(draft.get("mp4_paths") or {}),
source_task_id=draft.get("source_task_id"),
) )
@@ -166,6 +168,34 @@ async def list_video_posts(
return [_to_response(t) for t in tasks] return [_to_response(t) for t in tasks]
def _to_pipeline_item(task: TaskTable) -> VideoPipelineItemResponse:
draft = markers.get_video_draft(task) or {}
return VideoPipelineItemResponse(
task_id=str(task.id),
title=task.title,
occasion=str(draft.get("occasion") or ""),
status=_status_value(task),
pr_number=task.pr_number,
composition_id=draft.get("composition_id"),
render_status=draft.get("render_status"),
render_attempts=int(draft.get("render_attempts", 0)),
render_error=draft.get("render_error"),
)
@router.get("/pipeline", response_model=list[VideoPipelineItemResponse])
async def list_video_pipeline(
db: DbSession, agent: CurrentAgentContext
) -> list[VideoPipelineItemResponse]:
"""Every in-flight source=video authoring task, from claim through the
render loop's retry/failure states — the Social page's pipeline-
visibility strip. A rendered task has already materialized its
video_post draft (visible instead via ``/posts``) and drops out here."""
_require_ceo(agent)
tasks = await get_task_service(db).list_video_pipeline_tasks()
return [_to_pipeline_item(t) for t in tasks]
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": ".."}``)."""
@@ -190,6 +220,7 @@ def _to_history_response(task: TaskTable) -> VideoPostHistoryResponse:
reject_reason=markers.get_video_reject_reason(task), reject_reason=markers.get_video_reject_reason(task),
posted=_posted_ids(draft), posted=_posted_ids(draft),
acted_at=task.updated_at or task.created_at, acted_at=task.updated_at or task.created_at,
source_task_id=draft.get("source_task_id"),
) )
+22
View File
@@ -4,6 +4,7 @@ from datetime import datetime
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from roboco.foundation.policy.content.markers import MAX_VIDEO_RENDER_ATTEMPTS
from roboco.services.video_post_service import MAX_TIKTOK_CAPTION_CHARS from roboco.services.video_post_service import MAX_TIKTOK_CAPTION_CHARS
from roboco.services.x_client import MAX_TWEET_CHARS from roboco.services.x_client import MAX_TWEET_CHARS
@@ -38,6 +39,7 @@ class VideoPostResponse(BaseModel):
tiktok_caption: str | None = None tiktok_caption: str | None = None
reject_reason: str | None = None reject_reason: str | None = None
mp4_paths: dict[str, str] = Field(default_factory=dict) mp4_paths: dict[str, str] = Field(default_factory=dict)
source_task_id: str | None = None # the authoring task this draft rendered from
class VideoPostApproveRequest(BaseModel): class VideoPostApproveRequest(BaseModel):
@@ -79,6 +81,26 @@ class VideoPostHistoryResponse(BaseModel):
reject_reason: str | None = None reject_reason: str | None = None
posted: dict[str, str] = Field(default_factory=dict) # platform -> posted id posted: dict[str, str] = Field(default_factory=dict) # platform -> posted id
acted_at: datetime acted_at: datetime
source_task_id: str | None = None # the authoring task this draft rendered from
class VideoPipelineItemResponse(BaseModel):
"""One in-flight source=video item — the Social page's pipeline-strip
basis. Spans the authoring task's whole pre-post lifecycle: any
non-terminal delivery status, then the render loop's retry/failure
states. composition_id/render_status/render_attempts/render_error live
in the orchestration_markers.video_draft JSON, not a column."""
task_id: str
title: str
occasion: str
status: str
pr_number: int | None = None
composition_id: str | None = None
render_status: str | None = None
render_attempts: int = 0
max_attempts: int = MAX_VIDEO_RENDER_ATTEMPTS
render_error: str | None = None
class TikTokCredentialsStatus(BaseModel): class TikTokCredentialsStatus(BaseModel):
+11 -2
View File
@@ -228,8 +228,17 @@ def set_roadmap_cycle(task: HasMarkers, payload: dict[str, Any]) -> None:
# The video engine's working payload, carried on both the UX/UI authoring task # The video engine's working payload, carried on both the UX/UI authoring task
# (source=video) and the held post draft it later produces (source=video_post): # (source=video) and the held post draft it later produces (source=video_post):
# {occasion, script, composition_id, input_props, mp4_paths, x_caption, # {occasion, script, composition_id, input_props, mp4_paths, x_caption,
# tiktok_caption, platforms, render_status}. Set once at authoring-open time # tiktok_caption, platforms, render_status, render_attempts, render_error,
# and extended (not replaced) once rendering fills in the mp4/caption fields. # source_task_id}. Set once at authoring-open time and extended (not replaced)
# once rendering fills in the mp4/caption fields.
# Bounded retry for the video render loop: a failed render (read-clone not yet
# synced to the just-merged composition, or a transient sidecar blip) retries
# on a later cycle; only after this many attempts is a task marked terminally
# failed, so a genuinely broken composition can't re-render forever. Single
# source of truth for the orchestrator's render loop AND the pipeline-strip
# API — importable from both without the API importing the orchestrator.
MAX_VIDEO_RENDER_ATTEMPTS = 5
def get_video_draft(task: HasMarkers) -> dict[str, Any] | None: def get_video_draft(task: HasMarkers) -> dict[str, Any] | None:
+6
View File
@@ -630,6 +630,12 @@ def propose_video(
"""UX/UI dev: propose your video's composition + captions. Metadata only — """UX/UI dev: propose your video's composition + captions. Metadata only —
this does NOT render (rendering happens later, off this path). this does NOT render (rendering happens later, off this path).
Before authoring, read motion/README.md for the design bar and
motion/kit/README.md for the panel-demo kit — build in the panel-demo
register on motion/kit/ (extend motion/compositions/panel-demo/) rather
than starting from scratch or shipping a text card, unless the occasion
has no product visual to show.
Call this exactly ONCE per authoring task, after building the HyperFrames Call this exactly ONCE per authoring task, after building the HyperFrames
composition in motion/compositions/<id>/. Then commit + open_pr to send composition in motion/compositions/<id>/. Then commit + open_pr to send
it through the normal PR-review gate. it through the normal PR-review gate.
+8 -7
View File
@@ -793,11 +793,10 @@ def _is_non_dev_dispatch_source(task: dict[str, Any]) -> bool:
return task.get("source") in (ROADMAP_SOURCE, X_FEATURE_EXPLORATION_SOURCE) return task.get("source") in (ROADMAP_SOURCE, X_FEATURE_EXPLORATION_SOURCE)
# Bounded retry for the video render loop: a failed render (read-clone not yet # Bounded retry for the video render loop — single source of truth lives in
# synced to the just-merged composition, or a transient sidecar blip) retries on # the policy/markers layer (importable from API code without importing this
# a later cycle; only after this many attempts is a task marked terminally # orchestrator module); mirrored here under the historical name.
# failed, so a genuinely broken composition can't re-render forever. _MAX_VIDEO_RENDER_ATTEMPTS = _markers.MAX_VIDEO_RENDER_ATTEMPTS
_MAX_VIDEO_RENDER_ATTEMPTS = 5
class AgentOrchestrator: class AgentOrchestrator:
@@ -8056,7 +8055,7 @@ Start by:
except Exception as exc: except Exception as exc:
attempts = int(draft.get("render_attempts", 0)) + 1 attempts = int(draft.get("render_attempts", 0)) + 1
terminal = attempts >= _MAX_VIDEO_RENDER_ATTEMPTS terminal = attempts >= _MAX_VIDEO_RENDER_ATTEMPTS
payload = {**draft, "render_attempts": attempts} payload = {**draft, "render_attempts": attempts, "render_error": str(exc)}
if terminal: if terminal:
payload["render_status"] = "failed" payload["render_status"] = "failed"
markers.set_video_draft(task, payload) markers.set_video_draft(task, payload)
@@ -8136,7 +8135,9 @@ Start by:
}, },
platforms=draft.get("platforms") or [], platforms=draft.get("platforms") or [],
) )
markers.set_video_draft(task, {**draft, "render_status": "rendered"}) rendered_payload = {**draft, "render_status": "rendered"}
rendered_payload.pop("render_error", None) # clear any prior-attempt error
markers.set_video_draft(task, rendered_payload)
async def _load_dep_update_set(self, db: Any) -> list[Any]: async def _load_dep_update_set(self, db: Any) -> list[Any]:
"""Projects with a ``dep_update_command`` + a git_url, one per """Projects with a ``dep_update_command`` + a git_url, one per
+23 -28
View File
@@ -1313,11 +1313,14 @@ class ContentActions:
feature hasn't already been covered, then materializes the held X-queue feature hasn't already been covered, then materializes the held X-queue
draft and completes the caller's exploration task. One call per cycle. draft and completes the caller's exploration task. One call per cycle.
``wants_video`` optionally requests a companion video (gated on ``wants_video`` optionally requests a companion video. The video
``video_engine_enabled AND video_on_spotlight``, on top of this authoring task no longer opens here it opens later, at CEO-approve
default-False param) a best-effort side effect that never disturbs time (``XPostService._open_spotlight_video``, gated on
the spotlight draft above. Defaults leave the flow byte-for-byte ``video_engine_enabled AND video_on_spotlight``), so a ux-dev never
unchanged. burns a cycle on a spotlight the CEO then rejects. This verb only
records the request (+ optional script) on the draft's
``x_feature_ref`` marker for approve time to read. Defaults leave the
flow byte-for-byte unchanged.
""" """
role = await self._caller_role(agent_id) role = await self._caller_role(agent_id)
if role not in _FEATURE_SPOTLIGHT_ROLES: if role not in _FEATURE_SPOTLIGHT_ROLES:
@@ -1365,11 +1368,22 @@ class ContentActions:
feature_title=feature_title, feature_title=feature_title,
body=body, body=body,
) )
video_armed = settings.video_engine_enabled and settings.video_on_spotlight if wants_video:
if wants_video and video_armed: # Gating (video_engine_enabled AND video_on_spotlight) and the
await self._open_spotlight_video( # actual open_video_task call happen later, at CEO-approve time —
feature_slug, feature_title, body, video_script # see XPostService._open_spotlight_video. Stash the request here
# so approve time has it; slug/title are already on this ref from
# materialize_feature_spotlight above, so re-set alongside them.
markers.set_x_feature_ref(
new_task,
{
"slug": feature_slug,
"title": feature_title,
"wants_video": True,
"video_script": video_script.strip(),
},
) )
await self.task.session.flush()
return Envelope.ok( return Envelope.ok(
status="feature_spotlight_proposed", status="feature_spotlight_proposed",
task_id=str(new_task.id), task_id=str(new_task.id),
@@ -1380,25 +1394,6 @@ class ContentActions:
}, },
) )
async def _open_spotlight_video(
self, feature_slug: str, feature_title: str, body: str, video_script: str
) -> None:
"""Best-effort: a spotlight video failure must never break the
spotlight draft that already materialized above. HoM decides *what*
(this brief); UX/UI later builds *how* (the composition)."""
try:
from roboco.services.video_engine import get_video_engine
feature_brief = f"{feature_title}: {body}"
await get_video_engine(self.task.session).open_video_task(
occasion=f"spotlight {feature_slug}",
script=video_script.strip() or feature_brief,
platforms=["x", "tiktok"],
brief=feature_brief,
)
except Exception as exc:
logger.warning("spotlight video draft failed (best-effort)", error=str(exc))
@classmethod @classmethod
def _reject_caption( def _reject_caption(
cls, value: str, *, field: str, max_chars: int cls, value: str, *, field: str, max_chars: int
+33
View File
@@ -1482,6 +1482,39 @@ class TaskService(BaseService):
) )
return list(result.scalars().all()) return list(result.scalars().all())
async def list_video_pipeline_tasks(self) -> list[TaskTable]:
"""Every source=VIDEO_SOURCE authoring task still visible in the
Social page's pipeline strip: any non-terminal status, plus
COMPLETED tasks the render loop hasn't finished with (render_status
unset pending/retrying or terminally "failed"). A rendered
COMPLETED task already materialized its video_post draft and drops
out of view. composition_id/render_status/render_attempts live in
the JSON marker, not a column, so that half of the filter runs in
Python on this bounded scan (mirrors list_completed_video_tasks).
Newest-first + bounded, so a large old backlog can't crowd a
currently-active item out of the scanned window; the panel can
re-order for display.
"""
from roboco.config import settings
result = await self.session.execute(
select(TaskTable)
.where(
TaskTable.source == VIDEO_SOURCE,
TaskTable.status != TaskStatus.CANCELLED,
)
.order_by(TaskTable.created_at.desc())
.limit(settings.video_render_scan_limit)
)
tasks = list(result.scalars().all())
return [
t
for t in tasks
if t.status != TaskStatus.COMPLETED
or (markers.get_video_draft(t) or {}).get("render_status")
in (None, "failed")
]
async def list_open_video_post_drafts(self) -> list[TaskTable]: async def list_open_video_post_drafts(self) -> list[TaskTable]:
"""Non-terminal video_post held drafts only (excludes the video """Non-terminal video_post held drafts only (excludes the video
authoring source) the panel-queue basis for VideoPostService, authoring source) the panel-queue basis for VideoPostService,
+95 -10
View File
@@ -19,7 +19,7 @@ while the flag is off.
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING, Any, cast
import httpx import httpx
@@ -28,6 +28,7 @@ from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers from roboco.foundation.policy.content import markers
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType, Team from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType, Team
from roboco.services.base import BaseService from roboco.services.base import BaseService
from roboco.services.company_goals import get_company_goals_service
from roboco.services.project import get_project_service from roboco.services.project import get_project_service
from roboco.services.task import ( from roboco.services.task import (
VIDEO_POST_SOURCE, VIDEO_POST_SOURCE,
@@ -46,11 +47,26 @@ if TYPE_CHECKING:
_AUTHORING_ACCEPTANCE_CRITERIA = [ _AUTHORING_ACCEPTANCE_CRITERIA = [
"Both 9:16 and 1:1 cuts render", "Both 9:16 and 1:1 cuts render",
"Captions within platform limits", "Captions within platform limits",
"Composition follows motion/README.md's design bar and uses the "
"panel-demo kit register where the occasion shows the product",
] ]
_POST_ACCEPTANCE_CRITERIA = ["CEO approves or rejects the draft"] _POST_ACCEPTANCE_CRITERIA = ["CEO approves or rejects the draft"]
_CHAT_TIMEOUT_SECONDS = 60.0 _CHAT_TIMEOUT_SECONDS = 60.0
# The whole CHANGELOG section for a release, not one bullet — capped so a
# pathological entry can't blow up the task description.
_CHANGELOG_BRIEF_CHARS = 4000
# Shared by every open_video_task caller (release/spotlight/on-demand) so the
# authoring dev always lands on the demo kit instead of a text card.
_MOTION_DESIGN_POINTER = (
"Before authoring: read motion/README.md's design bar and motion/kit/"
"README.md. Build in the panel-demo register on motion/kit/ — extend "
"compositions/panel-demo/ rather than starting from scratch or shipping "
"a text card."
)
async def _chat(prompt: str) -> str | None: async def _chat(prompt: str) -> str | None:
"""One local-LLM chat call (OpenAI-compatible); None on a non-success. """One local-LLM chat call (OpenAI-compatible); None on a non-success.
@@ -77,16 +93,24 @@ async def _chat(prompt: str) -> str | None:
return content if isinstance(content, str) else None return content if isinstance(content, str) else None
def _first_changelog_bullet(changelog: str) -> str: def _changelog_highlights(changelog: str) -> list[str]:
"""First CHANGELOG bullet's text, or "" if none — the fallback template's """Every bullet line in a CHANGELOG section, in order — the structured
headline when the local model is unavailable.""" highlights list a composition's props.js can render directly."""
highlights = []
for line in changelog.splitlines(): for line in changelog.splitlines():
stripped = line.strip() stripped = line.strip()
if stripped.startswith(("-", "*")): if stripped.startswith(("-", "*")):
text = stripped.lstrip("-*").strip() text = stripped.lstrip("-*").strip()
if text: if text:
return text highlights.append(text)
return "" return highlights
def _first_changelog_bullet(changelog: str) -> str:
"""First CHANGELOG bullet's text, or "" if none — the fallback template's
headline when the local model is unavailable."""
highlights = _changelog_highlights(changelog)
return highlights[0] if highlights else ""
def _fallback_release_script(version: str, changelog: str) -> str: def _fallback_release_script(version: str, changelog: str) -> str:
@@ -106,6 +130,19 @@ def _release_video_prompt(version: str, changelog: str) -> str:
) )
def _release_video_brief(version: str, changelog: str, highlights: list[str]) -> str:
"""The structured release brief: the (capped) CHANGELOG section for this
version plus its highlights list replaces the old one-liner-as-
description. The LLM script stays a separate ``script`` prop suggestion,
never the whole brief."""
section = changelog[:_CHANGELOG_BRIEF_CHARS].strip() or "(no changelog entry)"
parts = [f"RoboCo v{version} release notes:", section]
if highlights:
bullets = "\n".join(f"- {h}" for h in highlights)
parts.append(f"Highlights:\n{bullets}")
return "\n\n".join(parts)
class VideoEngine(BaseService): class VideoEngine(BaseService):
"""Open video-authoring tasks (event hooks + on-demand), both gated.""" """Open video-authoring tasks (event hooks + on-demand), both gated."""
@@ -161,8 +198,39 @@ class VideoEngine(BaseService):
# ---- authoring task (event hooks + on-demand) -------------------------- # ---- authoring task (event hooks + on-demand) --------------------------
async def _brand_voice_note(self) -> str:
"""CEO-supplied brand-voice sample from the company charter, or ""
when unset. Same ``company_goals.brand_voice`` read x_engine's
``_voice_guide`` uses, duplicated locally per this service's
no-cross-service-internals policy."""
charter = await get_company_goals_service(self.session).get()
return (charter.get("brand_voice") or "").strip()
async def _enrich_brief(self, brief: str) -> str:
"""Append the CEO's brand-voice sample (when set) and the motion
design-bar pointer to any occasion's brief.
The one seam shared by the release, spotlight, and on-demand callers
of ``open_video_task`` each supplies only its own content, and all
three inherit the same enrichment here rather than duplicating it
per caller (on-demand's caller, the ``/video/request`` route, passes
its brief through verbatim, so this is the only place it can land).
"""
parts = [brief]
brand_voice = await self._brand_voice_note()
if brand_voice:
parts.append(f"Brand voice (from the CEO's charter):\n{brand_voice}")
parts.append(_MOTION_DESIGN_POINTER)
return "\n\n".join(parts)
async def open_video_task( async def open_video_task(
self, *, occasion: str, script: str, platforms: list[str], brief: str self,
*,
occasion: str,
script: str,
platforms: list[str],
brief: str,
suggested_input_props: dict[str, Any] | None = None,
) -> TaskTable | None: ) -> TaskTable | None:
"""Originate ONE UX/UI authoring task for a bespoke video, or None. """Originate ONE UX/UI authoring task for a bespoke video, or None.
@@ -173,6 +241,13 @@ class VideoEngine(BaseService):
delivery task (``source=VIDEO_SOURCE``, ``confirmed_by_human=True``) delivery task (``source=VIDEO_SOURCE``, ``confirmed_by_human=True``)
NOT held so it dispatches straight to the assigned ux-dev like any NOT held so it dispatches straight to the assigned ux-dev like any
other pre-assigned code task. other pre-assigned code task.
``brief`` is enriched (brand-voice + motion design-bar pointer
appended) before becoming the task description and the marker's
``brief`` field. ``suggested_input_props`` (e.g. ``{"version": ...,
"highlights": [...]}`` from the release caller) is seeded onto the
marker as-is so the dev copies real structured data into
``propose_video``'s ``input_props`` instead of hand-typing facts.
""" """
if not settings.video_engine_enabled: if not settings.video_engine_enabled:
return None return None
@@ -194,6 +269,7 @@ class VideoEngine(BaseService):
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
assignee = self._select_ux_dev(open_tasks) assignee = self._select_ux_dev(open_tasks)
enriched_brief = await self._enrich_brief(brief)
# Savepoint-isolate the insert: a DBAPI error here (FK, deadlock, # Savepoint-isolate the insert: a DBAPI error here (FK, deadlock,
# dropped connection) must roll back ONLY this insert, never poison the # dropped connection) must roll back ONLY this insert, never poison the
# shared session — whose next commit is the caller's release-publish # shared session — whose next commit is the caller's release-publish
@@ -203,7 +279,7 @@ class VideoEngine(BaseService):
task = await task_svc.create( task = await task_svc.create(
TaskCreateRequest( TaskCreateRequest(
title=f"Video: {occasion}", title=f"Video: {occasion}",
description=brief, description=enriched_brief,
acceptance_criteria=list(_AUTHORING_ACCEPTANCE_CRITERIA), acceptance_criteria=list(_AUTHORING_ACCEPTANCE_CRITERIA),
team=Team.UX_UI, team=Team.UX_UI,
assigned_to=assignee, assigned_to=assignee,
@@ -227,7 +303,8 @@ class VideoEngine(BaseService):
"occasion": occasion, "occasion": occasion,
"script": script, "script": script,
"platforms": platforms, "platforms": platforms,
"brief": brief, "brief": enriched_brief,
"suggested_input_props": dict(suggested_input_props or {}),
}, },
) )
await self.session.flush() await self.session.flush()
@@ -258,15 +335,23 @@ class VideoEngine(BaseService):
Called from ``ReleaseProposalService.approve()``'s publish success Called from ``ReleaseProposalService.approve()``'s publish success
branch, right beside the X-post draft hook never invoked by a loop branch, right beside the X-post draft hook never invoked by a loop
itself. itself.
The brief is the structured changelog block (built independent of
the local model, so it stands even when the model is down); the
LLM-drafted (or template-fallback) one-liner is only the ``script``
prop suggestion, never the whole brief.
""" """
if not (settings.video_engine_enabled and settings.video_on_release): if not (settings.video_engine_enabled and settings.video_on_release):
return None return None
script = await self._draft_release_script(version, changelog) script = await self._draft_release_script(version, changelog)
highlights = _changelog_highlights(changelog)
brief = _release_video_brief(version, changelog, highlights)
return await self.open_video_task( return await self.open_video_task(
occasion=f"release {version}", occasion=f"release {version}",
script=script, script=script,
platforms=["x", "tiktok"], platforms=["x", "tiktok"],
brief=script, brief=brief,
suggested_input_props={"version": version, "highlights": highlights},
) )
async def _draft_release_script(self, version: str, changelog: str) -> str: async def _draft_release_script(self, version: str, changelog: str) -> str:
+45 -1
View File
@@ -9,6 +9,12 @@ second approve is a no-op that returns the already-posted result. Rejecting
records the reason and CANCELS the draft (unlike the release proposal there is records the reason and CANCELS the draft (unlike the release proposal there is
no revision workflow here the CEO edits inline and re-approves, or a fresh no revision workflow here the CEO edits inline and re-approves, or a fresh
draft is originated on the next cycle/release). draft is originated on the next cycle/release).
A successfully-posted ``x_feature`` (spotlight) draft additionally fires
``_open_spotlight_video`` the companion-video hook moved here from
authoring time (``propose_feature_spotlight``) so it only fires once the CEO
has actually approved the spotlight, mirroring the
``ReleaseProposalService.approve`` -> ``_draft_video`` seam.
""" """
from __future__ import annotations from __future__ import annotations
@@ -24,7 +30,7 @@ from roboco.config import settings
from roboco.foundation.policy.content import markers from roboco.foundation.policy.content import markers
from roboco.models.base import TaskStatus from roboco.models.base import TaskStatus
from roboco.services.base import BaseService from roboco.services.base import BaseService
from roboco.services.task import X_SOURCES, get_task_service from roboco.services.task import X_FEATURE_SOURCE, X_SOURCES, get_task_service
from roboco.services.x_client import MAX_TWEET_CHARS, build_x_client from roboco.services.x_client import MAX_TWEET_CHARS, build_x_client
from roboco.services.x_credentials import get_x_credentials_service from roboco.services.x_credentials import get_x_credentials_service
@@ -188,10 +194,48 @@ class XPostService(BaseService):
# release — otherwise a racing approve could acquire the lock the # release — otherwise a racing approve could acquire the lock the
# instant we drop it and double-post before the route-level commit. # instant we drop it and double-post before the route-level commit.
await self.session.commit() await self.session.commit()
if task.source == X_FEATURE_SOURCE:
await self._open_spotlight_video(task, body)
return XPostExecuteResult( return XPostExecuteResult(
status="posted", tweet_id=result.tweet_id, detail=result.detail status="posted", tweet_id=result.tweet_id, detail=result.detail
) )
async def _open_spotlight_video(self, task: TaskTable, posted_body: str) -> None:
"""Mirrors ``ReleaseProposalService._draft_video``: a best-effort side
effect after the post has already succeeded, never allowed to affect
the result above. Moved here from authoring time
(``propose_feature_spotlight``) so a ux-dev never burns a delivery
cycle on a spotlight video for a draft the CEO ends up rejecting
this only fires once the tweet is actually live.
Fires only when the draft's ``x_feature_ref`` marker carries
``wants_video`` (stamped by ``propose_feature_spotlight``) and both
``video_engine_enabled`` and ``video_on_spotlight`` are on.
``open_video_task``'s own occasion-dedup covers a hypothetical repeat
call; the COMPLETED short-circuit in ``approve()`` already prevents
``_post`` (and so this) from running twice for the same draft.
"""
if not (settings.video_engine_enabled and settings.video_on_spotlight):
return
ref = markers.get_x_feature_ref(task) or {}
if not ref.get("wants_video"):
return
feature_slug = str(ref.get("slug") or "")
feature_title = str(ref.get("title") or "")
video_script = str(ref.get("video_script") or "")
try:
from roboco.services.video_engine import get_video_engine
feature_brief = f"{feature_title}: {posted_body}"
await get_video_engine(self.session).open_video_task(
occasion=f"spotlight {feature_slug}",
script=video_script.strip() or feature_brief,
platforms=["x", "tiktok"],
brief=feature_brief,
)
except Exception as exc:
logger.warning("spotlight video draft failed (best-effort): %s", exc)
async def reject(self, task_id: UUID, reason: str) -> TaskTable | None: async def reject(self, task_id: UUID, reason: str) -> TaskTable | None:
"""Record the CEO's reason and cancel the draft (never posted).""" """Record the CEO's reason and cancel the draft (never posted)."""
task = await get_task_service(self.session).get(task_id) task = await get_task_service(self.session).get(task_id)
+183
View File
@@ -45,6 +45,7 @@ SYSTEM_UUID = _foundation.AGENTS["system"].uuid
UX_DEV_1_UUID = _foundation.AGENTS["ux-dev-1"].uuid 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
async def _seed(session: AsyncSession) -> None: async def _seed(session: AsyncSession) -> None:
@@ -168,6 +169,55 @@ async def _seed_draft(
return task return task
async def _seed_authoring_task(
session: AsyncSession,
*,
status: TaskStatus = TaskStatus.IN_PROGRESS,
draft_extra: dict[str, object] | None = None,
pr_number: int | None = None,
) -> TaskTable:
"""A ``source=video`` UX/UI authoring task — the pipeline route's basis.
Mirrors ``_seed_draft`` but for the pre-render authoring stage."""
system = await _seed_agent(session, AgentRole.SYSTEM, "system")
ux_dev = await _seed_agent(session, AgentRole.DEVELOPER, "ux-dev")
project = ProjectTable(
id=uuid4(),
name="RoboCo",
slug=f"roboco-{uuid4().hex[:6]}",
git_url="https://example.com/roboco.git",
assigned_cell=Team.UX_UI,
created_by=system.id,
)
session.add(project)
await session.flush()
task = TaskTable(
id=uuid4(),
title="Video: launch teaser",
description="A short teaser for the launch",
acceptance_criteria=["dev builds the composition"],
status=status,
priority=2,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
estimated_complexity=Complexity.LOW,
project_id=project.id,
created_by=system.id,
assigned_to=ux_dev.id,
team=Team.UX_UI,
source=VIDEO_SOURCE,
confirmed_by_human=True,
pr_number=pr_number,
)
session.add(task)
await session.flush()
markers.set_video_draft(
task,
{"occasion": "launch teaser", "script": "script", **(draft_extra or {})},
)
await session.flush()
return task
def _build_app( def _build_app(
db_session: AsyncSession | None, role: AgentRole, agent_id: UUID db_session: AsyncSession | None, role: AgentRole, agent_id: UUID
) -> FastAPI: ) -> FastAPI:
@@ -304,6 +354,139 @@ async def test_list_posts_returns_open_draft(
} }
@pytest.mark.asyncio
async def test_list_posts_includes_source_task_id(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
"""source_task_id round-trips from the marker to the response — the
panel's basis for a future draft->authoring-task deep link."""
source_task_id = uuid4()
task = await _seed_draft(db_session)
draft = markers.get_video_draft(task) or {}
markers.set_video_draft(task, {**draft, "source_task_id": str(source_task_id)})
await db_session.flush()
resp = await ceo_client.get("/api/video/posts")
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert body[0]["source_task_id"] == str(source_task_id)
@pytest.mark.asyncio
async def test_history_includes_source_task_id(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
source_task_id = uuid4()
task = await _seed_draft(db_session)
draft = markers.get_video_draft(task) or {}
markers.set_video_draft(task, {**draft, "source_task_id": str(source_task_id)})
await db_session.flush()
with _LOCKED[0], _LOCKED[1]:
await ceo_client.post(
f"/api/video/posts/{task.id}/reject", json={"reason": "off-brand"}
)
resp = await ceo_client.get("/api/video/posts/history")
body = resp.json()
row = next(r for r in body if r["task_id"] == str(task.id))
assert row["source_task_id"] == str(source_task_id)
# --- pipeline strip (task 1, 2026-07-09) --------------------------------------
@pytest.mark.asyncio
async def test_pipeline_lists_non_terminal_authoring_task(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_authoring_task(db_session, status=TaskStatus.IN_PROGRESS)
resp = await ceo_client.get("/api/video/pipeline")
assert resp.status_code == HTTPStatus.OK
body = resp.json()
row = next(r for r in body if r["task_id"] == str(task.id))
assert row["status"] == "in_progress"
assert row["occasion"] == "launch teaser"
assert row["render_status"] is None
assert row["render_attempts"] == 0
assert row["max_attempts"] == markers.MAX_VIDEO_RENDER_ATTEMPTS
assert row["render_error"] is None
@pytest.mark.asyncio
async def test_pipeline_shows_completed_unrendered_with_attempts(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
"""A COMPLETED authoring task the render loop hasn't finished with
(render_status unset) stays visible with its retry count."""
task = await _seed_authoring_task(
db_session,
status=TaskStatus.COMPLETED,
draft_extra={"composition_id": "Intro", "render_attempts": RETRY_ATTEMPTS},
)
resp = await ceo_client.get("/api/video/pipeline")
body = resp.json()
row = next(r for r in body if r["task_id"] == str(task.id))
assert row["render_attempts"] == RETRY_ATTEMPTS
assert row["render_status"] is None
assert row["composition_id"] == "Intro"
@pytest.mark.asyncio
async def test_pipeline_shows_failed_render_with_error(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_authoring_task(
db_session,
status=TaskStatus.COMPLETED,
draft_extra={
"composition_id": "Intro",
"render_status": "failed",
"render_attempts": markers.MAX_VIDEO_RENDER_ATTEMPTS,
"render_error": "sidecar timeout",
},
)
resp = await ceo_client.get("/api/video/pipeline")
body = resp.json()
row = next(r for r in body if r["task_id"] == str(task.id))
assert row["render_status"] == "failed"
assert row["render_attempts"] == markers.MAX_VIDEO_RENDER_ATTEMPTS
assert row["render_error"] == "sidecar timeout"
@pytest.mark.asyncio
async def test_pipeline_excludes_rendered_completed_task(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
"""A rendered task already materialized its video_post draft — it must
not double-appear in the pipeline strip."""
task = await _seed_authoring_task(
db_session,
status=TaskStatus.COMPLETED,
draft_extra={"composition_id": "Intro", "render_status": "rendered"},
)
resp = await ceo_client.get("/api/video/pipeline")
ids = [row["task_id"] for row in resp.json()]
assert str(task.id) not in ids
@pytest.mark.asyncio
async def test_pipeline_excludes_cancelled_task(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_authoring_task(db_session, status=TaskStatus.CANCELLED)
resp = await ceo_client.get("/api/video/pipeline")
ids = [row["task_id"] for row in resp.json()]
assert str(task.id) not in ids
@pytest.mark.asyncio
async def test_pipeline_non_ceo_is_forbidden(db_session: AsyncSession) -> None:
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("/api/video/pipeline")
assert resp.status_code == HTTPStatus.FORBIDDEN
app.dependency_overrides.clear()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_media_returns_the_rendered_cut( async def test_media_returns_the_rendered_cut(
db_session: AsyncSession, db_session: AsyncSession,
@@ -9,6 +9,7 @@ from uuid import uuid4
import pytest import pytest
from roboco.config import settings as cfg from roboco.config import settings as cfg
from roboco.foundation.policy.content import markers
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
@@ -181,13 +182,20 @@ async def test_propose_feature_spotlight_materializes_new_draft_task(
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# wants_video companion — additive, default-False, best-effort # wants_video companion — additive, default-False. Task 4 (2026-07-09 pipeline
# fixes) moved the actual video-authoring open OFF authoring time and onto
# XPostService.approve (see test_x_post_service.py), so this verb only stamps
# the request onto the draft's x_feature_ref marker and never touches the
# video engine itself, regardless of wants_video or the video flags.
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def _mock_spotlight_materialization(monkeypatch: pytest.MonkeyPatch) -> Any: def _mock_spotlight_materialization(
monkeypatch: pytest.MonkeyPatch,
) -> tuple[Any, Any]:
"""Wire an open exploration + a materializing XEngine, mirroring the happy """Wire an open exploration + a materializing XEngine, mirroring the happy
path above, so wants_video tests only need to stub the video engine.""" path above, so wants_video tests only need to inspect the returned draft's
marker (or stub the video engine to prove it's never called)."""
agent_id = uuid4() agent_id = uuid4()
exploration = _FakeTask(assigned_to=agent_id) exploration = _FakeTask(assigned_to=agent_id)
task_svc = MagicMock() task_svc = MagicMock()
@@ -199,57 +207,51 @@ def _mock_spotlight_materialization(monkeypatch: pytest.MonkeyPatch) -> Any:
x_engine.is_feature_seen = AsyncMock(return_value=False) x_engine.is_feature_seen = AsyncMock(return_value=False)
x_engine.materialize_feature_spotlight = AsyncMock(return_value=materialized) x_engine.materialize_feature_spotlight = AsyncMock(return_value=materialized)
monkeypatch.setattr("roboco.services.x_engine.get_x_engine", lambda _s: x_engine) monkeypatch.setattr("roboco.services.x_engine.get_x_engine", lambda _s: x_engine)
return agent_id return agent_id, materialized
def _mock_video_engine(monkeypatch: pytest.MonkeyPatch) -> MagicMock: def _actions_with_flushable_session(role: str) -> ContentActions:
"""``_actions`` with ``task.session.flush`` made awaitable — needed once
``wants_video`` triggers the marker-write flush in
``propose_feature_spotlight`` (mirrors the same pattern in
test_content_actions_roadmap.py)."""
actions = _actions(role)
actions.task.session.flush = AsyncMock()
return actions
@pytest.mark.asyncio
async def test_propose_feature_spotlight_never_opens_video_task(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Even with both video flags on and wants_video=True, this verb never
opens a video-authoring task that now happens at CEO-approve time."""
monkeypatch.setattr(cfg, "video_engine_enabled", True)
monkeypatch.setattr(cfg, "video_on_spotlight", True)
agent_id, _materialized = _mock_spotlight_materialization(monkeypatch)
video_engine = MagicMock() video_engine = MagicMock()
video_engine.open_video_task = AsyncMock(return_value=MagicMock()) video_engine.open_video_task = AsyncMock(return_value=MagicMock())
monkeypatch.setattr( monkeypatch.setattr(
"roboco.services.video_engine.get_video_engine", lambda _s: video_engine "roboco.services.video_engine.get_video_engine", lambda _s: video_engine
) )
return video_engine
env = await _actions_with_flushable_session(
def _enable_video(monkeypatch: pytest.MonkeyPatch) -> None: "head_marketing"
monkeypatch.setattr(cfg, "video_engine_enabled", True) ).propose_feature_spotlight(agent_id=agent_id, **_valid_kwargs(), wants_video=True)
monkeypatch.setattr(cfg, "video_on_spotlight", True)
@pytest.mark.asyncio
async def test_propose_feature_spotlight_wants_video_opens_video_task(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_enable_video(monkeypatch)
agent_id = _mock_spotlight_materialization(monkeypatch)
video_engine = _mock_video_engine(monkeypatch)
env = await _actions("head_marketing").propose_feature_spotlight(
agent_id=agent_id, **_valid_kwargs(), wants_video=True
)
assert env.error is None assert env.error is None
video_engine.open_video_task.assert_awaited_once() video_engine.open_video_task.assert_not_called()
kwargs = video_engine.open_video_task.call_args.kwargs
assert kwargs["occasion"] == "spotlight org-memory"
assert kwargs["platforms"] == ["x", "tiktok"]
expected_brief = (
"Organizational Memory Loop: Did you know RoboCo agents learn from "
"every completed task?"
)
assert kwargs["brief"] == expected_brief
assert kwargs["script"] == expected_brief # falls back — no video_script given
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_propose_feature_spotlight_wants_video_uses_explicit_script( async def test_propose_feature_spotlight_wants_video_stamps_marker(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
_enable_video(monkeypatch) agent_id, materialized = _mock_spotlight_materialization(monkeypatch)
agent_id = _mock_spotlight_materialization(monkeypatch)
video_engine = _mock_video_engine(monkeypatch)
env = await _actions("head_marketing").propose_feature_spotlight( env = await _actions_with_flushable_session(
"head_marketing"
).propose_feature_spotlight(
agent_id=agent_id, agent_id=agent_id,
**_valid_kwargs(), **_valid_kwargs(),
wants_video=True, wants_video=True,
@@ -257,63 +259,44 @@ async def test_propose_feature_spotlight_wants_video_uses_explicit_script(
) )
assert env.error is None assert env.error is None
kwargs = video_engine.open_video_task.call_args.kwargs ref = markers.get_x_feature_ref(materialized)
assert kwargs["script"] == "Custom voiceover script" assert ref is not None
assert kwargs["brief"] != "Custom voiceover script" # brief is always title:body assert ref["slug"] == "org-memory"
assert ref["title"] == "Organizational Memory Loop"
assert ref["wants_video"] is True
assert ref["video_script"] == "Custom voiceover script"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_propose_feature_spotlight_default_wants_video_false_skips_video( async def test_propose_feature_spotlight_wants_video_without_script_stores_empty(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""Default False -> byte-for-byte unchanged spotlight behavior: the video """No explicit script -> stored as "" (the fallback-to-brief logic lives
engine is never even looked up.""" in XPostService._open_spotlight_video at approve time, not here)."""
_enable_video(monkeypatch) agent_id, materialized = _mock_spotlight_materialization(monkeypatch)
agent_id = _mock_spotlight_materialization(monkeypatch)
video_engine = _mock_video_engine(monkeypatch)
env = await _actions("head_marketing").propose_feature_spotlight( env = await _actions_with_flushable_session(
agent_id=agent_id, **_valid_kwargs() "head_marketing"
) ).propose_feature_spotlight(agent_id=agent_id, **_valid_kwargs(), wants_video=True)
assert env.error is None assert env.error is None
assert env.status == "feature_spotlight_proposed" ref = markers.get_x_feature_ref(materialized)
video_engine.open_video_task.assert_not_called() assert ref is not None
assert ref["video_script"] == ""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_propose_feature_spotlight_wants_video_but_flags_off_skips_video( async def test_propose_feature_spotlight_default_wants_video_false_leaves_marker(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
monkeypatch.setattr(cfg, "video_engine_enabled", False) """Default False -> byte-for-byte unchanged: this verb never re-touches
monkeypatch.setattr(cfg, "video_on_spotlight", False) the x_feature_ref marker at all."""
agent_id = _mock_spotlight_materialization(monkeypatch) agent_id, materialized = _mock_spotlight_materialization(monkeypatch)
video_engine = _mock_video_engine(monkeypatch)
env = await _actions("head_marketing").propose_feature_spotlight( env = await _actions_with_flushable_session(
agent_id=agent_id, **_valid_kwargs(), wants_video=True "head_marketing"
) ).propose_feature_spotlight(agent_id=agent_id, **_valid_kwargs())
assert env.error is None
video_engine.open_video_task.assert_not_called()
@pytest.mark.asyncio
async def test_propose_feature_spotlight_video_failure_does_not_break_spotlight(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Best-effort: a video-engine blow-up must not surface as an error on the
spotlight verb the spotlight draft already materialized."""
_enable_video(monkeypatch)
agent_id = _mock_spotlight_materialization(monkeypatch)
monkeypatch.setattr(
"roboco.services.video_engine.get_video_engine",
MagicMock(side_effect=RuntimeError("video-engine boom")),
)
env = await _actions("head_marketing").propose_feature_spotlight(
agent_id=agent_id, **_valid_kwargs(), wants_video=True
)
assert env.error is None assert env.error is None
assert env.status == "feature_spotlight_proposed" assert env.status == "feature_spotlight_proposed"
assert markers.get_x_feature_ref(materialized) is None
+53
View File
@@ -287,6 +287,59 @@ async def test_list_awaiting_main_pm_all_returns_root_tasks() -> None:
assert out == roots assert out == roots
# ---------------------------------------------------------------------------
# list_video_pipeline_tasks — pipeline-strip basis (task 1, 2026-07-09)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_video_pipeline_tasks_keeps_non_terminal_and_unrendered() -> None:
"""The query itself only excludes CANCELLED at the SQL level; the
COMPLETED-but-rendered drop happens in Python off the marker, so this
exercises that filter directly against a mixed mocked result set."""
in_progress = _build_task(status=TaskStatus.IN_PROGRESS, orchestration_markers=None)
awaiting_ceo = _build_task(
status=TaskStatus.AWAITING_CEO_APPROVAL, orchestration_markers=None
)
rendered = _build_task(
status=TaskStatus.COMPLETED,
orchestration_markers={"video_draft": {"render_status": "rendered"}},
)
pending_render = _build_task(
status=TaskStatus.COMPLETED,
orchestration_markers={"video_draft": {"render_attempts": 2}},
)
failed_render = _build_task(
status=TaskStatus.COMPLETED,
orchestration_markers={
"video_draft": {"render_status": "failed", "render_error": "boom"}
},
)
scalars = MagicMock()
scalars.all.return_value = [
in_progress,
awaiting_ceo,
rendered,
pending_render,
failed_render,
]
result = MagicMock()
result.scalars.return_value = scalars
svc = _service_with(result)
out = await svc.list_video_pipeline_tasks()
assert out == [in_progress, awaiting_ceo, pending_render, failed_render]
@pytest.mark.asyncio
async def test_list_video_pipeline_tasks_empty_when_nothing_in_flight() -> None:
scalars = MagicMock()
scalars.all.return_value = []
result = MagicMock()
result.scalars.return_value = scalars
svc = _service_with(result)
assert await svc.list_video_pipeline_tasks() == []
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# all_subtasks_terminal # all_subtasks_terminal
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+166 -2
View File
@@ -19,6 +19,7 @@ from roboco.foundation.policy.content import markers
from roboco.models.base import AgentRole, AgentStatus, Complexity, Team from roboco.models.base import AgentRole, AgentStatus, Complexity, Team
from roboco.models.base import TaskStatus as TS from roboco.models.base import TaskStatus as TS
from roboco.services import video_engine as video_engine_module from roboco.services import video_engine as video_engine_module
from roboco.services.company_goals import get_company_goals_service
from roboco.services.task import VIDEO_POST_SOURCE, VIDEO_SOURCE, get_task_service from roboco.services.task import VIDEO_POST_SOURCE, VIDEO_SOURCE, get_task_service
from sqlalchemy import delete, select from sqlalchemy import delete, select
@@ -32,6 +33,7 @@ UX_DEV_2_UUID = _foundation.AGENTS["ux-dev-2"].uuid
SLUG = "roboco" SLUG = "roboco"
ONE = 1 ONE = 1
TWO = 2 TWO = 2
THREE = 3
async def _seed(session: AsyncSession) -> None: async def _seed(session: AsyncSession) -> None:
@@ -134,6 +136,10 @@ async def test_open_video_task_creates_assigned_authoring_task(
# would auto-block for subtasks it never owns and deadlock. # would auto-block for subtasks it never owns and deadlock.
assert task.estimated_complexity == Complexity.LOW assert task.estimated_complexity == Complexity.LOW
assert task.acceptance_criteria # non-empty assert task.acceptance_criteria # non-empty
# Third AC line: composition follows the design bar / demo-kit register.
assert len(task.acceptance_criteria) == THREE
assert "motion/README.md" in task.acceptance_criteria[2]
assert "panel-demo" in task.acceptance_criteria[2]
project = await db_session.get(ProjectTable, task.project_id) project = await db_session.get(ProjectTable, task.project_id)
assert project is not None assert project is not None
assert project.slug == SLUG assert project.slug == SLUG
@@ -142,7 +148,14 @@ async def test_open_video_task_creates_assigned_authoring_task(
assert draft["occasion"] == "release v1.0.0" assert draft["occasion"] == "release v1.0.0"
assert draft["script"] == "Here's what shipped" assert draft["script"] == "Here's what shipped"
assert draft["platforms"] == ["x", "tiktok"] assert draft["platforms"] == ["x", "tiktok"]
assert draft["brief"] == "Announce the release" # brief is enriched: raw content + motion design-bar pointer appended.
assert draft["brief"].startswith("Announce the release")
assert "motion/README.md" in draft["brief"]
assert "motion/kit/README.md" in draft["brief"]
assert "compositions/panel-demo/" in draft["brief"]
assert "Brand voice" not in draft["brief"] # unset -> omitted
assert draft["suggested_input_props"] == {} # none supplied
assert task.description == draft["brief"]
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -433,7 +446,16 @@ async def test_draft_release_video_opens_authoring_task(
assert draft["occasion"] == "release 1.0.0" assert draft["occasion"] == "release 1.0.0"
assert draft["platforms"] == ["x", "tiktok"] assert draft["platforms"] == ["x", "tiktok"]
assert draft["script"] == "RoboCo v1.0.0 just shipped a huge release." assert draft["script"] == "RoboCo v1.0.0 just shipped a huge release."
assert draft["brief"] == draft["script"] # brief is now the structured changelog block, not the LLM one-liner —
# the whole CHANGELOG section (not one bullet) plus a highlights list.
assert draft["brief"] != draft["script"]
assert _CHANGELOG.strip() in draft["brief"]
assert "a huge new release" in draft["brief"]
assert "motion/README.md" in draft["brief"]
assert draft["suggested_input_props"] == {
"version": "1.0.0",
"highlights": ["a huge new release"],
}
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -451,6 +473,10 @@ async def test_draft_release_video_falls_back_to_template_on_local_model_failure
assert draft is not None assert draft is not None
assert "1.0.0" in draft["script"] assert "1.0.0" in draft["script"]
assert "a huge new release" in draft["script"] assert "a huge new release" in draft["script"]
# The structured brief is built independent of the local model, so a
# model outage still produces the full changelog-derived brief.
assert _CHANGELOG.strip() in draft["brief"]
assert draft["suggested_input_props"]["highlights"] == ["a huge new release"]
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -466,6 +492,144 @@ async def test_draft_release_video_falls_back_to_template_on_empty_local_reply(
draft = markers.get_video_draft(task) draft = markers.get_video_draft(task)
assert draft is not None assert draft is not None
assert draft["script"] == "RoboCo v2.0.0 just shipped: no bullets." assert draft["script"] == "RoboCo v2.0.0 just shipped: no bullets."
assert "- no bullets" in draft["brief"]
assert draft["suggested_input_props"] == {
"version": "2.0.0",
"highlights": ["no bullets"],
}
@pytest.mark.asyncio
async def test_draft_release_video_brief_contains_brand_voice_when_set(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch, video_on_release=True)
await get_company_goals_service(db_session).upsert(
{"brand_voice": "Dry wit, never an exclamation point."}
)
_mock_local_model(monkeypatch, "shipped!")
engine = video_engine_module.VideoEngine(db_session)
task = await engine.draft_release_video(version="1.0.0", changelog=_CHANGELOG)
assert task is not None
draft = markers.get_video_draft(task)
assert draft is not None
assert "Dry wit, never an exclamation point." in draft["brief"]
# --------------------------------------------------------------------------- #
# brief enrichment shared by every occasion — brand voice + motion pointer
# (spec Task 2: "Feed the video brief")
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_open_video_task_brief_carries_motion_pointer_no_brand_voice_by_default(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Simulates the on-demand (`POST /video/request`) caller, which passes
its brief through verbatim enrichment must land centrally here since
that route can't append it itself."""
await _seed(db_session)
_enable(monkeypatch)
engine = video_engine_module.VideoEngine(db_session)
task = await engine.open_video_task(
occasion="on-demand demo",
script="script",
platforms=["x"],
brief="CEO's on-demand brief, verbatim.",
)
assert task is not None
draft = markers.get_video_draft(task)
assert draft is not None
assert draft["brief"].startswith("CEO's on-demand brief, verbatim.")
assert "motion/README.md" in draft["brief"]
assert "motion/kit/README.md" in draft["brief"]
assert "compositions/panel-demo/" in draft["brief"]
assert "Brand voice" not in draft["brief"] # unset -> omitted
assert task.description == draft["brief"]
@pytest.mark.asyncio
async def test_open_video_task_brief_includes_brand_voice_when_set(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
await get_company_goals_service(db_session).upsert(
{"brand_voice": "Dry wit, never an exclamation point."}
)
engine = video_engine_module.VideoEngine(db_session)
task = await engine.open_video_task(
occasion="on-demand demo 2",
script="script",
platforms=["x"],
brief="CEO's on-demand brief.",
)
assert task is not None
draft = markers.get_video_draft(task)
assert draft is not None
assert "Dry wit, never an exclamation point." in draft["brief"]
@pytest.mark.asyncio
async def test_open_video_task_does_not_compress_spotlight_brief(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Mirrors content_actions._open_spotlight_video's `f"{title}: {body}"`
shape the spotlight body is already rich; enrichment only appends,
never truncates or rewrites the original content."""
await _seed(db_session)
_enable(monkeypatch)
engine = video_engine_module.VideoEngine(db_session)
feature_brief = (
"Organizational Memory Loop: Did you know RoboCo agents learn from "
"every completed task? A local model distills one high-signal lesson "
"per task, retrieved back into future briefings."
)
task = await engine.open_video_task(
occasion="spotlight org-memory",
script=feature_brief,
platforms=["x", "tiktok"],
brief=feature_brief,
)
assert task is not None
draft = markers.get_video_draft(task)
assert draft is not None
assert draft["brief"].startswith(feature_brief) # not truncated/compressed
assert "motion/README.md" in draft["brief"]
@pytest.mark.asyncio
async def test_open_video_task_no_suggested_input_props_defaults_empty(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
engine = video_engine_module.VideoEngine(db_session)
task = await engine.open_video_task(
occasion="spotlight x", script="s", platforms=["x"], brief="b"
)
assert task is not None
draft = markers.get_video_draft(task)
assert draft is not None
assert draft["suggested_input_props"] == {}
@pytest.mark.asyncio
async def test_open_video_task_acceptance_criteria_has_design_bar_line(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
engine = video_engine_module.VideoEngine(db_session)
task = await engine.open_video_task(
occasion="release v9.9.9", script="s", platforms=["x"], brief="b"
)
assert task is not None
assert len(task.acceptance_criteria) == THREE
assert "motion/README.md" in task.acceptance_criteria[2]
assert "panel-demo" in task.acceptance_criteria[2]
@pytest.mark.asyncio @pytest.mark.asyncio
+256 -2
View File
@@ -12,6 +12,7 @@ from unittest.mock import AsyncMock, patch
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
from roboco.config import settings as cfg
from roboco.db.tables import AgentTable, ProjectTable, TaskTable from roboco.db.tables import AgentTable, ProjectTable, TaskTable
from roboco.foundation import identity as _foundation from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers from roboco.foundation.policy.content import markers
@@ -126,6 +127,40 @@ async def _seed_draft(
return task return task
_FEATURE_SLUG = "org-memory"
_FEATURE_TITLE = "Organizational Memory Loop"
async def _seed_feature_draft(
session: AsyncSession,
*,
wants_video: bool = True,
video_script: str = "",
body: str = "Draft body",
) -> TaskTable:
"""An X_FEATURE_SOURCE draft carrying the x_feature_ref marker
``propose_feature_spotlight`` stamps (Task 4, 2026-07-09 pipeline fixes):
slug/title always, plus wants_video/video_script when a companion video
was requested at authoring time."""
task = await _seed_draft(session, source=X_FEATURE_SOURCE, body=body)
markers.set_x_feature_ref(
task,
{
"slug": _FEATURE_SLUG,
"title": _FEATURE_TITLE,
"wants_video": wants_video,
"video_script": video_script,
},
)
await session.flush()
return task
def _enable_video(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(cfg, "video_engine_enabled", True)
monkeypatch.setattr(cfg, "video_on_spotlight", True)
def _svc(session: AsyncSession) -> XPostService: def _svc(session: AsyncSession) -> XPostService:
return get_x_post_service(session) return get_x_post_service(session)
@@ -324,8 +359,9 @@ async def test_list_open_posts_excludes_terminal(db_session: AsyncSession) -> No
async def test_approve_posts_feature_spotlight_draft( async def test_approve_posts_feature_spotlight_draft(
db_session: AsyncSession, db_session: AsyncSession,
) -> None: ) -> None:
"""The feature-spotlight source needs zero service changes: it rides the """The feature-spotlight source rides the same generic post path as
same generic approve path as x_post/x_reply.""" x_post/x_reply; it only branches for the best-effort video hook below
(a no-op here since this draft carries no x_feature_ref marker)."""
task = await _seed_draft(db_session, source=X_FEATURE_SOURCE) task = await _seed_draft(db_session, source=X_FEATURE_SOURCE)
client = _StubClient() client = _StubClient()
with ( with (
@@ -463,3 +499,221 @@ async def test_approve_does_not_flush_edited_body_before_lock(
assert result.status == "already_posted" assert result.status == "already_posted"
await db_session.refresh(task) await db_session.refresh(task)
assert markers.get_x_draft_body(task) == original_body assert markers.get_x_draft_body(task) == original_body
# --------------------------------------------------------------------------- #
# Spotlight video hook (Task 4, 2026-07-09 pipeline fixes): moved from
# authoring time (propose_feature_spotlight) to this posted-success branch so
# a ux-dev never burns a cycle on a spotlight the CEO then rejects.
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_approve_feature_spotlight_with_video_opens_video_task(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
_enable_video(monkeypatch)
task = await _seed_feature_draft(db_session, video_script="Custom voiceover script")
client = _StubClient()
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
video_engine.open_video_task.assert_awaited_once()
kwargs = video_engine.open_video_task.call_args.kwargs
assert kwargs["occasion"] == "spotlight org-memory"
assert kwargs["platforms"] == ["x", "tiktok"]
assert kwargs["script"] == "Custom voiceover script"
assert kwargs["brief"] == "Organizational Memory Loop: Draft body"
@pytest.mark.asyncio
async def test_approve_feature_spotlight_video_falls_back_to_brief_script(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""No explicit video_script -> script falls back to the brief, mirroring
the fallback the authoring-time hook used to do."""
_enable_video(monkeypatch)
task = await _seed_feature_draft(db_session)
client = _StubClient()
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
kwargs = video_engine.open_video_task.call_args.kwargs
expected_brief = "Organizational Memory Loop: Draft body"
assert kwargs["script"] == expected_brief
assert kwargs["brief"] == expected_brief
@pytest.mark.asyncio
async def test_approve_feature_spotlight_reapprove_does_not_reopen_video(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Idempotent re-approve: the second call short-circuits on the already-
COMPLETED check before ever reaching _post/_open_spotlight_video again."""
_enable_video(monkeypatch)
task = await _seed_feature_draft(db_session)
client = _StubClient()
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
),
):
svc = _svc(db_session)
first = await svc.approve(_id(task))
second = await svc.approve(_id(task))
assert first is not None
assert first.status == "posted"
assert second is not None
assert second.status == "already_posted"
video_engine.open_video_task.assert_awaited_once()
@pytest.mark.asyncio
async def test_approve_plain_x_post_never_opens_video(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A plain x_post draft carries no x_feature_ref, so the source check
alone keeps the video hook from ever firing for it."""
_enable_video(monkeypatch)
task = await _seed_draft(db_session, source=X_POST_SOURCE)
client = _StubClient()
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
video_engine.open_video_task.assert_not_called()
@pytest.mark.asyncio
async def test_reject_feature_spotlight_with_wants_video_opens_none(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Rejecting a spotlight draft never posts, so the video hook (which only
fires from the posted-success branch of _post) never runs either."""
_enable_video(monkeypatch)
task = await _seed_feature_draft(db_session)
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
):
updated = await _svc(db_session).reject(_id(task), "not on-brand")
assert updated is not None
assert updated.status == TS.CANCELLED
video_engine.open_video_task.assert_not_called()
@pytest.mark.asyncio
async def test_approve_feature_spotlight_video_flags_off_skips(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(cfg, "video_engine_enabled", False)
monkeypatch.setattr(cfg, "video_on_spotlight", False)
task = await _seed_feature_draft(db_session)
client = _StubClient()
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
video_engine.open_video_task.assert_not_called()
@pytest.mark.asyncio
async def test_approve_feature_spotlight_without_wants_video_skips(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Flags on but the draft's author didn't request a video (wants_video
absent/False on the marker) -> no video task, distinct from the
flags-off case above."""
_enable_video(monkeypatch)
task = await _seed_feature_draft(db_session, wants_video=False)
client = _StubClient()
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
video_engine.open_video_task.assert_not_called()
@pytest.mark.asyncio
async def test_approve_feature_spotlight_video_failure_does_not_break_post(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Best-effort: a video-engine blow-up must not affect the already-
succeeded post."""
_enable_video(monkeypatch)
task = await _seed_feature_draft(db_session)
client = _StubClient()
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
side_effect=RuntimeError("video-engine boom"),
),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
await db_session.refresh(task)
assert task.status == TS.COMPLETED
+2 -1
View File
@@ -9,7 +9,8 @@
"node": ">=22.13" "node": ">=22.13"
}, },
"scripts": { "scripts": {
"start": "node server.js" "start": "node server.js",
"test": "node --test"
}, },
"dependencies": { "dependencies": {
"@hyperframes/engine": "^0.7.36", "@hyperframes/engine": "^0.7.36",
+28 -3
View File
@@ -9,18 +9,37 @@
// streamed the response. // streamed the response.
import { createRenderJob, executeRenderJob } from "@hyperframes/producer"; import { createRenderJob, executeRenderJob } from "@hyperframes/producer";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { cp, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; import { cp, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import * as tar from "tar"; import * as tar from "tar";
const FPS = 30;
// @hyperframes/producer reads each cut's dimensions from the composition HTML // @hyperframes/producer reads each cut's dimensions from the composition HTML
// itself (data-width/data-height on the stage), so the sidecar no longer // itself (data-width/data-height on the stage), so the sidecar no longer
// passes width/height — it only picks the quality tier. // passes width/height — it only picks the quality tier.
const QUALITY = "high"; const QUALITY = "high";
const DEFAULT_FPS = 30;
const MIN_FPS = 24;
const MAX_FPS = 60;
/**
* Read the composition-declared frame rate off its HTML text (the
* `data-fps` attribute motion/README.md instructs authors to set it
* appears on both `<html>` and `#stage` in every composition seen so far;
* a plain regex over the whole file catches either). Falls back to
* DEFAULT_FPS when absent, unparsable, or outside the sane broadcast bound
* never lets a bad attribute wedge the job at an undefined rate.
*/
export function parseFps(html) {
const match = /data-fps=["'](\d+)["']/.exec(html);
if (!match) return DEFAULT_FPS;
const fps = Number(match[1]);
if (!Number.isFinite(fps) || fps < MIN_FPS || fps > MAX_FPS) return DEFAULT_FPS;
return fps;
}
// Caps the DECOMPRESSED size (a gzip bomb inflates a tiny upload into a huge // Caps the DECOMPRESSED size (a gzip bomb inflates a tiny upload into a huge
// tar stream); MAX_UPLOAD_BYTES in server.js only bounds the compressed // tar stream); MAX_UPLOAD_BYTES in server.js only bounds the compressed
// bytes on the wire. // bytes on the wire.
@@ -142,6 +161,12 @@ export async function renderComposition({
throw new UnknownCompositionError(compositionId, knownIds); throw new UnknownCompositionError(compositionId, knownIds);
} }
const entryHtml = await readFile(
path.join(compositionDir, `${orientation}.html`),
"utf8",
);
const fps = parseFps(entryHtml);
// The HTML files <script src="props.js"></script> reads this — set up by // The HTML files <script src="props.js"></script> reads this — set up by
// Task T2, but the sidecar must write the file per render so the HTML // Task T2, but the sidecar must write the file per render so the HTML
// picks up per-release content + orientation. // picks up per-release content + orientation.
@@ -171,7 +196,7 @@ export async function renderComposition({
// out of the job config), and resolves the cut's HTML from entryFile // out of the job config), and resolves the cut's HTML from entryFile
// relative to projectDir. // relative to projectDir.
const job = createRenderJob({ const job = createRenderJob({
fps: FPS, fps,
quality: QUALITY, quality: QUALITY,
format: "mp4", format: "mp4",
entryFile: `${orientation}.html`, entryFile: `${orientation}.html`,
+46
View File
@@ -0,0 +1,46 @@
// node --test: node's built-in runner, no dependency needed. Only parseFps is
// exercised (a pure function) — renderComposition needs a real Chromium
// render and is covered by manual/e2e verification instead.
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseFps } from "./render.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const compositionsRoot = path.join(__dirname, "..", "motion", "compositions");
test("parseFps reads data-fps off a real composition file", async () => {
const html = await readFile(
path.join(compositionsRoot, "release-announcement", "vertical.html"),
"utf8",
);
assert.equal(parseFps(html), 30);
});
test("parseFps reads a declared 24fps composition", () => {
const html = `<div id="stage" data-fps="24"></div>`;
assert.equal(parseFps(html), 24);
});
test("parseFps falls back to 30 when the attribute is missing", () => {
assert.equal(parseFps("<html><body>no fps here</body></html>"), 30);
});
test("parseFps clamps below the 24-60 bound to the 30 fallback", () => {
assert.equal(parseFps(`<div data-fps="15"></div>`), 30);
});
test("parseFps clamps above the 24-60 bound to the 30 fallback", () => {
assert.equal(parseFps(`<div data-fps="120"></div>`), 30);
});
test("parseFps falls back to 30 on an unparsable value", () => {
assert.equal(parseFps(`<div data-fps="abc"></div>`), 30);
});
test("parseFps accepts the boundary values 24 and 60", () => {
assert.equal(parseFps(`<div data-fps="24"></div>`), 24);
assert.equal(parseFps(`<div data-fps="60"></div>`), 60);
});