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:
@@ -2,51 +2,68 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import type { VideoPost } from "@/lib/api/video";
|
||||
import type { VideoPipelineItem, VideoPost } from "@/lib/api/video";
|
||||
|
||||
const { resolveApproveRef } = vi.hoisted(() => ({
|
||||
resolveApproveRef: { current: null as null | ((v: unknown) => void) },
|
||||
}));
|
||||
|
||||
const { listPosts, approve, reject, requestVideo, getMediaBlob } = vi.hoisted(
|
||||
() => ({
|
||||
listPosts: vi.fn(
|
||||
async () =>
|
||||
[
|
||||
{
|
||||
task_id: "v-1",
|
||||
source: "video_post",
|
||||
title: "Video: release v0.19.0",
|
||||
status: "pending",
|
||||
occasion: "release",
|
||||
script: "RoboCo v0.19.0 just shipped!",
|
||||
platforms: ["x", "tiktok"],
|
||||
x_caption: "RoboCo v0.19.0 is here!",
|
||||
tiktok_caption: "New RoboCo drop!",
|
||||
const {
|
||||
listPosts,
|
||||
listPipeline,
|
||||
approve,
|
||||
reject,
|
||||
requestVideo,
|
||||
getMediaBlob,
|
||||
} = vi.hoisted(() => ({
|
||||
listPosts: vi.fn(
|
||||
async () =>
|
||||
[
|
||||
{
|
||||
task_id: "v-1",
|
||||
source: "video_post",
|
||||
title: "Video: release v0.19.0",
|
||||
status: "pending",
|
||||
occasion: "release",
|
||||
script: "RoboCo v0.19.0 just shipped!",
|
||||
platforms: ["x", "tiktok"],
|
||||
x_caption: "RoboCo v0.19.0 is here!",
|
||||
tiktok_caption: "New RoboCo drop!",
|
||||
mp4_paths: {
|
||||
vertical: "/fake/vertical.mp4",
|
||||
square: "/fake/square.mp4",
|
||||
},
|
||||
] as VideoPost[],
|
||||
),
|
||||
// Deferred so the test can freeze the approve mid-flight.
|
||||
approve: vi.fn(
|
||||
() =>
|
||||
new Promise((r) => {
|
||||
resolveApproveRef.current = r as (v: unknown) => void;
|
||||
}),
|
||||
),
|
||||
reject: vi.fn(async () => ({})),
|
||||
requestVideo: vi.fn(async () => ({
|
||||
status: "opened",
|
||||
task_id: "v-2",
|
||||
detail: "Video-authoring task opened.",
|
||||
})),
|
||||
getMediaBlob: vi.fn(
|
||||
async () => new Blob(["fake-mp4-bytes"], { type: "video/mp4" }),
|
||||
),
|
||||
}),
|
||||
);
|
||||
},
|
||||
] as VideoPost[],
|
||||
),
|
||||
listPipeline: vi.fn(async (): Promise<VideoPipelineItem[]> => []),
|
||||
// Deferred so the test can freeze the approve mid-flight.
|
||||
approve: vi.fn(
|
||||
() =>
|
||||
new Promise((r) => {
|
||||
resolveApproveRef.current = r as (v: unknown) => void;
|
||||
}),
|
||||
),
|
||||
reject: vi.fn(async () => ({})),
|
||||
requestVideo: vi.fn(async () => ({
|
||||
status: "opened",
|
||||
task_id: "v-2",
|
||||
detail: "Video-authoring task opened.",
|
||||
})),
|
||||
getMediaBlob: vi.fn(
|
||||
async () => new Blob(["fake-mp4-bytes"], { type: "video/mp4" }),
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
videoApi: { listPosts, approve, reject, requestVideo, getMediaBlob },
|
||||
videoApi: {
|
||||
listPosts,
|
||||
listPipeline,
|
||||
approve,
|
||||
reject,
|
||||
requestVideo,
|
||||
getMediaBlob,
|
||||
},
|
||||
}));
|
||||
|
||||
import { VideoPostQueue } from "../video-post-queue";
|
||||
@@ -61,6 +78,7 @@ function withQueryClient(ui: ReactNode) {
|
||||
describe("VideoPostQueue", () => {
|
||||
beforeEach(() => {
|
||||
listPosts.mockClear();
|
||||
listPipeline.mockClear();
|
||||
approve.mockClear();
|
||||
reject.mockClear();
|
||||
requestVideo.mockClear();
|
||||
@@ -87,6 +105,46 @@ describe("VideoPostQueue", () => {
|
||||
expect(screen.getByDisplayValue("New RoboCo drop!")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the fetched title and script instead of dropping them", async () => {
|
||||
render(withQueryClient(<VideoPostQueue />));
|
||||
expect(
|
||||
await screen.findByText("Video: release v0.19.0"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("RoboCo v0.19.0 just shipped!"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("flags a missing cut as disabled instead of silently blanking the player", async () => {
|
||||
listPosts.mockResolvedValueOnce([
|
||||
{
|
||||
task_id: "v-1",
|
||||
source: "video_post",
|
||||
title: "Video: release v0.19.0",
|
||||
status: "pending",
|
||||
occasion: "release",
|
||||
script: "RoboCo v0.19.0 just shipped!",
|
||||
platforms: ["x", "tiktok"],
|
||||
x_caption: "RoboCo v0.19.0 is here!",
|
||||
tiktok_caption: "New RoboCo drop!",
|
||||
mp4_paths: { vertical: "/fake/vertical.mp4" }, // square never rendered
|
||||
},
|
||||
] as VideoPost[]);
|
||||
render(withQueryClient(<VideoPostQueue />));
|
||||
await screen.findByText("release");
|
||||
|
||||
const squareButton = screen.getByRole("button", { name: /1:1/ });
|
||||
expect(squareButton).toHaveTextContent("(missing)");
|
||||
expect(squareButton).toBeDisabled();
|
||||
// The present cut is unaffected — no "(missing)" suffix, not disabled.
|
||||
const verticalButton = screen.getByRole("button", { name: /9:16/ });
|
||||
expect(verticalButton).not.toHaveTextContent("(missing)");
|
||||
expect(verticalButton).not.toBeDisabled();
|
||||
await waitFor(() =>
|
||||
expect(getMediaBlob).toHaveBeenCalledWith("v-1", "vertical"),
|
||||
);
|
||||
});
|
||||
|
||||
// H15: the 30s refetchInterval produces a new `post` prop, but useState
|
||||
// initializes once — so a server-side re-draft between the CEO opening the
|
||||
// card and approving would be silently overwritten by the stale initial
|
||||
@@ -242,12 +300,36 @@ describe("VideoPostQueue", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("shows an empty-state card (with the request action) when there are no drafts", async () => {
|
||||
it("shows the keys/engine empty copy when nothing is in the pipeline either", async () => {
|
||||
listPosts.mockResolvedValueOnce([]);
|
||||
listPipeline.mockResolvedValueOnce([]);
|
||||
render(withQueryClient(<VideoPostQueue />));
|
||||
expect(await screen.findByText(/No drafts yet/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Request a video/ }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an in-flight count instead of the keys/engine copy when the pipeline is non-empty", async () => {
|
||||
listPosts.mockResolvedValueOnce([]);
|
||||
listPipeline.mockResolvedValueOnce([
|
||||
{
|
||||
task_id: "vp-1",
|
||||
title: "Video: launch teaser",
|
||||
occasion: "launch",
|
||||
status: "in_progress",
|
||||
pr_number: null,
|
||||
composition_id: null,
|
||||
render_status: null,
|
||||
render_attempts: 0,
|
||||
max_attempts: 5,
|
||||
render_error: null,
|
||||
},
|
||||
] as VideoPipelineItem[]);
|
||||
render(withQueryClient(<VideoPostQueue />));
|
||||
expect(
|
||||
await screen.findByText(/1 video in flight — nothing rendered yet/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/No drafts yet/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user