[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:
Renzo F
2026-07-11 07:39:17 +02:00
committed by GitHub
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
29 changed files with 2707 additions and 107 deletions
+218
View File
@@ -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(() => {
+1
View File
@@ -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,
}));
}
+28
View File
@@ -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",
+1
View File
@@ -1087,6 +1087,7 @@ export interface ProjectSummary {
is_active: boolean;
has_workspace: boolean;
has_git_token: boolean;
video_engine_enabled: boolean;
}
export interface ProductCellMapping {