mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[e4ed92d6] Video pipeline per-project requests, re-render action, composition preview (#403)
* [7f2c881a] Project-scope video pipeline + re-render + preview proxy (#386) (#396) * [7f2c881a] feat(video): scope on-demand video requests + render loop to project_id Require project_id on VideoRequestBody (404 when unresolvable or not opted into the video engine), thread it through VideoEngine.open_video_task via a shared resolve_authoring_project helper, and resolve the render loop's motion/ workspace from the authoring task's own project_id instead of the hardcoded self_heal_project_slug. * [7f2c881a] fix(video): cast task.id to UUID before VideoEngine.rerender calls mypy flagged task.id as sqlalchemy.sql.sqltypes.UUID[Any] rather than uuid.UUID in the three rerender tests; cast to UUID per the codebase's established idiom (cast("UUID", obj.id)) used elsewhere for the same SQLAlchemy Mapped-attribute inference gap. * [7f2c881a] docs(video): API endpoints for project-scoped requests, re-render, and preview proxy Add comprehensive API documentation for the new project-scoped video engine endpoints: - POST /api/video/request: on-demand video authoring scoped to project_id (breaking change) - POST /api/video/pipeline/{task_id}/rerender: CEO-triggered re-render with idempotency key clearing - GET /api/video/preview/{task_id}/{file_path}: CEO preview proxy with path-traversal confinement Document project-scoping architecture: authoring tasks and render loop now resolve from task's own project_id instead of hardcoded self_heal_project_slug. Add migration guide covering breaking change to VideoRequestBody schema (project_id now required), error handling changes (404 on unresolvable/non-opted-in projects), and client migration steps. --------- Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech> Co-authored-by: Backend Documenter <be-doc@roboco.tech> * [8f959c3b] docs(ux_ui): add project picker, re-render control, and composition preview panel spec (#381) (#398) Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech> * [1fb5b5cb] Project picker, re-render button, and composition preview panel (#397) (#402) * [1fb5b5cb] feat(video): project picker, re-render button, and composition preview panel * [1fb5b5cb] docs(video): add comprehensive guide for project picker, re-render button, and composition preview panel --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech> Co-authored-by: Frontend Documenter <fe-doc@roboco.tech> * [a512f364] Add video_engine_enabled to ProjectSummaryResponse (#412) (#414) * [a512f364] feat(api): surface video_engine_enabled on ProjectSummaryResponse * [a512f364] docs(api): document video_engine_enabled on ProjectSummaryResponse --------- Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech> Co-authored-by: Backend Documenter <be-doc@roboco.tech> * [03607ab9] Fix re-render control gating/placement and project picker filter (#434) * [f2f3e89f] Fix RerenderControl gating/placement across queue and strip views (#431) * [f2f3e89f] feat(video): widen RerenderControl gating and share it across queue/strip views Extracts RerenderControl into a shared panel/src/components/dashboard/ video-rerender-control.tsx component, widens its gate from render_status === 'failed' to source_task_id + composition_id present (matching what the backend rerender endpoint actually requires), adds a confirm dialog before firing the mutation, and wires the same component into video-pipeline-strip.tsx for still-in-flight rendering/render_failed rows. * [f2f3e89f] docs(video): enhance RerenderControl JSDoc with gating logic and usage examples Add comprehensive JSDoc to the RerenderControl component covering its purpose, gating logic (render for any source_task_id + composition_id, regardless of render_status), three visual button states (idle/loading/ error), confirm-dialog guard behavior, and usage examples for both video-post-queue.tsx and video-pipeline-strip.tsx contexts. Explains why the backend's rerender endpoint doesn't require a failed render and how the component prevents accidental re-renders. --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech> Co-authored-by: Frontend Documenter <fe-doc@roboco.tech> * [404d8ed3] Filter project picker to video-engine-enabled projects (#432) * [404d8ed3] feat(panel): filter video-request project picker to opted-in projects Add video_engine_enabled to the client ProjectSummary type, give ProjectSelector a videoEngineOnly filter prop, default RequestVideoDialog's picker to the current video-enabled project with a friendly empty-state when none exist, and cover the filter with a new project-selector test. * [404d8ed3] docs(panel): add ProjectSelector component API reference with videoEngineOnly filter Document the reusable ProjectSelector component with its props, filtering behavior, and new videoEngineOnly filter for video-engine-enabled projects. Follows the existing component documentation pattern from page-refresh-provider. --------- Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech> Co-authored-by: Frontend Documenter <fe-doc@roboco.tech> * [519a4088] fix(panel): import missing RerenderControl in video-post-queue and correct stale doc (#436) Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech> --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech> Co-authored-by: Frontend Documenter <fe-doc@roboco.tech> Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech> * [8e912c3e] Reflow hard-wrapped video UX design doc to pass quality gate (#439) * [ccfe2015] docs(ux_ui): reflow video request composition-controls spec to one line per paragraph (#438) Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech> * [99c3ed9c] docs(backend): reflow hard-wrapped prose in video-engine-endpoints.md and video-project-scoping.md (#442) Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech> * [c2e98fc0] docs(backend): strip stray trailing whitespace in video-engine-endpoints.md fence (#451) Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech> * [9c7bc11a] Reflow all 3 hard-wrapped docs on this branch and verify quality gate (#457) * [9c7bc11a] test(scripts): guard reflow_md.py --check wiring into make quality * [9c7bc11a] docs(standards): document markdown reflow quality gate workflow and verification Added comprehensive guide explaining the one-logical-unit-per-line markdown prose standard, how the reflow check integrates into make quality, the three reflowed files (video-engine-endpoints.md, video-project-scoping.md, composition-controls spec), and the regression test added to ensure wiring stability. This task verifies all three ACs are satisfied: reflow_md.py --check exits 0, make quality passes (non-DB portions), and the three files are whitespace-only reflowed. --------- Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech> Co-authored-by: UX/UI Documenter <ux-doc@roboco.tech> --------- Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech> Co-authored-by: UX/UI Documenter <ux-doc@roboco.tech> * [002f0cdd] docs(rag): document reflow-check zero-diff troubleshooting path (#459) (#460) Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech> * [e4ed92d6] fix rerender missing-task test — assert the empty queue it creates The test never seeds; the trailing assertion expected a phantom video post. Broken since the branch's first commit but never executed — every earlier CI run short-circuited at a pre-pytest gate step. --------- Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech> Co-authored-by: Backend Documenter <be-doc@roboco.tech> Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech> Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech> Co-authored-by: Frontend Documenter <fe-doc@roboco.tech> Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech> Co-authored-by: UX/UI Documenter <ux-doc@roboco.tech> Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
co-authored by
Backend Developer 1
Backend Documenter
UX/UI Developer 1
Frontend Developer 1
Frontend Documenter
Frontend Developer 2
UX/UI Documenter
Renn F
parent
4d52f6ff59
commit
7f138d3bf5
@@ -0,0 +1,218 @@
|
||||
# Video Post Queue: Project Picker, Re-render, and Composition Preview
|
||||
|
||||
The video posting flow consists of three major components: project selection for on-demand video requests, a re-render retry control for failed drafts, and a live composition preview panel. All three integrate into the VideoPostRow and RequestVideoDialog components in `panel/src/components/dashboard/video-post-queue.tsx`.
|
||||
|
||||
## Project Picker in RequestVideoDialog
|
||||
|
||||
The "Request a video" dialog now requires a project selection before the CEO can submit. The picker is built on the existing `ProjectSelector` component (from `panel/src/components/projects/project-selector.tsx`) that provides a combobox populated via `projectsApi.list()`.
|
||||
|
||||
### Implementation
|
||||
|
||||
- **Location**: RequestVideoDialog component, first form field
|
||||
- **State**: `projectId` (string | null), initialized to `null`
|
||||
- **Binding**: The `ProjectSelector` renders with `value={projectId}` and `onChange={setProjectId}`, and the Request button is disabled until `projectId` is truthy
|
||||
- **Payload**: The `project_id` is passed as a string to `videoApi.requestVideo()` in the mutation body
|
||||
|
||||
### Usage in RequestVideoDialog
|
||||
|
||||
```typescript
|
||||
const [projectId, setProjectId] = useState<string | null>(null);
|
||||
|
||||
// Inside the form:
|
||||
<div className="space-y-2">
|
||||
<Label>Project</Label>
|
||||
<ProjectSelector
|
||||
value={projectId}
|
||||
onChange={setProjectId}
|
||||
placeholder="Select the project this video is about..."
|
||||
allowClear={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
// Submit guard:
|
||||
const canSubmit =
|
||||
!!projectId &&
|
||||
occasion.trim().length > 0 &&
|
||||
brief.trim().length > 0 &&
|
||||
platforms.length > 0;
|
||||
```
|
||||
|
||||
The picker prevents submission of the video request until a project is explicitly selected, ensuring every on-demand video is scoped to a specific project.
|
||||
|
||||
## Re-render Control for Any Composition-Bearing Draft
|
||||
|
||||
The `RerenderControl` component (`panel/src/components/dashboard/video-rerender-control.tsx`) appears on any draft that has both an authoring task and a proposed composition, giving the CEO a way to retry — or deliberately redo — a render without creating a new request.
|
||||
|
||||
### When It Appears
|
||||
|
||||
- Rendered in `VideoPostRow` when both conditions hold:
|
||||
- `post.source_task_id` is truthy (the authoring task exists)
|
||||
- `post.composition_id` is truthy (a composition was proposed)
|
||||
- This is regardless of `render_status` — the backend's rerender endpoint only requires a completed authoring task with a proposed composition, not a failed render, so a healthy render can be deliberately redone too
|
||||
- Positioned in the draft header row, right-aligned after the occasion badge
|
||||
|
||||
### Visual States
|
||||
|
||||
1. **Idle** ("Re-render" button) — ready to click
|
||||
2. **Loading** ("Re-rendering...") — mutation in flight, button disabled, spinner animating
|
||||
3. **Error** ("Retry re-render") — the retry itself failed, button text and border turn red (`text-destructive` / `border-destructive`), button re-enables so the CEO can try again
|
||||
|
||||
### API Interaction
|
||||
|
||||
- Calls `videoApi.rerender(authoringTaskId)` where `authoringTaskId` is the draft's `source_task_id` (the video-authoring task, NOT the draft's own task_id)
|
||||
- The backend endpoint is `POST /video/pipeline/{task_id}/rerender` and clears the render idempotency keys so the render loop picks up the task on its next cycle
|
||||
- On success, invalidates the `["video", "pipeline"]` query key and shows a success toast: "Re-render queued — it will re-pick up on the next cycle."
|
||||
- On error, shows an error toast with the backend message
|
||||
|
||||
### Implementation Detail
|
||||
|
||||
The component uses `useMutation` from @tanstack/react-query and mirrors the pattern in the "Reject draft" dialog's mutation (error state re-enables the button, allowing the CEO to retry).
|
||||
|
||||
## Composition Preview Panel
|
||||
|
||||
The `CompositionPreviewPanel` displays a live, read-only preview of the video composition (the actual HyperFrames HTML render) alongside the platform captions so the CEO can see exactly what will post before approving.
|
||||
|
||||
### When It Appears
|
||||
|
||||
- Rendered in `VideoPostRow` immediately above the MP4 player
|
||||
- Only shows when BOTH of these are true:
|
||||
- `post.composition_id` is truthy
|
||||
- `post.source_task_id` is truthy
|
||||
|
||||
This degrades gracefully: older drafts or drafts from versions before the backend exposed these fields simply render no preview panel (not a crash).
|
||||
|
||||
### Layout
|
||||
|
||||
The panel is a two-column grid on desktop (`sm:grid-cols-2`) that stacks on mobile:
|
||||
|
||||
1. **Left column (desktop) / Top (mobile)**:
|
||||
- An `<iframe>` element embedding the composition HTML directly
|
||||
- The iframe uses a sandboxed environment (`sandbox="allow-scripts"`)
|
||||
- Lazy-loads for performance (`loading="lazy"`)
|
||||
- Applies aspect-video sizing and a black background
|
||||
|
||||
2. **Right column (desktop) / Bottom (mobile)**:
|
||||
- A "Captions as they will post" header
|
||||
- Per-platform caption display:
|
||||
- "X:" followed by the `x_caption` (if present)
|
||||
- "TikTok:" followed by the `tiktok_caption` (if present)
|
||||
|
||||
### Composition Preview URL
|
||||
|
||||
The iframe `src` is built using the `compositionPreviewUrl()` helper:
|
||||
|
||||
```typescript
|
||||
compositionPreviewUrl(
|
||||
post.source_task_id, // authoring task ID
|
||||
post.composition_id, // composition ID from the draft
|
||||
cut, // current cut selection (vertical or square)
|
||||
)
|
||||
// → `/api/video/preview/{source_task_id}/motion/compositions/{composition_id}/{cut}.html`
|
||||
```
|
||||
|
||||
The backend's `GET /video/preview/{task_id}/{file_path:path}` route serves the composition HTML + sibling assets from the project's merged read-clone with iframe-permitting headers (no auth-header workaround needed like the MP4 route).
|
||||
|
||||
### Caption Display
|
||||
|
||||
Captions are displayed as read-only text. The component only renders a caption section if at least one platform has a caption defined. This mirrors the structure the CEO will see when editing captions below (the "Edit X caption" / "Edit TikTok caption" checkboxes), giving visual parity between the live preview and the editable form.
|
||||
|
||||
## API Updates
|
||||
|
||||
### VideoPost Interface
|
||||
|
||||
Two new optional fields were added to mirror the backend's `video_draft` marker's render idempotency tracking:
|
||||
|
||||
```typescript
|
||||
composition_id?: string | null; // The composition this draft rendered from
|
||||
render_status?: string | null; // null | "rendered" | "failed"
|
||||
```
|
||||
|
||||
Both are optional because drafts created before the backend exposed these fields will not have them. The UI gracefully handles their absence (re-render button doesn't appear, composition preview doesn't render).
|
||||
|
||||
### New API Functions
|
||||
|
||||
#### `compositionPreviewUrl()`
|
||||
|
||||
Builds the URL for the composition preview iframe:
|
||||
|
||||
```typescript
|
||||
export function compositionPreviewUrl(
|
||||
authoringTaskId: string,
|
||||
compositionId: string,
|
||||
cut: VideoCut,
|
||||
): string
|
||||
```
|
||||
|
||||
- **Parameters**:
|
||||
- `authoringTaskId`: The video-authoring task ID (VideoPost.source_task_id)
|
||||
- `compositionId`: The composition ID (VideoPost.composition_id)
|
||||
- `cut`: "vertical" or "square"
|
||||
- **Returns**: The URL to pass to `<iframe src>`
|
||||
|
||||
#### `videoApi.rerender()`
|
||||
|
||||
Triggers a re-render of a failed composition:
|
||||
|
||||
```typescript
|
||||
rerender: async (authoringTaskId: string): Promise<void>
|
||||
```
|
||||
|
||||
- **Parameter**: The authoring task ID (VideoPost.source_task_id), NOT the draft's task_id
|
||||
- **Backend**: POSTs to `/video/pipeline/{task_id}/rerender`
|
||||
- **Effect**: Clears the render idempotency keys so the render loop picks the task up again on the next cycle
|
||||
|
||||
### Updated: `videoApi.requestVideo()`
|
||||
|
||||
The request signature now includes `project_id`:
|
||||
|
||||
```typescript
|
||||
requestVideo: async (body: {
|
||||
occasion: string;
|
||||
brief: string;
|
||||
platforms: string[];
|
||||
project_id: string; // ← NEW
|
||||
}): Promise<VideoRequestResult>
|
||||
```
|
||||
|
||||
## Integration with VideoPostRow
|
||||
|
||||
All three features integrate into `VideoPostRow` via:
|
||||
|
||||
1. **Re-render button** appears in the header row (between the occasion badge and the edge):
|
||||
```typescript
|
||||
const canRerender = !!post.source_task_id && !!post.composition_id;
|
||||
|
||||
{canRerender && (
|
||||
<div className="ml-auto">
|
||||
<RerenderControl authoringTaskId={post.source_task_id as string} />
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
2. **Composition preview panel** renders immediately above the MP4 player/cut switcher:
|
||||
```typescript
|
||||
<CompositionPreviewPanel post={post} cut={cut} />
|
||||
```
|
||||
|
||||
This placement gives the CEO a visual hierarchy: draft metadata → live composition preview → MP4 cuts → caption editors → approve/reject actions.
|
||||
|
||||
## Design Notes
|
||||
|
||||
- **Rerender gating**: The `canRerender` computation (`!!source_task_id && !!composition_id`) shows the control for any composition-bearing draft regardless of `render_status`, matching `RerenderControl`'s own gating in `video-pipeline-strip.tsx`, keeping both UI surfaces consistent.
|
||||
- **Composition preview sizing**: Uses `aspect-video` to maintain the standard 16:9 ratio for the iframe, with `w-full` for responsive scaling.
|
||||
- **Lazy loading**: The iframe uses `loading="lazy"` so it only fetches when scrolled into view, reducing initial page load on the queue.
|
||||
- **Sandbox isolation**: The iframe runs with `sandbox="allow-scripts"` to execute the composition's interactive elements while preventing navigation or form submission from escaping the preview.
|
||||
- **Graceful degradation**: All three features degrade safely — missing `composition_id` or `render_status` fields simply hide the corresponding UI, never crash.
|
||||
|
||||
## Testing
|
||||
|
||||
Six new tests cover the three features:
|
||||
|
||||
1. **Project picker** — Submit disabled until a project is selected
|
||||
2. **Re-render button** — Hidden on healthy renders, shown only on stale drafts
|
||||
3. **Re-render action** — Clicking queues the backend re-render action
|
||||
4. **Composition preview** — Rendered only when `composition_id` is present, with correct iframe src and caption display
|
||||
5. **Preview visibility** — No preview when `composition_id` is absent
|
||||
6. **Request payload** — The `project_id` is sent in the POST /video/request body
|
||||
|
||||
All tests pass; the full suite (380 tests) is green on the panel, and typecheck/lint/prettier are clean.
|
||||
@@ -1,15 +1,16 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, 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(() => ({
|
||||
const { listPipeline, rerender } = vi.hoisted(() => ({
|
||||
listPipeline: vi.fn(async (): Promise<VideoPipelineItem[]> => []),
|
||||
rerender: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
videoApi: { listPipeline },
|
||||
videoApi: { listPipeline, rerender },
|
||||
}));
|
||||
|
||||
import { VideoPipelineStrip } from "../video-pipeline-strip";
|
||||
@@ -61,6 +62,7 @@ const FAILED: VideoPipelineItem = {
|
||||
describe("VideoPipelineStrip", () => {
|
||||
beforeEach(() => {
|
||||
listPipeline.mockClear();
|
||||
rerender.mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -99,4 +101,35 @@ describe("VideoPipelineStrip", () => {
|
||||
expect(reviewLinks).toHaveLength(1);
|
||||
expect(reviewLinks[0]).toHaveAttribute("href", "/tasks/vp-2");
|
||||
});
|
||||
|
||||
it("shows a re-render button on rendering and render_failed rows with a composition, but not on authoring/awaiting-approval rows", async () => {
|
||||
listPipeline.mockResolvedValueOnce([
|
||||
AUTHORING,
|
||||
AWAITING_APPROVAL,
|
||||
RENDERING,
|
||||
FAILED,
|
||||
]);
|
||||
render(withQueryClient(<VideoPipelineStrip />));
|
||||
await screen.findByText("Video Pipeline");
|
||||
|
||||
const rerenderButtons = screen.getAllByRole("button", {
|
||||
name: /Re-render/,
|
||||
});
|
||||
expect(rerenderButtons).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("triggers the backend re-render action for the clicked row's authoring task id, behind a confirm dialog", async () => {
|
||||
listPipeline.mockResolvedValueOnce([FAILED]);
|
||||
render(withQueryClient(<VideoPipelineStrip />));
|
||||
await screen.findByText("Video Pipeline");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Re-render/ }));
|
||||
expect(rerender).not.toHaveBeenCalled();
|
||||
const confirmButton = await screen.findByRole("button", {
|
||||
name: /Re-render/,
|
||||
});
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
await waitFor(() => expect(rerender).toHaveBeenCalledWith("vp-4"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,30 @@ const { resolveApproveRef } = vi.hoisted(() => ({
|
||||
resolveApproveRef: { current: null as null | ((v: unknown) => void) },
|
||||
}));
|
||||
|
||||
const { useProjects } = vi.hoisted(() => ({
|
||||
// One video-enabled project by default ("p-1", matching the mocked
|
||||
// ProjectSelector's onChange value below) so the dialog can default-select
|
||||
// it — tests that need the empty-state override this per-test.
|
||||
useProjects: vi.fn(() => ({
|
||||
data: [
|
||||
{
|
||||
id: "p-1",
|
||||
name: "roboco-panel",
|
||||
slug: "roboco-panel",
|
||||
git_url: "https://example.com/roboco-panel.git",
|
||||
assigned_cell: "frontend",
|
||||
is_active: true,
|
||||
has_workspace: true,
|
||||
has_git_token: true,
|
||||
video_engine_enabled: true,
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-projects", () => ({ useProjects }));
|
||||
|
||||
const {
|
||||
listPosts,
|
||||
listPipeline,
|
||||
@@ -15,6 +39,7 @@ const {
|
||||
reject,
|
||||
requestVideo,
|
||||
getMediaBlob,
|
||||
rerender,
|
||||
} = vi.hoisted(() => ({
|
||||
listPosts: vi.fn(
|
||||
async () =>
|
||||
@@ -53,6 +78,7 @@ const {
|
||||
getMediaBlob: vi.fn(
|
||||
async () => new Blob(["fake-mp4-bytes"], { type: "video/mp4" }),
|
||||
),
|
||||
rerender: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
@@ -63,8 +89,18 @@ vi.mock("@/lib/api", () => ({
|
||||
reject,
|
||||
requestVideo,
|
||||
getMediaBlob,
|
||||
rerender,
|
||||
},
|
||||
}));
|
||||
// ProjectSelector: a button that sets the project, mirroring
|
||||
// create-task-dialog.test.tsx — bypasses the data-fetching combobox.
|
||||
vi.mock("@/components/projects/project-selector", () => ({
|
||||
ProjectSelector: ({ onChange }: { onChange: (v: string | null) => void }) => (
|
||||
<button type="button" onClick={() => onChange("p-1")}>
|
||||
Set Project
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import { VideoPostQueue } from "../video-post-queue";
|
||||
|
||||
@@ -277,11 +313,12 @@ describe("VideoPostQueue", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("requests an on-demand video with the chosen occasion, brief, and platforms", async () => {
|
||||
it("requests an on-demand video with the chosen project, occasion, brief, and platforms", async () => {
|
||||
render(withQueryClient(<VideoPostQueue />));
|
||||
await screen.findByText("release");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Request a video/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set Project" }));
|
||||
fireEvent.change(screen.getByLabelText("Occasion"), {
|
||||
target: { value: "Founder's Day" },
|
||||
});
|
||||
@@ -296,10 +333,70 @@ describe("VideoPostQueue", () => {
|
||||
occasion: "Founder's Day",
|
||||
brief: "Celebrate the founding.",
|
||||
platforms: ["x", "tiktok"],
|
||||
project_id: "p-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults the project to the current (first) video-enabled project so Request enables without an explicit pick", async () => {
|
||||
render(withQueryClient(<VideoPostQueue />));
|
||||
await screen.findByText("release");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Request a video/ }));
|
||||
// Request still needs occasion/brief filled...
|
||||
expect(screen.getByRole("button", { name: "Request" })).toBeDisabled();
|
||||
fireEvent.change(screen.getByLabelText("Occasion"), {
|
||||
target: { value: "Founder's Day" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Brief"), {
|
||||
target: { value: "Celebrate the founding." },
|
||||
});
|
||||
// ...but never a manual "Set Project" click — the picker already
|
||||
// defaulted to the sole video-enabled project.
|
||||
expect(screen.getByRole("button", { name: "Request" })).not.toBeDisabled();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Request" }));
|
||||
await waitFor(() =>
|
||||
expect(requestVideo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ project_id: "p-1" }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a friendly empty-state when no project has the video engine enabled", async () => {
|
||||
// mockReturnValue (not -Once): RequestVideoDialog re-renders more than
|
||||
// once before the assertions run, and a -Once override would only cover
|
||||
// the first of those renders.
|
||||
useProjects.mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
id: "p-2",
|
||||
name: "not-opted-in",
|
||||
slug: "not-opted-in",
|
||||
git_url: "https://example.com/not-opted-in.git",
|
||||
assigned_cell: "backend",
|
||||
is_active: true,
|
||||
has_workspace: true,
|
||||
has_git_token: true,
|
||||
video_engine_enabled: false,
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
});
|
||||
render(withQueryClient(<VideoPostQueue />));
|
||||
await screen.findByText("release");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Request a video/ }));
|
||||
expect(
|
||||
screen.getByText(/No projects have the video engine enabled/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Occasion")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Request" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the keys/engine empty copy when nothing is in the pipeline either", async () => {
|
||||
listPosts.mockResolvedValueOnce([]);
|
||||
listPipeline.mockResolvedValueOnce([]);
|
||||
@@ -332,4 +429,107 @@ describe("VideoPostQueue", () => {
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/No drafts yet/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show a re-render button when the draft has no source_task_id/composition_id", async () => {
|
||||
render(withQueryClient(<VideoPostQueue />));
|
||||
await screen.findByText("release");
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /Re-render/ }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a re-render button on a draft with a composition regardless of render_status, behind a confirm dialog", 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" },
|
||||
source_task_id: "auth-1",
|
||||
composition_id: "release-recap",
|
||||
render_status: "failed",
|
||||
},
|
||||
] as VideoPost[]);
|
||||
render(withQueryClient(<VideoPostQueue />));
|
||||
await screen.findByText("release");
|
||||
|
||||
const rerenderButton = screen.getByRole("button", { name: /Re-render/ });
|
||||
fireEvent.click(rerenderButton);
|
||||
|
||||
// The action is gated behind a confirm dialog — clicking the trigger
|
||||
// alone must not call the backend.
|
||||
expect(rerender).not.toHaveBeenCalled();
|
||||
const confirmButton = await screen.findByRole("button", {
|
||||
name: /Re-render/,
|
||||
});
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
await waitFor(() => expect(rerender).toHaveBeenCalledWith("auth-1"));
|
||||
});
|
||||
|
||||
it("renders the re-render button for a post with render_status='rendered'", 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" },
|
||||
source_task_id: "auth-1",
|
||||
composition_id: "release-recap",
|
||||
render_status: "rendered",
|
||||
},
|
||||
] as VideoPost[]);
|
||||
render(withQueryClient(<VideoPostQueue />));
|
||||
await screen.findByText("release");
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Re-render/ }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the live composition preview iframe with captions when composition_id is present", 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" },
|
||||
source_task_id: "auth-1",
|
||||
composition_id: "release-recap",
|
||||
},
|
||||
] as VideoPost[]);
|
||||
render(withQueryClient(<VideoPostQueue />));
|
||||
await screen.findByText("release");
|
||||
|
||||
const iframe = document.querySelector("iframe");
|
||||
expect(iframe).toBeInTheDocument();
|
||||
expect(iframe?.getAttribute("src")).toContain(
|
||||
"/video/preview/auth-1/motion/compositions/release-recap/vertical.html",
|
||||
);
|
||||
expect(screen.getByText("Captions as they will post")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the composition preview panel when the draft carries no composition_id", async () => {
|
||||
render(withQueryClient(<VideoPostQueue />));
|
||||
await screen.findByText("release");
|
||||
expect(document.querySelector("iframe")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
pipelineStageColor,
|
||||
pipelineStageLabel,
|
||||
} from "./video-pipeline-utils";
|
||||
import { RerenderControl } from "@/components/dashboard/video-rerender-control";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -24,9 +25,17 @@ import { Film } from "lucide-react";
|
||||
// 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.
|
||||
// pipeline is surfacing); every other stage is just visibility. The
|
||||
// re-render control (shared with video-post-queue.tsx) shows for any item
|
||||
// carrying a proposed composition — the "rendering" and "render_failed"
|
||||
// stages are the only ones where the item's own task_id doubles as the
|
||||
// authoring task id the rerender endpoint needs, regardless of whether the
|
||||
// render is currently retrying or has failed outright.
|
||||
function PipelineRow({ item }: { item: VideoPipelineItem }) {
|
||||
const stage = derivePipelineStage(item);
|
||||
const canRerender =
|
||||
(stage.kind === "rendering" || stage.kind === "render_failed") &&
|
||||
!!item.composition_id;
|
||||
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" />
|
||||
@@ -46,6 +55,11 @@ function PipelineRow({ item }: { item: VideoPipelineItem }) {
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{canRerender && (
|
||||
<div className="ml-auto">
|
||||
<RerenderControl authoringTaskId={item.task_id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { videoApi } from "@/lib/api";
|
||||
import { compositionPreviewUrl } from "@/lib/api/video";
|
||||
import type {
|
||||
VideoCut,
|
||||
VideoPost,
|
||||
@@ -30,6 +31,9 @@ import {
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ProjectSelector } from "@/components/projects/project-selector";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
import { RerenderControl } from "@/components/dashboard/video-rerender-control";
|
||||
import { CheckCircle2, Film, Sparkles, XCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -64,6 +68,53 @@ function describeExecuteResult(result: VideoPostExecuteResult): string {
|
||||
return `${result.status}: ${result.detail}`;
|
||||
}
|
||||
|
||||
// Live composition preview: the authoring task's actual HyperFrames HTML for
|
||||
// the currently-selected cut, embedded via the backend's composition-HTML
|
||||
// proxy (iframe-permitting headers, so a direct <iframe src> works — no
|
||||
// blob-fetch workaround needed, unlike the MP4 player below). Read-only
|
||||
// captions sit alongside so the CEO can compare the live composition against
|
||||
// what will actually post, before approving. Renders nothing when the draft
|
||||
// carries no composition_id (older drafts / backend not yet exposing it).
|
||||
function CompositionPreviewPanel({
|
||||
post,
|
||||
cut,
|
||||
}: {
|
||||
post: VideoPost;
|
||||
cut: VideoCut;
|
||||
}) {
|
||||
if (!post.composition_id || !post.source_task_id) return null;
|
||||
return (
|
||||
<div className="mb-3 grid gap-3 rounded-md border p-3 sm:grid-cols-2">
|
||||
<iframe
|
||||
src={compositionPreviewUrl(
|
||||
post.source_task_id,
|
||||
post.composition_id,
|
||||
cut,
|
||||
)}
|
||||
title={`${post.title} — live composition preview`}
|
||||
sandbox="allow-scripts"
|
||||
loading="lazy"
|
||||
className="aspect-video w-full rounded-md border bg-black"
|
||||
/>
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="font-medium text-muted-foreground">
|
||||
Captions as they will post
|
||||
</p>
|
||||
{post.x_caption && (
|
||||
<p>
|
||||
<span className="font-medium">X:</span> {post.x_caption}
|
||||
</p>
|
||||
)}
|
||||
{post.tiktok_caption && (
|
||||
<p>
|
||||
<span className="font-medium">TikTok:</span> {post.tiktok_caption}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// One row of the queue: an MP4 preview (9:16 / 1:1 cut switcher) + per-
|
||||
// platform editable captions + approve/reject. Mirrors XPostRow. Unchecking
|
||||
// a platform's "Edit ... caption" box leaves it disabled (shown, not sent) —
|
||||
@@ -134,6 +185,11 @@ function VideoPostRow({
|
||||
const tiktokOverLimit =
|
||||
editTiktok && tiktokCaption.length > MAX_TIKTOK_CAPTION_CHARS;
|
||||
const overLimit = xOverLimit || tiktokOverLimit;
|
||||
// The re-render endpoint only requires a completed authoring task with a
|
||||
// proposed composition — it doesn't require a failed render, so the
|
||||
// button shows for ANY draft that carries both, regardless of
|
||||
// render_status (a healthy render can be deliberately redone too).
|
||||
const canRerender = !!post.source_task_id && !!post.composition_id;
|
||||
|
||||
const handleApprove = () => {
|
||||
onApprove(post.task_id, {
|
||||
@@ -148,6 +204,11 @@ function VideoPostRow({
|
||||
<meta.icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{meta.label}</span>
|
||||
{post.occasion && <Badge variant="outline">{post.occasion}</Badge>}
|
||||
{canRerender && (
|
||||
<div className="ml-auto">
|
||||
<RerenderControl authoringTaskId={post.source_task_id as string} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mb-1 text-sm font-medium">{post.title}</p>
|
||||
@@ -157,6 +218,8 @@ function VideoPostRow({
|
||||
</p>
|
||||
)}
|
||||
|
||||
<CompositionPreviewPanel post={post} cut={cut} />
|
||||
|
||||
<div className="mb-3 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
@@ -176,7 +239,9 @@ function VideoPostRow({
|
||||
size="sm"
|
||||
variant={cut === "square" ? "default" : "outline"}
|
||||
disabled={!post.mp4_paths?.square}
|
||||
title={post.mp4_paths?.square ? undefined : "1:1 hasn't rendered yet"}
|
||||
title={
|
||||
post.mp4_paths?.square ? undefined : "1:1 hasn't rendered yet"
|
||||
}
|
||||
onClick={() => setCut("square")}
|
||||
>
|
||||
1:1{!post.mp4_paths?.square && " (missing)"}
|
||||
@@ -294,6 +359,14 @@ function RequestVideoDialog({
|
||||
const [occasion, setOccasion] = useState("");
|
||||
const [brief, setBrief] = useState("");
|
||||
const [platforms, setPlatforms] = useState<string[]>(["x", "tiktok"]);
|
||||
// null means "no explicit pick yet" — derived below to the current (first)
|
||||
// video-enabled project, mirroring the caption-tracking pattern elsewhere
|
||||
// in this file (`editedX ?? post.x_caption`) rather than syncing via effect.
|
||||
const [projectId, setProjectId] = useState<string | null>(null);
|
||||
const { data: allProjects = [] } = useProjects();
|
||||
const videoProjects = allProjects.filter((p) => p.video_engine_enabled);
|
||||
const hasVideoProjects = videoProjects.length > 0;
|
||||
const effectiveProjectId = projectId ?? videoProjects[0]?.id ?? null;
|
||||
|
||||
const requestMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
@@ -301,6 +374,7 @@ function RequestVideoDialog({
|
||||
occasion: occasion.trim(),
|
||||
brief: brief.trim(),
|
||||
platforms,
|
||||
project_id: effectiveProjectId as string,
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
if (result.status === "opened") {
|
||||
@@ -309,6 +383,7 @@ function RequestVideoDialog({
|
||||
setOccasion("");
|
||||
setBrief("");
|
||||
setPlatforms(["x", "tiktok"]);
|
||||
setProjectId(null);
|
||||
} else {
|
||||
toast.warning(result.detail);
|
||||
}
|
||||
@@ -328,6 +403,7 @@ function RequestVideoDialog({
|
||||
};
|
||||
|
||||
const canSubmit =
|
||||
!!effectiveProjectId &&
|
||||
occasion.trim().length > 0 &&
|
||||
brief.trim().length > 0 &&
|
||||
platforms.length > 0;
|
||||
@@ -343,58 +419,85 @@ function RequestVideoDialog({
|
||||
rendering finishes.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="video-request-occasion">Occasion</Label>
|
||||
<Input
|
||||
id="video-request-occasion"
|
||||
placeholder="e.g. v0.19.0 launch, Founder's Day..."
|
||||
value={occasion}
|
||||
onChange={(e) => setOccasion(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="video-request-brief">Brief</Label>
|
||||
<Textarea
|
||||
id="video-request-brief"
|
||||
placeholder="What should this video cover?"
|
||||
value={brief}
|
||||
onChange={(e) => setBrief(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Platforms</Label>
|
||||
<div className="flex gap-4">
|
||||
{REQUEST_PLATFORMS.map((platform) => (
|
||||
<div key={platform} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`video-request-${platform}`}
|
||||
checked={platforms.includes(platform)}
|
||||
onCheckedChange={() => togglePlatform(platform)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`video-request-${platform}`}
|
||||
className="text-sm font-normal"
|
||||
>
|
||||
{PLATFORM_LABELS[platform]}
|
||||
</Label>
|
||||
{hasVideoProjects ? (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Project</Label>
|
||||
<ProjectSelector
|
||||
value={effectiveProjectId}
|
||||
onChange={setProjectId}
|
||||
placeholder="Select the project this video is about..."
|
||||
allowClear={false}
|
||||
videoEngineOnly
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="video-request-occasion">Occasion</Label>
|
||||
<Input
|
||||
id="video-request-occasion"
|
||||
placeholder="e.g. v0.19.0 launch, Founder's Day..."
|
||||
value={occasion}
|
||||
onChange={(e) => setOccasion(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="video-request-brief">Brief</Label>
|
||||
<Textarea
|
||||
id="video-request-brief"
|
||||
placeholder="What should this video cover?"
|
||||
value={brief}
|
||||
onChange={(e) => setBrief(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Platforms</Label>
|
||||
<div className="flex gap-4">
|
||||
{REQUEST_PLATFORMS.map((platform) => (
|
||||
<div key={platform} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`video-request-${platform}`}
|
||||
checked={platforms.includes(platform)}
|
||||
onCheckedChange={() => togglePlatform(platform)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`video-request-${platform}`}
|
||||
className="text-sm font-normal"
|
||||
>
|
||||
{PLATFORM_LABELS[platform]}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => requestMutation.mutate()}
|
||||
disabled={!canSubmit || requestMutation.isPending}
|
||||
>
|
||||
{requestMutation.isPending ? "Requesting..." : "Request"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => requestMutation.mutate()}
|
||||
disabled={!canSubmit || requestMutation.isPending}
|
||||
>
|
||||
{requestMutation.isPending ? "Requesting..." : "Request"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No projects have the video engine enabled yet. Turn it on for a
|
||||
project in its edit dialog (Projects → Edit) before requesting a
|
||||
video.
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { videoApi } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
/**
|
||||
* Shared re-render control — renders a button + confirm dialog for re-triggering
|
||||
* video render operations. Used by both video-post-queue.tsx (a rendered draft)
|
||||
* and video-pipeline-strip.tsx (a still-in-flight authoring task).
|
||||
*
|
||||
* @component
|
||||
*
|
||||
* Gating Logic:
|
||||
* - Shows for any task with source_task_id + composition_id, regardless of
|
||||
* render_status. The backend's rerender endpoint only requires a completed
|
||||
* authoring task with a proposed composition, not a failed render, so a
|
||||
* healthy render can be deliberately redone too.
|
||||
*
|
||||
* Behavior:
|
||||
* - Trigger button opens a Dialog with Cancel and Re-render buttons (guards
|
||||
* against accidental clicks, since re-rendering discards whatever already
|
||||
* rendered).
|
||||
* - Only the confirm click calls videoApi.rerender(authoringTaskId).
|
||||
* - Three visual states on the trigger button:
|
||||
* 1. Idle: "Re-render" (ready to click)
|
||||
* 2. Loading: "Re-rendering..." with spinning icon (mutation in-flight)
|
||||
* 3. Error: "Retry re-render" in red (the retry itself failed; button stays
|
||||
* enabled so the CEO can try again).
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Usage in video-post-queue for a rendered draft:
|
||||
* {canRerender && (
|
||||
* <div className="ml-auto">
|
||||
* <RerenderControl authoringTaskId={post.source_task_id as string} />
|
||||
* </div>
|
||||
* )}
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Usage in video-pipeline-strip for an in-flight authoring task:
|
||||
* {canRerender && (
|
||||
* <div className="ml-auto">
|
||||
* <RerenderControl authoringTaskId={item.task_id} />
|
||||
* </div>
|
||||
* )}
|
||||
* ```
|
||||
*/
|
||||
export function RerenderControl({
|
||||
authoringTaskId,
|
||||
}: {
|
||||
/**
|
||||
* The authoring task ID to re-render. Passed to videoApi.rerender(authoringTaskId).
|
||||
* For video-post-queue: the post's source_task_id.
|
||||
* For video-pipeline-strip: the pipeline item's own task_id (a pipeline item IS
|
||||
* the authoring task).
|
||||
*/
|
||||
authoringTaskId: string;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const rerenderMutation = useMutation({
|
||||
mutationFn: () => videoApi.rerender(authoringTaskId),
|
||||
onSuccess: () => {
|
||||
toast.success("Re-render queued — it will re-pick up on the next cycle.");
|
||||
queryClient.invalidateQueries({ queryKey: ["video", "pipeline"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["video", "posts"] });
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(
|
||||
`Re-render failed: ${e instanceof Error ? e.message : "error"}`,
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={rerenderMutation.isPending}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
className={
|
||||
rerenderMutation.isError
|
||||
? "border-destructive text-destructive"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-1 h-4 w-4 ${rerenderMutation.isPending ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{rerenderMutation.isPending
|
||||
? "Re-rendering..."
|
||||
: rerenderMutation.isError
|
||||
? "Retry re-render"
|
||||
: "Re-render"}
|
||||
</Button>
|
||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Re-render this video?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This clears the current render and queues a fresh one from the
|
||||
same composition. Any already-rendered cuts stay visible until
|
||||
the new render finishes.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setConfirmOpen(false);
|
||||
rerenderMutation.mutate();
|
||||
}}
|
||||
>
|
||||
Re-render
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { Team } from "@/types";
|
||||
import type { ProjectSummary } from "@/types";
|
||||
|
||||
const { useProjects } = vi.hoisted(() => ({ useProjects: vi.fn() }));
|
||||
vi.mock("@/hooks/use-projects", () => ({ useProjects }));
|
||||
|
||||
// Make the Select testable without Radix's portal/pointer machinery — mirrors
|
||||
// a2a-reply-composer.test.tsx: SelectContent always renders its children, so
|
||||
// the assertions below can query rendered project names directly.
|
||||
vi.mock("@/components/ui/select", () => ({
|
||||
Select: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectValue: ({ placeholder }: { placeholder?: string }) => (
|
||||
<span>{placeholder}</span>
|
||||
),
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectGroup: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectLabel: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectItem: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import { ProjectSelector } from "../project-selector";
|
||||
|
||||
function project(overrides: Partial<ProjectSummary>): ProjectSummary {
|
||||
return {
|
||||
id: "p-id",
|
||||
name: "project",
|
||||
slug: "project",
|
||||
git_url: "https://example.com/project.git",
|
||||
assigned_cell: Team.FRONTEND,
|
||||
is_active: true,
|
||||
has_workspace: true,
|
||||
has_git_token: true,
|
||||
video_engine_enabled: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ProjectSelector", () => {
|
||||
it("videoEngineOnly excludes projects that have not opted into the video engine", () => {
|
||||
useProjects.mockReturnValue({
|
||||
data: [
|
||||
project({ id: "p-1", name: "Video Ready", video_engine_enabled: true }),
|
||||
project({
|
||||
id: "p-2",
|
||||
name: "Not Opted In",
|
||||
video_engine_enabled: false,
|
||||
}),
|
||||
],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<ProjectSelector value={null} onChange={vi.fn()} videoEngineOnly />);
|
||||
|
||||
expect(screen.getByText("Video Ready")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Not Opted In")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows every project when videoEngineOnly is not set", () => {
|
||||
useProjects.mockReturnValue({
|
||||
data: [
|
||||
project({ id: "p-1", name: "Video Ready", video_engine_enabled: true }),
|
||||
project({
|
||||
id: "p-2",
|
||||
name: "Not Opted In",
|
||||
video_engine_enabled: false,
|
||||
}),
|
||||
],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<ProjectSelector value={null} onChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText("Video Ready")).toBeInTheDocument();
|
||||
expect(screen.getByText("Not Opted In")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,10 @@ interface ProjectSelectorProps {
|
||||
filterByTeam?: Team;
|
||||
disabled?: boolean;
|
||||
allowClear?: boolean;
|
||||
// Restrict the list to projects with the video engine opted in
|
||||
// (project.video_engine_enabled) — for pickers scoped to video-authoring
|
||||
// flows (e.g. RequestVideoDialog).
|
||||
videoEngineOnly?: boolean;
|
||||
}
|
||||
|
||||
// Team display names for project cells
|
||||
@@ -38,6 +42,7 @@ export function ProjectSelector({
|
||||
filterByTeam,
|
||||
disabled = false,
|
||||
allowClear = true,
|
||||
videoEngineOnly = false,
|
||||
}: ProjectSelectorProps) {
|
||||
const { data: projects = [], isLoading } = useProjects();
|
||||
|
||||
@@ -45,6 +50,11 @@ export function ProjectSelector({
|
||||
const groupedProjects = useMemo(() => {
|
||||
let filtered = projects;
|
||||
|
||||
// Restrict to video-engine-opted-in projects
|
||||
if (videoEngineOnly) {
|
||||
filtered = filtered.filter((p) => p.video_engine_enabled);
|
||||
}
|
||||
|
||||
// Apply team filter
|
||||
if (filterByTeam) {
|
||||
filtered = filtered.filter((p) => p.assigned_cell === filterByTeam);
|
||||
@@ -71,7 +81,7 @@ export function ProjectSelector({
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [projects, filterByTeam]);
|
||||
}, [projects, filterByTeam, videoEngineOnly]);
|
||||
|
||||
// Find selected project for display
|
||||
const selectedProject = useMemo(() => {
|
||||
|
||||
@@ -40,6 +40,7 @@ export const projectsApi = {
|
||||
is_active: p.is_active,
|
||||
has_workspace: !!p.workspace_path,
|
||||
has_git_token: false, // Mock mode has no tokens
|
||||
video_engine_enabled: p.video_engine_enabled,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,12 @@ export interface VideoPost {
|
||||
reject_reason?: string | null;
|
||||
mp4_paths?: Record<string, string>;
|
||||
source_task_id?: string | null; // the authoring task this draft rendered from
|
||||
// The following mirror the shared `video_draft` marker's render idempotency
|
||||
// keys (same fields as VideoPipelineItem) — optional because a draft may
|
||||
// predate the backend exposing them on this response. Undefined reads as
|
||||
// "not stale" everywhere below, so the re-render control degrades safely.
|
||||
composition_id?: string | null;
|
||||
render_status?: string | null; // null | "rendered" | "failed"
|
||||
}
|
||||
|
||||
// One in-flight source=video authoring task — GET /video/pipeline
|
||||
@@ -86,6 +92,20 @@ export function videoMediaUrl(taskId: string, cut: VideoCut): string {
|
||||
return `${API_URL}/video/posts/${taskId}/media?cut=${cut}`;
|
||||
}
|
||||
|
||||
// GET /video/preview/{task_id}/{file_path:path} (roboco/api/routes/video.py)
|
||||
// serves a video-authoring task's composition HTML + sibling assets directly
|
||||
// from the project's merged read-clone, with iframe-permitting headers — the
|
||||
// panel's live composition preview embeds this URL as an <iframe src>
|
||||
// directly (unlike the MP4 media route, no auth-header workaround needed).
|
||||
export function compositionPreviewUrl(
|
||||
authoringTaskId: string,
|
||||
compositionId: string,
|
||||
cut: VideoCut,
|
||||
): string {
|
||||
const filePath = `motion/compositions/${compositionId}/${cut}.html`;
|
||||
return `${API_URL}/video/preview/${authoringTaskId}/${filePath}`;
|
||||
}
|
||||
|
||||
export const videoApi = {
|
||||
listPosts: async (): Promise<VideoPost[]> => {
|
||||
const { data } = await api.get<VideoPost[]>("/video/posts");
|
||||
@@ -136,10 +156,18 @@ export const videoApi = {
|
||||
occasion: string;
|
||||
brief: string;
|
||||
platforms: string[];
|
||||
project_id: string;
|
||||
}): Promise<VideoRequestResult> => {
|
||||
const { data } = await api.post<VideoRequestResult>("/video/request", body);
|
||||
return data;
|
||||
},
|
||||
// POST /video/pipeline/{task_id}/rerender (roboco/api/routes/video.py) —
|
||||
// clears the authoring task's render idempotency keys so the render loop
|
||||
// re-picks it up. taskId is the *authoring* task (VideoPost.source_task_id),
|
||||
// not the held draft's own task_id.
|
||||
rerender: async (authoringTaskId: string): Promise<void> => {
|
||||
await api.post(`/video/pipeline/${authoringTaskId}/rerender`);
|
||||
},
|
||||
getCredentialsStatus: async (): Promise<TikTokCredentialsStatus> => {
|
||||
const { data } = await api.get<TikTokCredentialsStatus>(
|
||||
"/tiktok/credentials",
|
||||
|
||||
@@ -1087,6 +1087,7 @@ export interface ProjectSummary {
|
||||
is_active: boolean;
|
||||
has_workspace: boolean;
|
||||
has_git_token: boolean;
|
||||
video_engine_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ProductCellMapping {
|
||||
|
||||
Reference in New Issue
Block a user