mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Video pipeline fixes: visibility strip, rich briefs, spotlight timing + fps (#369)
* feat(video): pipeline visibility — strip, state-aware queue, render-error capture Task 1 of the 2026-07-09 video-pipeline review. New CEO-gated GET /video/pipeline lists every in-flight video item (authoring statuses, rendering attempt n/max, terminal failures with the error — now stamped onto the video_draft marker instead of dying as a log line). source_task_id exposed on both video schemas. Panel: pipeline strip on the Social page, state-aware queue empty copy, title/script on queue rows, missing cuts disabled instead of a blank player, notifications deep-link related_task_id. MAX_VIDEO_RENDER_ATTEMPTS moved to the markers policy layer (single source of truth). * feat(video): rich authoring briefs — changelog section, brand voice, kit pointer Task 2 of the 2026-07-09 video-pipeline review. The release brief is now a structured block (full CHANGELOG section capped at 4000 chars + highlights) instead of one LLM-compressed sentence; brand_voice and a motion/kit design-bar pointer are appended centrally in open_video_task so release, spotlight, and on-demand paths all inherit them. suggested_input_props seeded on the video_draft marker; third acceptance criterion pins the design bar; propose_video docstring points at the kit. * fix(video): spotlight video drafts on CEO approval, renderer honors data-fps Task 4 of the 2026-07-09 video-pipeline review. The companion-video hook moves from propose_feature_spotlight (HoM authoring time) to XPostService approve's posted-success branch for x_feature drafts, mirroring the release-publish seam — a rejected spotlight no longer burns a ux-dev cycle; wants_video/video_script ride the x_feature_ref marker. Best-effort: a video-engine failure never breaks the post. render.js reads data-fps from the composition HTML (clamped 24-60, fallback 30) instead of hardcoding 30; parseFps covered by node --test. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -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,14 +2,20 @@ 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,
|
||||||
|
listPipeline,
|
||||||
|
approve,
|
||||||
|
reject,
|
||||||
|
requestVideo,
|
||||||
|
getMediaBlob,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
listPosts: vi.fn(
|
listPosts: vi.fn(
|
||||||
async () =>
|
async () =>
|
||||||
[
|
[
|
||||||
@@ -23,9 +29,14 @@ const { listPosts, approve, reject, requestVideo, getMediaBlob } = vi.hoisted(
|
|||||||
platforms: ["x", "tiktok"],
|
platforms: ["x", "tiktok"],
|
||||||
x_caption: "RoboCo v0.19.0 is here!",
|
x_caption: "RoboCo v0.19.0 is here!",
|
||||||
tiktok_caption: "New RoboCo drop!",
|
tiktok_caption: "New RoboCo drop!",
|
||||||
|
mp4_paths: {
|
||||||
|
vertical: "/fake/vertical.mp4",
|
||||||
|
square: "/fake/square.mp4",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
] as VideoPost[],
|
] as VideoPost[],
|
||||||
),
|
),
|
||||||
|
listPipeline: vi.fn(async (): Promise<VideoPipelineItem[]> => []),
|
||||||
// Deferred so the test can freeze the approve mid-flight.
|
// Deferred so the test can freeze the approve mid-flight.
|
||||||
approve: vi.fn(
|
approve: vi.fn(
|
||||||
() =>
|
() =>
|
||||||
@@ -42,11 +53,17 @@ const { listPosts, approve, reject, requestVideo, getMediaBlob } = vi.hoisted(
|
|||||||
getMediaBlob: vi.fn(
|
getMediaBlob: vi.fn(
|
||||||
async () => new Blob(["fake-mp4-bytes"], { type: "video/mp4" }),
|
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,25 +150,39 @@ 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>
|
||||||
|
{post.mp4_paths?.[cut] ? (
|
||||||
<video
|
<video
|
||||||
key={`${post.task_id}-${cut}`}
|
key={`${post.task_id}-${cut}`}
|
||||||
controls
|
controls
|
||||||
@@ -167,6 +191,11 @@ function VideoPostRow({
|
|||||||
>
|
>
|
||||||
Your browser does not support embedded video.
|
Your browser does not support embedded video.
|
||||||
</video>
|
</video>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-48 items-center justify-center rounded-md border border-dashed text-sm text-muted-foreground">
|
||||||
|
This cut hasn't rendered yet
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export { videoApi, videoMediaUrl } from "./video";
|
|||||||
export type {
|
export type {
|
||||||
VideoCut,
|
VideoCut,
|
||||||
VideoPost,
|
VideoPost,
|
||||||
|
VideoPipelineItem,
|
||||||
VideoPostExecuteResult,
|
VideoPostExecuteResult,
|
||||||
VideoPostHistoryEntry,
|
VideoPostHistoryEntry,
|
||||||
VideoRequestResult,
|
VideoRequestResult,
|
||||||
|
|||||||
@@ -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[]> => {
|
||||||
|
|||||||
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user