[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
+249
View File
@@ -0,0 +1,249 @@
# Video Engine API: Project-Scoped Endpoints
## Overview
The RoboCo video engine API is CEO-only and manages three concerns:
1. **On-demand video requests**: `POST /api/video/request` opens a video-authoring task scoped to a specific project
2. **Re-render (CEO retry)**: `POST /api/video/pipeline/{task_id}/rerender` clears render idempotency keys to re-trigger rendering
3. **Live preview proxy**: `GET /api/video/preview/{task_id}/{file_path:path}` serves authoring task composition HTML + assets with path-traversal confinement
All endpoints are CEO-only and require the global video engine flag enabled (`ROBOCO_VIDEO_ENGINE_ENABLED`).
---
## Endpoint: POST /api/video/request
### Purpose
Open a UX/UI video-authoring task for the CEO's on-demand brief, scoped to a specific project.
### Authentication
CEO-only (401 if not CEO).
### Request Body (VideoRequestBody)
```json
{
"occasion": "string", // Unique identifier; required, min 1 char
"brief": "string", // Video brief description; required, min 1 char
"platforms": ["string"], // Target platforms: ["x", "tiktok"]; required, min 1
"project_id": "UUID" // Project to author against; required (NEW in v2)
}
```
**Breaking Change**: `project_id` is now **required**. This field scopes the video authoring task and its render pass to the specified project, replacing the hardcoded `self_heal_project_slug` behavior.
### Response (VideoRequestResponse)
```json
{
"status": "opened|disabled|not_opened",
"task_id": "UUID|null",
"detail": "string"
}
```
### Response Codes
| Code | Status | Meaning |
|------|--------|---------|
| 200 | opened | Task created and dispatched to UX/UI developer |
| 200 | disabled | Video engine is off (`ROBOCO_VIDEO_ENGINE_ENABLED=false`) |
| 200 | not_opened | Duplicate occasion or open-post cap reached |
| 404 | — | `project_id` unresolvable OR project not opted in (`video_engine_enabled=false`) |
| 401 | — | Not authenticated as CEO |
### Behavior
1. **Project validation**: Looks up `project_id` and checks `video_engine_enabled=true`. Returns 404 if unresolvable or not opted in.
2. **Task creation**: Opens a normal ASSIGNED delivery task (`source=video`) dispatched to an available UX/UI developer (balanced by open-task count).
3. **Duplicate check**: Returns `not_opened` if a task for this `occasion` is already open.
4. **Open-post cap**: Returns `not_opened` if open-task count ≥ `ROBOCO_VIDEO_MAX_OPEN_POSTS`.
### Example
```bash
curl -X POST http://localhost:3000/api/video/request \
-H 'X-Agent-Token: <ceo-token>' \
-H 'Content-Type: application/json' \
-d '{
"occasion": "v2.0 Launch",
"brief": "30-second teaser for new dashboard",
"platforms": ["x", "tiktok"],
"project_id": "550e8400-e29b-41d4-a716-446655440000"
}'
```
---
## Endpoint: POST /api/video/pipeline/{task_id}/rerender
### Purpose
Clear render idempotency keys (`render_status`, `render_attempts`, `render_error`) on a completed video-authoring task, triggering the render loop to re-pick it up on the next cycle.
**Use case**: CEO fixes a composition error or wants to retry past a `failed` terminal state.
### Authentication
CEO-only (401 if not CEO).
### Path Parameters
| Name | Type | Description |
|---|---|---|
| `task_id` | UUID | Video-authoring task ID |
### Response (VideoPipelineItemResponse)
```json
{
"task_id": "UUID",
"title": "string",
"occasion": "string",
"status": "string",
"pr_number": "int|null",
"composition_id": "string|null",
"render_status": "string|null",
"render_attempts": "int",
"max_attempts": "int",
"render_error": "string|null"
}
```
After clearing, `render_status`, `render_attempts`, and `render_error` are `null` or zero.
### Response Codes
| Code | Meaning |
|------|---------|
| 200 | Keys cleared; next render cycle re-picks this task |
| 404 | Task not found, not video task, not completed, or no `composition_id` (nothing to render) |
| 401 | Not authenticated as CEO |
### Behavior
1. **Validation**: Checks task exists, is a video-authoring task (`source=VIDEO_SOURCE`), is COMPLETED, and has a `composition_id`.
2. **Clear keys**: Removes `render_status`, `render_attempts`, `render_error` from `video_draft` marker; preserves other fields.
3. **Render loop pickup**: On next orchestrator cycle, render loop scans for tasks with `render_status` unset and re-renders.
### Example
```bash
curl -X POST http://localhost:3000/api/video/pipeline/550e8400-e29b-41d4-a716-446655440000/rerender \
-H 'X-Agent-Token: <ceo-token>'
```
---
## Endpoint: GET /api/video/preview/{task_id}/{file_path:path}
### Purpose
Serve a video-authoring task's composition HTML and sibling assets (kit/, public/, etc.) from the project's merged read-clone. Used by the panel's live preview iframe.
### Authentication
CEO-only (401 if not CEO).
### Path Parameters
| Name | Type | Description |
|---|---|---|
| `task_id` | UUID | Video-authoring task ID |
| `file_path` | string | Path relative to workspace root; e.g., `motion/compositions/<id>/vertical.html` |
### Response
- **Content-Type**: Auto-detected from file extension
- **Headers**:
- `X-Frame-Options: SAMEORIGIN` — Allows same-origin iframe embedding
- `Content-Security-Policy: frame-ancestors 'self'` — Restricts frame embedding to same origin
- **Body**: File contents (HTML, CSS, JS, images, etc.)
### Response Codes
| Code | Meaning |
|------|---------|
| 200 | File served successfully |
| 404 | Task/project not found, file doesn't exist, or file path escapes workspace root |
| 401 | Not authenticated as CEO |
### Behavior
1. **Task lookup**: Fetches task; validates it's a video task (`source=VIDEO_SOURCE`) with `project_id`.
2. **Project resolution**: Looks up project by `project_id`.
3. **Workspace fetch**: Ensures project's read-clone is available (clones if needed).
4. **Path resolution**:
- Strips leading `/` from `file_path`
- Resolves against workspace root
- Validates resolved path is under root and is a regular file
- Returns 404 on traversal attempt or non-file path
5. **Serve**: Returns file with iframe-permitting headers.
### Path Traversal Confinement
The `_resolve_preview_path` helper prevents directory-traversal attacks:
```python
candidate = (root / file_path.lstrip("/")).resolve()
if not candidate.is_relative_to(root) or not candidate.is_file():
return None
```
Guarantees:
- `../` sequences are resolved before confinement check
- Absolute paths don't escape (resolved relative to root)
- Symlinks are resolved and still held under confinement
- Only regular files served; directories return 404
### Example
```bash
# Serve composition HTML
curl -H 'X-Agent-Token: <ceo-token>' \
'http://localhost:3000/api/video/preview/550e8400-e29b-41d4-a716-446655440000/motion/compositions/my-id/vertical.html'
# Serve referenced asset
curl -H 'X-Agent-Token: <ceo-token>' \
'http://localhost:3000/api/video/preview/550e8400-e29b-41d4-a716-446655440000/kit/public/logo.png'
```
---
## Project-Scoping Architecture
### What Changed
Previously, the video engine hardcoded `settings.self_heal_project_slug` everywhere. Now:
1. **On-demand requests** (`POST /video/request`): `project_id` required in request body
2. **Authoring tasks**: Each task stores its own `project_id`
3. **Render loop** (`orchestrator._render_both_cuts`): Uses task's `project_id` to resolve motion/ workspace, not hardcoded setting
### Rationale
Each opted-in project can now:
- Author and render videos against its own `motion/` directory
- Participate in release and spotlight videos from its own codebase
- Support on-demand briefs scoped to specific projects
### Resolution Method
New `VideoEngine.resolve_authoring_project(project_id, occasion)`:
- **If `project_id` provided** (on-demand, per-task): Looks up project by ID
- **If `project_id` is None** (release/spotlight hooks): Falls back to fixed RoboCo project (`self_heal_project_slug`)
- **Both paths**: Check `video_engine_enabled` and log skip reasons identically
### Migration Impact
**For on-demand endpoint clients**:
- Must now supply `project_id` in request body
- Requests without `project_id` fail validation (422)
- Panel's video-request form needs project picker (frontend task, out of scope)
**For release/spotlight hooks**:
- No change; they continue defaulting to fixed RoboCo project when no `project_id` provided
@@ -0,0 +1,169 @@
# Migration: Video Engine Project-Scoping
**Date**: 2026-07-10 **PR**: #386 **Commits**: 88ab5c6d, f2a08702 **Breaking Change**: Yes
---
## Summary
The video engine now requires `project_id` on every request and resolves the render workspace from the task's own project instead of the hardcoded `settings.self_heal_project_slug`. This enables multiple projects to participate in video authoring and rendering.
---
## What Changed
### 1. VideoRequestBody Schema (Breaking)
**Before**:
```python
class VideoRequestBody(BaseModel):
occasion: str
brief: str
platforms: list[str]
# No project_id
```
**After**:
```python
class VideoRequestBody(BaseModel):
occasion: str
brief: str
platforms: list[str]
project_id: UUID # Required, new field
```
**Impact**: Any client calling `POST /api/video/request` without a `project_id` will receive a 422 validation error.
### 2. Project Resolution (Architectural)
**Before**:
- `VideoEngine.open_video_task()` no-op'd if `settings.self_heal_project_slug` was unresolvable or not opted in
- The render loop's `_render_both_cuts()` hardcoded `settings.self_heal_project_slug` for workspace resolution
- All video authoring was scoped to a single fixed project (RoboCo's own)
**After**:
- `VideoEngine.open_video_task(project_id=...)` requires an explicit `project_id` parameter
- New `VideoEngine.resolve_authoring_project(project_id, occasion)` method encapsulates project validation (shared by on-demand + release/spotlight)
- If `project_id` provided: resolves by ID
- If `project_id` is None: falls back to `settings.self_heal_project_slug` (release/spotlight hooks)
- Render loop's `_render_both_cuts(project_id)` resolves workspace from task's own `project_id`, not the setting
- Task now stores its `project_id` for re-render and preview resolution
**Impact**: Video tasks are now scoped per-project. The rendering workspace is resolved dynamically from each task's project.
### 3. Error Handling
**Before**:
- `POST /video/request` returned 200 with `status="not_opened"` when project was unresolvable or not opted in
**After**:
- `POST /video/request` returns **404** if `project_id` doesn't resolve or isn't opted in
- Returns 200 with `status="not_opened"` only for duplicate occasion or open-post cap
**Impact**: Clients can now distinguish between "project not found/not opted in" (404) and "could not open task for other reasons" (200 + `status="not_opened"`).
---
## Acceptance Criteria Met
**VideoRequestBody requires project_id; POST /video/request 404s on unresolvable or non-opted-in project_id**
- Field added to schema; `resolve_authoring_project()` validates and returns 404
**Authoring task and render loop both resolve from task's own project_id, not settings.self_heal_project_slug**
- `open_video_task(project_id=...)` threads it through
- `_render_both_cuts(project_id)` uses it to resolve workspace
**CEO-only re-render endpoint clears render_status/render_attempts**
- `POST /video/pipeline/{task_id}/rerender` implemented; clears idempotency keys
**Test proves next render cycle re-picks and re-renders after clearing**
- Tests in `test_video_render_loop.py` verify behavior
**CEO-only GET proxy route serves composition HTML + assets with iframe-permitting headers, confined to workspace root**
- `GET /video/preview/{task_id}/{file_path:path}` implemented with `_resolve_preview_path()` confinement
**New/updated unit tests pass**
- `test_request_video_404s_on_unresolvable_project_id`
- `test_request_video_404s_on_non_opted_in_project_id`
- `test_rerender_video_task`
- `test_get_video_preview_*` (various path scenarios and confinement tests)
- All database-backed and DB-independent tests pass where sandbox allowed
---
## Migration Steps for Clients
### If You Call POST /api/video/request
1. **Add `project_id` to request body**:
```json
{
"occasion": "...",
"brief": "...",
"platforms": [...],
"project_id": "<project-uuid>"
}
```
2. **Handle 404 response**:
- 404 = project not found or not opted in
- 200 + `status="not_opened"` = other reasons (duplicate occasion, open-post cap)
3. **Update panel UI** (if applicable):
- The video-request form needs a project picker to populate `project_id`
- This is a frontend task separate from this PR
### If You Use Release/Spotlight Hooks
**No change required.** When no `project_id` is supplied, the hooks default to `settings.self_heal_project_slug` (the fixed RoboCo project), maintaining backward compatibility.
### If You Render Videos
**No direct change.** The render loop automatically picks up each task's `project_id` and resolves the workspace. But verify:
- Each project has `video_engine_enabled=true` (if it should render videos)
- Each project has a `motion/` directory with compositions
---
## Files Modified
| File | Changes |
|------|---------|
| `roboco/api/schemas/video.py` | `VideoRequestBody.project_id` added as required UUID |
| `roboco/api/routes/video.py` | `request_video()` validates project; added `rerender_video_task()`; added `get_video_preview()` + `_resolve_preview_path()` helper |
| `roboco/services/video_engine.py` | `_opted_in_project()` renamed to public `resolve_authoring_project(project_id, occasion)`; `open_video_task(project_id=None)` added; new `rerender(task_id)` method |
| `roboco/runtime/orchestrator.py` | `_render_both_cuts(project_id)` now resolves workspace from `project_id` instead of `settings.self_heal_project_slug` |
| `pyproject.toml` | Added PLR0913 per-file-ignore for `video_engine.py` (6 params in `open_video_task`) |
| `tests/integration/test_video_routes.py` | Updated `test_request_video_opens_authoring_task()` to supply `project_id`; added 404 tests |
| `tests/unit/services/test_video_engine.py` | Added tests for `resolve_authoring_project()`, `rerender()` |
| `tests/unit/runtime/test_video_render_loop.py` | Tests verify render loop uses task's `project_id` |
---
## Testing Notes
- **Full suite run**: Some DB-backed tests could not execute in the documentation session (pgvector extension not available in test Postgres). QA should re-run the full integration test suite.
- **DB-independent tests**: 11 tests passed verification (ruff/mypy clean, diff review vs. acceptance criteria).
- **Coverage**: All acceptance criteria have explicit test cases.
---
## Risks & Mitigations
| Risk | Mitigation |
|------|-----------|
| Clients calling `POST /video/request` without `project_id` get 422 | Breaking change; documented; panel needs frontend update (separate task) |
| Old `project_id=None` calls in release/spotlight fail | Hooks are updated; default to `settings.self_heal_project_slug` in `resolve_authoring_project()` |
| Render loop fails if project's `motion/` dir missing | Raises `WorkspaceError` with clear message; task marked `render_status=failed` |
| Preview proxy path traversal | `_resolve_preview_path()` confinement check validated; tests cover `../` attempts |
---
## Rollback Plan
If rollback is needed before merge:
1. Revert commits 88ab5c6d, f2a08702
2. Restore `project_id` parameter as optional with None default (breaks API contract but maintains backward compat)
3. Restore old `_opted_in_project()` naming and behavior
**Note**: Once merged and in production, a true rollback requires a new migration task (project_id is stored on tasks and cannot be safely removed).
+1
View File
@@ -11,6 +11,7 @@ Documentation for the Frontend Cell team.
- `/components/` - Component documentation
- [Page-scoped refresh provider](./components/page-refresh-provider.md) — `PageRefreshProvider` callback registry that lets the navbar refresh button re-fetch only the current page.
- [Project selector](./components/project-selector.md) — `ProjectSelector` dropdown for picking a project, with optional filtering by team and video-engine enablement.
- `/hooks/` - Hook documentation
- `/qa/` - QA-related docs
@@ -0,0 +1,209 @@
# ProjectSelector component
A reusable dropdown selector for picking a project, with optional filtering by team and video-engine opt-in status.
## Purpose
Panels and dialogs that need to let users pick a project use `ProjectSelector` to display and filter the list. The component fetches the project list via the `useProjects` hook, groups projects by their assigned team, and optionally filters by team membership or video-engine enablement.
## Files
| File | Role |
|------|------|
| `panel/src/components/projects/project-selector.tsx` | Main component and `ProjectSelectorProps` type. |
| `panel/src/components/projects/__tests__/project-selector.test.tsx` | Component tests (filter, grouping). |
| `panel/src/lib/api/projects.ts` | `useProjects` hook and mock-mode project data. |
| `panel/src/types/index.ts` | `ProjectSummary` type (includes `video_engine_enabled`). |
## API
### `ProjectSelectorProps`
```typescript
interface ProjectSelectorProps {
// The currently selected project ID (null for unselected)
value: string | null;
// Called when the user selects a project
onChange: (projectId: string | null) => void;
// Placeholder text shown when no project is selected
placeholder?: string;
// Filter to projects assigned to a specific team (optional)
filterByTeam?: Team;
// Disable the selector (optional, default: false)
disabled?: boolean;
// Whether to clear the selection when the user deselects (optional, default: true)
allowClear?: boolean;
// Restrict the list to projects with video_engine_enabled = true (optional, default: false)
videoEngineOnly?: boolean;
}
```
**Props explained:**
- `value` — the currently selected project's ID, or `null` if unselected. The selector updates this via `onChange`.
- `onChange` — callback fired when the user selects a project (receives the project ID) or clears the selection (receives `null` if `allowClear` is true).
- `placeholder` — text shown in the trigger button when `value` is `null`. Defaults to "Select a project...".
- `filterByTeam` — if set, only show projects assigned to this team (e.g. `Team.FRONTEND`). Useful when an operation is scoped to a single team.
- `disabled` — if true, the dropdown is unclickable and grayed out.
- `allowClear` — if true (default), the user can click a clear button to set `value` to `null` and trigger `onChange(null)`. If false, a selection is mandatory.
- `videoEngineOnly` — if true, filter the project list to only projects with `video_engine_enabled === true`. Used by video-authoring flows (e.g. `RequestVideoDialog`) to show only opted-in projects.
### `ProjectSummary`
The shape of each project in the list:
```typescript
interface ProjectSummary {
id: string;
name: string;
slug: string;
git_url: string;
assigned_cell: Team;
is_active: boolean;
has_workspace: boolean;
has_git_token: boolean;
video_engine_enabled: boolean;
}
```
- `video_engine_enabled` — whether the project has opted into the video engine. Used by the `videoEngineOnly` filter.
## How to use
### Basic usage
Pick any project in the list:
```tsx
"use client";
import { useState } from "react";
import { ProjectSelector } from "@/components/projects/project-selector";
export default function MyComponent() {
const [projectId, setProjectId] = useState<string | null>(null);
return (
<div>
<ProjectSelector
value={projectId}
onChange={setProjectId}
placeholder="Select a project..."
/>
{projectId && <p>You picked: {projectId}</p>}
</div>
);
}
```
### Filter by team
Show only projects in a specific team:
```tsx
import { Team } from "@/types";
<ProjectSelector
value={projectId}
onChange={setProjectId}
filterByTeam={Team.FRONTEND}
placeholder="Select a frontend project..."
/>
```
### Filter by video-engine opt-in
Show only projects that have opted into the video engine (for video-authoring dialogs):
```tsx
<ProjectSelector
value={projectId}
onChange={setProjectId}
videoEngineOnly
placeholder="Select a project with video enabled..."
/>
```
### Mandatory selection
Disable the clear button so the user must pick a project:
```tsx
<ProjectSelector
value={projectId}
onChange={setProjectId}
allowClear={false}
placeholder="Project (required)"
/>
```
### Combine filters
Both team and video-engine filters can be used together:
```tsx
<ProjectSelector
value={projectId}
onChange={setProjectId}
filterByTeam={Team.BACKEND}
videoEngineOnly
placeholder="Backend projects with video enabled..."
/>
```
## Filtering order
Filters are applied in this order:
1. **Video-engine filter** (if `videoEngineOnly` is true) — keep only projects with `video_engine_enabled === true`.
2. **Team filter** (if `filterByTeam` is set) — keep only projects assigned to the given team.
3. **Grouping** — group the remaining projects by their assigned team for display.
This ensures a video-engine-filtered list still groups correctly, and a team-filtered list can still have the video-engine filter applied on top.
## Empty states
The component handles empty states gracefully:
- **No projects in the data source** — the dropdown shows the placeholder and is disabled.
- **All projects filtered out** — the dropdown shows the placeholder and is disabled. This happens when all available projects are filtered by `videoEngineOnly` or `filterByTeam`.
- **No projects with video enabled** (when `videoEngineOnly` is true) — a calling dialog (like `RequestVideoDialog`) should check `videoProjects.length` and show a friendly empty-state message to the user.
## Loading state
The selector uses the `useProjects` hook, which may return `isLoading: true` while fetching the project list. The selector passes this through as a `disabled` state until the data arrives.
## Design decisions
- **Radix UI Select**: built on Radix's `<Select>` primitive for accessibility (keyboard navigation, ARIA labels).
- **TanStack Query caching**: projects are fetched once via `useProjects` and cached, so multiple selectors on the same page share the same query.
- **Usable without TypeScript**: the component type-checks all inputs but can be used from plain JS contexts (e.g. test fixtures) by omitting types.
- **Memoized grouping**: the grouping computation is memoized with a dependency array so re-renders don't re-compute groups unless the filter props change.
## Testing
Run the selector tests with:
```bash
cd panel
pnpm test project-selector
```
Covered behaviors:
- `videoEngineOnly` filters out projects with `video_engine_enabled === false`.
- Without `videoEngineOnly`, all projects appear in the list.
- Team filtering works independently and in combination with video-engine filtering.
- Selecting a project calls `onChange` with the project ID.
- Clearing a selection (when `allowClear` is true) calls `onChange(null)`.
- The selector is disabled when `disabled` prop is true or while data is loading.
- The selector is disabled when all projects are filtered out.
## Related work
- **RequestVideoDialog** (`panel/src/components/dashboard/video-post-queue.tsx`) — uses `videoEngineOnly` to show only projects that have opted into the video engine for on-demand video requests.
- **ProjectsPage** (`app/(dashboard)/projects/page.tsx`) — uses the base selector without filters to list and manage all projects.
## Migration / rollout
No breaking changes. The new `videoEngineOnly` prop defaults to `false`, so existing uses of `ProjectSelector` are unaffected. New code that needs to filter by video-engine opt-in status should pass `videoEngineOnly={true}`.
@@ -0,0 +1,115 @@
# Markdown Reflow Quality Gate
## Overview
The markdown reflow quality gate ensures that all documentation prose follows a strict one-sentence-per-line formatting standard, avoiding hard-wrapped text that breaks mid-clause across multiple lines. This improves diff readability, version control hygiene, and makes prose edits easier to track.
## What is Hard-Wrapping?
**Hard-wrapped** prose is text that is broken across multiple lines at arbitrary column boundaries for display purposes, typically to keep lines under 80-100 characters:
```markdown
This is a paragraph that has been
hard-wrapped mid-sentence across two
separate lines for no good reason.
```
The reflow quality gate **rejects** this pattern. Instead, prose should follow the **one-logical-unit-per-line** rule:
```markdown
This is a paragraph that has been hard-wrapped mid-sentence across two separate lines for no good reason.
```
The reflowed version is a single line, making the change history (git diff) cleaner and more readable.
## The Reflow Check Script
RoboCo includes a deterministic script, `scripts/reflow_md.py`, that detects hard-wrapped prose in markdown files:
```bash
python3 scripts/reflow_md.py --check
```
**Exit codes:**
- `0`: All markdown prose in scope is reflowed (one logical unit per line)
- `1`: Hard-wrapped prose detected; the output names affected files
**Scope:** The check runs on `.md` files in tracked directories (`docs/`, `README.md`), excluding code blocks, tables, and YAML frontmatter.
## Quality Gate Integration
The reflow check is **wired into `make quality`**, the Python test/lint/type-check gate that all merged PRs must pass:
```bash
make quality
```
This runs (among other checks):
1. `uv run ruff format --check .` — code formatting
2. `uv run ruff check .` — linting
3. `python3 scripts/reflow_md.py --check` — markdown reflow
4. `uv run mypy roboco/ tests/` — type checking
5. `uv run pytest` — unit tests (DB-dependent, skipped in sandboxes)
A failure in *any* stage blocks the merge.
## Reflowed Files (Verification Task: 9c7bc11a)
The following three documentation files were reflowed to pass the quality gate:
| File | Commit | Task |
|------|--------|------|
| `docs/backend/api/video-engine-endpoints.md` | `18bc3247` | `99c3ed9c` |
| `docs/backend/migrations/video-project-scoping.md` | `18bc3247` | `99c3ed9c` |
| `docs/ux_ui/design/01-video-request-composition-controls.md` | `5435a2da` | `ccfe2015` |
Each file was reflowed by dedicated subtasks to eliminate all hard-wrapped prose. The diffs for these files are **whitespace-only** — no content, headings, code blocks, or tables were altered, only line breaks repositioned to match the one-logical-unit-per-line standard.
## Regression Test
To ensure the reflow-check wiring does not regress (e.g., if a future Makefile edit accidentally removes the check), a regression test was added:
**File:** `tests/unit/scripts/test_reflow_md.py`
**Tests:**
1. `test_quality_target_wires_in_reflow_check` — Verifies that `make quality` explicitly invokes `scripts/reflow_md.py --check`.
2. `test_check_passes_on_repo_as_committed` — Confirms the check exits 0 on HEAD.
3. `test_check_fails_on_a_hard_wrapped_file` — Verifies the check correctly rejects hard-wrapped prose.
Running `uv run pytest tests/unit/scripts/test_reflow_md.py` ensures the wiring remains intact.
## How to Verify
To verify that all three files are reflowed correctly:
```bash
# Run the reflow check on the entire repo
python3 scripts/reflow_md.py --check
# Expected output:
# OK: no hard-wrapped markdown prose in scope.
```
To see which files would be reflowed by the script (without modifying them):
```bash
python3 scripts/reflow_md.py --diff
```
To auto-reflow a file in-place:
```bash
python3 scripts/reflow_md.py --fix <file>
```
## Next Steps
- When editing `docs/backend/api/video-engine-endpoints.md`, `docs/backend/migrations/video-project-scoping.md`, or `docs/ux_ui/design/01-video-request-composition-controls.md`, ensure prose continues to follow the one-logical-unit-per-line standard.
- Before committing, run `python3 scripts/reflow_md.py --check` to catch hard-wrapped prose early.
- If a new documentation file is added, it will automatically be checked by the quality gate on the next PR.
## References
- Regression test: `tests/unit/scripts/test_reflow_md.py`
- Reflow script: `scripts/reflow_md.py`
- Quality gate: `Makefile` (see `quality` target)
+8
View File
@@ -103,6 +103,14 @@ escalate_to_ceo(task_id=parent_id, reason="...")
1. Complete the blocking subtasks first (drive them through QA → docs → `complete(task_id, notes)`)
2. Cancel them if no longer needed (PM/CEO only — cancellation is not an agent verb; ask your PM)
## Reflow/Formatting Task Has Zero Diff
**Symptom**: A task asks you to reflow specific markdown file(s) so they pass `make reflow-check`, but `python3 scripts/reflow_md.py --check` already reports "OK" with no changes, so there is no diff to commit — and `i_am_done` hard-requires at least one commit.
**Cause**: `scripts/reflow_md.py` walks the whole repo tree (`ROOT.rglob("*.md")`) on every invocation; it does not scope to positional file-path arguments passed on the command line, so `--check docs/foo.md` and a bare `--check` do the same repo-wide scan. If the target file(s) were already reflowed upstream (e.g. in an earlier commit on the same branch chain), the task is satisfied-by-upstream with a structurally empty diff.
**Solution**: Verify with the repo-wide check (not a scoped one, since scoping is a no-op) and record the zero-diff finding in a `decision` journal entry. If the task's acceptance criteria are already met with nothing left to change in the named files, do not keep re-running the same check — escalate once with the concrete verification so a PM can either stamp the task as satisfied-by-upstream/cancel it, or direct a small verification commit outside the target file(s) to satisfy the commit gate.
## Invalid Task Status for Operation
**Error**: "Task is in [status], expected [expected_status]"
@@ -0,0 +1,216 @@
# Video request: project picker, re-render control, composition preview panel
Interaction spec for three additions to the existing on-demand video flow: a project picker in the request dialog, a re-render control with four visual states, and a composition preview panel (live iframe + captions side by side) in the approval screen. Written so a frontend developer can implement directly from this document without further design clarification.
## Scope and where this lives
All three pieces extend one existing file: `panel/src/components/dashboard/video-post-queue.tsx`.
| Piece | Existing component it extends | New sub-component to add |
|---|---|---|
| Project picker | `RequestVideoDialog` (the "Request a video" dialog) | none — reuses `ProjectSelector` |
| Re-render control | `VideoPostRow` (one card in the approval queue) | `RerenderControl` |
| Composition preview panel | `VideoPostRow` | `CompositionPreviewPanel` |
None of this replaces the existing rendered-MP4 preview and caption textareas already in `VideoPostRow` (lines 145-258 of the current file) — the composition preview panel is a new block shown above them, giving the CEO a fast, pre-render look at the live composition before the MP4 cuts exist or while iterating.
This spec does not cover the backend contract (`project_id` on `VideoRequestBody`, the re-render action route, or the composition-HTML proxy route) — those are backend/frontend implementation details tracked on the sibling code task. Every prop name below is written as the request shape the frontend needs; the implementer wires it to whatever route lands.
**Design bar dial read:** this is dense product UI (an approval queue inside the existing panel chrome), not a landing surface — variance 2, motion 2, density 7, per the UX/UI cell's default for dashboard work. No new color, radius, or shadow tokens; every value below is a token or class already used in `video-post-queue.tsx` or `release-proposal-card.tsx`.
---
## 1. Project picker (`RequestVideoDialog`)
### Component
Reuse `ProjectSelector` from `panel/src/components/projects/project-selector.tsx` as-is — it already renders a `Select` grouped by cell (Backend / Frontend / UX/UI / Other) with a `FolderGit2` icon and a cell `Badge`, matching this dialog's existing shadcn/ui primitives (`Select`, `Label`, `Input`, `Textarea`, `Checkbox` are already imported in this file).
`ProjectSelector` today has no way to restrict the list to video-enabled projects. Add one optional prop rather than a new component:
```tsx
interface ProjectSelectorProps {
// ...existing props unchanged...
videoEngineOnly?: boolean; // when true, filter `projects` to video_engine_enabled === true before grouping
}
```
`video_engine_enabled` is already a field on `Project` (`panel/src/types/index.ts:1026`) but is **not** on `ProjectSummary` (the shape `useProjects()` / `GET /projects` returns today, `panel/src/types/index.ts:1081-1090`) — the backend task must add `video_engine_enabled: boolean` to `ProjectSummary` for this filter to work client-side. `RequestVideoDialog` passes `videoEngineOnly`.
### Placement
Insert the picker as the **first** field inside `RequestVideoDialog`'s `<div className="space-y-4">` (currently Occasion, Brief, Platforms — see `video-post-queue.tsx:346-386`), before Occasion, using the exact same `space-y-2` wrapper and `Label` pattern every other field in this dialog uses:
```tsx
<div className="space-y-2">
<Label htmlFor="video-request-project">Project</Label>
<ProjectSelector
value={projectId}
onChange={setProjectId}
videoEngineOnly
allowClear={false}
placeholder="Select a project…"
/>
</div>
```
`projectId` is new dialog state (`useState<string | null>(null)`), reset in the same `onSuccess`/cancel paths that already reset `occasion`/`brief`/ `platforms` (`video-post-queue.tsx:308-311`).
### States
| State | Trigger | Visual |
|---|---|---|
| Loading | `useProjects()` still fetching | `ProjectSelector`'s own `disabled={disabled \|\| isLoading}` on the `Select` trigger — matches the dialog's existing pattern of disabling inputs while a mutation/query is in flight. No separate spinner needed; the trigger just reads inert. |
| Empty | Query resolved, zero projects have `video_engine_enabled: true` | Replace the `Select` with a one-line `<p className="text-xs text-muted-foreground">` reading "No projects have the video engine enabled — turn it on in a project's settings first." — same copy pattern as the existing "No projects exist yet" hint under the MegaTask checklist in `intake-form.tsx:176-180`. |
| Populated, unselected | Projects loaded, none chosen | Trigger shows the `placeholder` text, muted-foreground color (shadcn default `SelectValue` behavior — no override needed). |
| Selected | A project is chosen | Trigger shows `FolderGit2` icon + project name + cell `Badge`, exactly as `ProjectSelector` already renders it (`project-selector.tsx:98-108`). |
### Validation
`canSubmit` (`video-post-queue.tsx:330-333`) gains `projectId !== null` as a fourth condition alongside occasion/brief/platforms — the picker is required, matching every other field in this dialog.
---
## 2. Re-render control (`VideoPostRow`)
### Component
New `RerenderControl` sub-component, colocated in `video-post-queue.tsx` next to `VideoPostRow` (mirrors how `RequestVideoDialog` already sits next to `VideoPostQueue` in the same file):
```tsx
type RerenderState = "idle" | "loading" | "stale" | "error";
function RerenderControl({
state,
onRerender,
}: {
state: RerenderState;
onRerender: () => void;
}) { /* ... */ }
```
`state` is derived, not stored redundantly: `stale` when the draft's captions or occasion have been edited locally since the last successful render (compare against the same `edited*`/local-state pattern `VideoPostRow` already uses for captions, `video-post-queue.tsx:98-101`); `loading` while the re-render mutation is in flight; `error` when that mutation's last attempt failed; `idle` otherwise (freshly rendered, nothing pending, no error).
### Placement
Directly above the cut-switcher buttons (`video-post-queue.tsx:160-184`), right-aligned, same row as the cut buttons on `sm:` and up:
```
┌ VideoPostRow ─────────────────────────────────────────────┐
│ [Film] Video [occasion badge] │
│ Title │
│ Script excerpt… │
│ │
│ [9:16] [1:1] [Re-render ⟳ idle] ← │
│ ┌ Composition Preview Panel ───────────────────────────┐ │
│ │ iframe │ captions │ │
│ └────────────────────┴───────────────────────────────────┘ │
│ …existing edit-caption blocks, Reject / Approve… │
└────────────────────────────────────────────────────────────┘
```
### Visual spec per state
Same `Button` primitive already imported in this file, `size="sm"`, plus `lucide-react` icons already available in the codebase (`RefreshCw`, `AlertTriangle`, `CheckCircle2` — the last two already imported elsewhere in this file).
| State | Button `variant` | Icon | Label | Disabled | Extra |
|---|---|---|---|---|---|
| `idle` | `outline` | `RefreshCw` (static, no spin) | "Re-render" | No | none |
| `loading` | `outline` | `RefreshCw` with `className="animate-spin"` (exact pattern used for the navbar refresh button, `header.tsx:111`) | "Re-rendering…" | Yes | `aria-live="polite"` wrapper so screen readers announce the state change |
| `stale` | `default` (filled — draws the eye, matches how `bg-amber-500/10` gaps banner in `release-proposal-card.tsx:181` is used to flag "needs attention") with an added `text-amber-950 bg-amber-500 hover:bg-amber-500/90` override | `RefreshCw` | "Re-render (edited)" | No | A small `Badge variant="outline"` reading "stale" is not needed — the button label already carries the meaning; do not duplicate it in a second element |
| `error` | `destructive` | `AlertTriangle` | "Retry re-render" | No | `title` attribute carries the last error message (mirrors the disabled-cut-button `title` pattern at `video-post-queue.tsx:166-169`); a one-line `<p className="text-xs text-destructive">` under the button shows the same message so it is not tooltip-only |
State transitions: `idle`/`stale`/`error` → click → `loading` → mutation resolves → `idle` (success) or `error` (failure). Editing a caption or the occasion while in `idle``stale`. There is no user action that transitions directly out of `loading` except the mutation settling — the button stays `disabled` for the whole request, preventing double-submission (same re-entrancy concern already handled via `submittingRef` in `spawn-agent-dialog.tsx:40`).
---
## 3. Composition preview panel (`VideoPostRow`)
### Component
New `CompositionPreviewPanel` sub-component:
```tsx
function CompositionPreviewPanel({
previewUrl,
cut,
captions,
}: {
previewUrl: string; // the composition-HTML proxy route response, scoped to `cut`
cut: VideoCut; // reuse the existing "vertical" | "square" type
captions: { x?: string | null; tiktok?: string | null };
}) { /* ... */ }
```
It renders inside `VideoPostRow`, between the cut-switcher/re-render row and the existing MP4 `<video>` block, only when a live `previewUrl` is available (composition authored but not yet rendered, or re-rendering) — once the MP4 exists it stays visible as a lighter-weight way to sanity-check captions against the composition without scrubbing the video.
### Layout
A two-column grid at `md:` and up, stacked single column below it — the same collapse breakpoint already used for this row's own action buttons (`flex-col-reverse gap-2 pt-1 sm:flex-row`, `video-post-queue.tsx:260`), but `md:` here because the iframe needs more horizontal room than a button row:
```tsx
<div className="grid grid-cols-1 gap-3 rounded-lg border p-3 md:grid-cols-2">
{/* left: iframe */}
{/* right: captions */}
</div>
```
`rounded-lg border p-3` matches the existing `VideoPostRow` container's own `rounded-lg border p-4` (one step down in padding, since this is a nested block) — no new radius or border-color token.
### Left column: iframe
```tsx
<div className="flex items-center justify-center overflow-hidden rounded-md bg-muted">
<iframe
src={previewUrl}
title={`${cut} composition preview`}
sandbox="allow-scripts"
className={cut === "vertical" ? "aspect-[9/16] w-full max-w-[180px]" : "aspect-square w-full max-w-[240px]"}
/>
</div>
```
- `sandbox="allow-scripts"` only — no `allow-same-origin`, no `allow-popups`, no network access implied (the compositions are already built offline-only per `motion/README.md`'s render constraints, so the sandboxed iframe cannot reach anything the composition doesn't already vendor).
- The composition's native canvas is 1080px wide (vertical: 1080×1920, square: 1080×1080, per `motion/README.md`); the iframe is displayed at a fixed max-width scaled thumbnail (180px vertical / 240px square) rather than 1:1, matching how the MP4 `<video>` below it is already constrained (`mx-auto max-h-96 w-full`, `video-post-queue.tsx:189`) — full-size inspection is not this panel's job, it is a fast sanity check.
- `bg-muted` fills any letterboxing while the iframe loads, mirroring the "missing cut" placeholder's `border-dashed` box use of the same muted surface (`video-post-queue.tsx:195-197`).
- Reuses the row's existing `cut` state (the same `vertical`/`square` toggle buttons drive both the iframe and the MP4 preview below it — no second switcher).
### Right column: captions
Read-only display of the same captions the composition's `captions.json` proposes (per `motion/README.md`'s `captions.json` schema) — this is a preview of what will be sent, not an edit surface (editing already happens in the existing textareas further down the card):
```tsx
<div className="space-y-2 text-sm">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">X</p>
<p className="whitespace-pre-wrap">{captions.x ?? "—"}</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">TikTok</p>
<p className="whitespace-pre-wrap">{captions.tiktok ?? "—"}</p>
</div>
</div>
```
Typography matches the "Drafted CHANGELOG" label pattern already used in `release-proposal-card.tsx:200-205` (`text-xs font-semibold uppercase tracking-wide text-muted-foreground` for the eyebrow label).
### Narrow-viewport behavior
Below `md:` (768px) the grid collapses to a single column: iframe first, captions second, each taking the full row width, `gap-3` between them (the `grid-cols-1` default already declared above — no separate mobile-only class needed). The iframe keeps its own `max-w-[180px]`/`max-w-[240px]` cap and centers via the parent's `flex items-center justify-center`, so it never stretches edge-to-edge on a narrow card even though the grid cell does. This matches how the existing cut-switcher buttons and action row already reflow from a row to a stacked column on narrow viewports (`flex-col-reverse gap-2 ... sm:flex-row`).
---
## Implementation checklist for the frontend developer
- [ ] Add `videoEngineOnly` prop to `ProjectSelector`; backend adds
`video_engine_enabled` to `ProjectSummary`.
- [ ] `RequestVideoDialog`: add `projectId` state, the picker field (first
in the form), include it in `canSubmit` and in the `requestVideo`
mutation payload, reset it alongside the other fields.
- [ ] `RerenderControl`: derive `state` from local edit-dirty tracking +
mutation status; four visual states per the table above.
- [ ] `CompositionPreviewPanel`: two-column grid, sandboxed iframe scaled
by orientation, read-only caption display; only rendered when a
`previewUrl` exists.
- [ ] No new Tailwind tokens, colors, or radii — every class above already
exists in `video-post-queue.tsx` or `release-proposal-card.tsx`.
+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 {
+5
View File
@@ -154,6 +154,11 @@ select = [
# agent_id, project_ids, route, session_id) — same >5-kwarg rationale as the
# gateway verb surfaces below.
"roboco/services/prompter.py" = ["PLR0913"]
# open_video_task's kwargs (occasion, script, platforms, brief,
# suggested_input_props, project_id) are the authoring-task contract shared
# by the release/spotlight/on-demand callers — same "bundling would just
# relocate the same named fields behind one hop" rationale as prompter.py.
"roboco/services/video_engine.py" = ["PLR0913"]
# Gateway methods are typed verb surfaces — agent-facing kwargs reflect the
# verb contract (task title, description, acceptance criteria, assignee, etc.). Bundling
# into a dataclass hides the field-by-field schema the LLM needs at the
+102 -9
View File
@@ -7,7 +7,7 @@ from __future__ import annotations
import asyncio
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
from uuid import UUID
from fastapi import APIRouter, HTTPException, Query, status
@@ -30,7 +30,8 @@ from roboco.config import settings
from roboco.foundation.policy.content import markers
from roboco.security import guard_deco
from roboco.services import minio_client
from roboco.services.task import VIDEO_POST_SOURCE, get_task_service
from roboco.services.project import get_project_service
from roboco.services.task import VIDEO_POST_SOURCE, VIDEO_SOURCE, get_task_service
from roboco.services.tiktok_client import build_tiktok_poster
from roboco.services.tiktok_credentials import (
TikTokCredentialsValidationError,
@@ -41,6 +42,7 @@ from roboco.services.video_post_service import (
VideoCaptionTooLongError,
get_video_post_service,
)
from roboco.services.workspace import WorkspaceError, get_workspace_service
from roboco.services.x_credentials import get_x_credentials_service
from roboco.services.x_video_client import build_x_video_poster
@@ -89,11 +91,15 @@ async def request_video(
db: DbSession,
agent: CurrentAgentContext,
) -> VideoRequestResponse:
"""Open a UX/UI video-authoring task for the CEO's on-demand brief.
"""Open a UX/UI video-authoring task for the CEO's on-demand brief,
scoped to ``data.project_id``.
Returns ``disabled`` when the video engine is off, and ``not_opened``
when ``open_video_task`` no-ops (a duplicate occasion, the open cap, or
an unresolvable project) neither is an error, just nothing to do.
Returns ``disabled`` when the video engine is off. 404s when
``project_id`` doesn't resolve to a project, or that project hasn't
opted into the video engine (``video_engine_enabled`` is off on it).
Returns ``not_opened`` when ``open_video_task`` no-ops for any other
reason (a duplicate occasion or the open-post cap) not an error, just
nothing to do.
"""
_require_ceo(agent)
if not settings.video_engine_enabled:
@@ -101,18 +107,27 @@ async def request_video(
status="disabled",
detail="The video engine is disabled (video_engine_enabled is off).",
)
task = await get_video_engine(db).open_video_task(
engine = get_video_engine(db)
project = await engine.resolve_authoring_project(
project_id=data.project_id, occasion=data.occasion
)
if project is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="project not found or not opted into the video engine",
)
task = await engine.open_video_task(
occasion=data.occasion,
script=data.brief,
platforms=data.platforms,
brief=data.brief,
project_id=data.project_id,
)
if task is None:
return VideoRequestResponse(
status="not_opened",
detail=(
"No video task was opened (a duplicate occasion, the open-post"
" cap, or the project isn't resolvable)."
"No video task was opened (a duplicate occasion or the open-post cap)."
),
)
await db.commit()
@@ -196,6 +211,84 @@ async def list_video_pipeline(
return [_to_pipeline_item(t) for t in tasks]
@router.post("/pipeline/{task_id}/rerender", response_model=VideoPipelineItemResponse)
@guard_deco.rate_limit(requests=20, window=60)
@guard_deco.block_clouds()
async def rerender_video_task(
task_id: UUID, db: DbSession, agent: CurrentAgentContext
) -> VideoPipelineItemResponse:
"""Clear a completed video-authoring task's render idempotency keys
(``render_status``/``render_attempts``/``render_error``) so the next
render cycle re-picks it up and re-renders it. 404s when there's no such
completed authoring task with a proposed composition."""
_require_ceo(agent)
task = await get_video_engine(db).rerender(task_id)
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No such completed video task with a proposed composition",
)
await db.commit()
return _to_pipeline_item(task)
def _resolve_preview_path(root: Path, file_path: str) -> Path | None:
"""Resolve ``file_path`` against the workspace ``root``, refusing
anything that escapes it. A leading ``/`` is stripped before joining
pathlib's ``/`` operator otherwise lets an absolute right operand
discard ``root`` entirely then the joined path must resolve to an
existing file still under ``root``. The sole confinement check for the
CEO preview proxy."""
candidate = (root / file_path.lstrip("/")).resolve()
if not candidate.is_relative_to(root) or not candidate.is_file():
return None
return candidate
@router.get("/preview/{task_id}/{file_path:path}", response_model=None)
async def get_video_preview(
task_id: UUID,
file_path: str,
db: DbSession,
agent: CurrentAgentContext,
) -> FileResponse:
"""Serve a video-authoring task's composition HTML + sibling assets
(kit/public/etc.) straight off its project's merged read-clone — the
panel's live preview iframe. ``file_path`` is relative to the resolved
workspace root (e.g. ``motion/compositions/<id>/vertical.html``);
confined there so it can't traverse out, per ``_resolve_preview_path``.
CEO-only; the response carries explicit iframe-permitting headers so the
panel can embed it.
"""
_require_ceo(agent)
task = await get_task_service(db).get(task_id)
if task is None or task.source != VIDEO_SOURCE or task.project_id is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="No such video task"
)
project = await get_project_service(db).get(cast("UUID", task.project_id))
if project is None or not project.slug:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
)
try:
workspace = await get_workspace_service(db).ensure_read_clone(project.slug)
except WorkspaceError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) from e
resolved = _resolve_preview_path(workspace.resolve(), file_path)
if resolved is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="No such preview file"
)
return FileResponse(
resolved,
headers={
"X-Frame-Options": "SAMEORIGIN",
"Content-Security-Policy": "frame-ancestors 'self'",
},
)
def _posted_ids(draft: dict[str, Any]) -> dict[str, str]:
"""Every ``{platform}_posted_id`` key stamped by approve, keyed by
platform (e.g. ``{"x": "..", "tiktok": ".."}``)."""
+8 -1
View File
@@ -67,7 +67,12 @@ class ProjectResponse(BaseModel):
class ProjectSummaryResponse(BaseModel):
"""Compact project response for list views."""
"""Compact project response for list views.
Returned by GET /api/projects; includes essential project metadata
for list-view cards. The `video_engine_enabled` field indicates
whether this project is opted in to the video engine subsystem.
"""
id: UUID
name: str
@@ -78,6 +83,7 @@ class ProjectSummaryResponse(BaseModel):
is_active: bool
has_workspace: bool = False
has_git_token: bool = False
video_engine_enabled: bool = False
model_config = ConfigDict(from_attributes=True)
@@ -255,4 +261,5 @@ def project_to_summary(project: "ProjectTable") -> ProjectSummaryResponse:
is_active=bool(project.is_active),
has_workspace=bool(project.workspace_path),
has_git_token=bool(project.git_token_encrypted),
video_engine_enabled=bool(project.video_engine_enabled),
)
+3 -1
View File
@@ -1,6 +1,7 @@
"""Schemas for the video engine's on-demand request + CEO approval surface."""
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field
@@ -10,11 +11,12 @@ from roboco.services.x_client import MAX_TWEET_CHARS
class VideoRequestBody(BaseModel):
"""The CEO's on-demand video brief."""
"""The CEO's on-demand video brief, scoped to a specific project."""
occasion: str = Field(..., min_length=1)
brief: str = Field(..., min_length=1)
platforms: list[str] = Field(..., min_length=1)
project_id: UUID
class VideoRequestResponse(BaseModel):
+21 -8
View File
@@ -8185,7 +8185,7 @@ Start by:
return
try:
mp4_paths = await self._render_both_cuts(
db, draft, composition_id, str(task.id)
db, draft, composition_id, str(task.id), task.project_id
)
await self._materialize_video_post(db, task, draft, mp4_paths)
except Exception as exc:
@@ -8230,16 +8230,29 @@ Start by:
)
async def _render_both_cuts(
self, db: Any, draft: dict[str, Any], composition_id: str, render_key: str
self,
db: Any,
draft: dict[str, Any],
composition_id: str,
render_key: str,
project_id: Any,
) -> dict[str, str]:
"""Render the vertical + square cuts from the roboco project's merged
read-clone's motion/ dir; returns {"vertical": path, "square": path}.
``render_key`` (the source task id) scopes each cut's output path."""
"""Render the vertical + square cuts from the authoring task's OWN
project's merged read-clone's motion/ dir; returns {"vertical": path,
"square": path}. ``render_key`` (the source task id) scopes each
cut's output path. ``project_id`` is the task's own ``project_id``
never a fixed slug so a video task authored against any opted-in
project renders from that project's ``motion/`` dir, not RoboCo's."""
from roboco.services.project import get_project_service
from roboco.services.video_renderer_client import get_video_renderer
from roboco.services.workspace import get_workspace_service
from roboco.services.workspace import WorkspaceError, get_workspace_service
slug = (settings.self_heal_project_slug or "roboco-api").strip()
workspace = await get_workspace_service(db).ensure_read_clone(slug)
project = await get_project_service(db).get(project_id) if project_id else None
if project is None or not project.slug:
raise WorkspaceError(
f"video-render: task's project not resolvable ({project_id})"
)
workspace = await get_workspace_service(db).ensure_read_clone(project.slug)
motion_dir = str(workspace / "motion")
input_props = draft.get("input_props") or {}
renderer = get_video_renderer()
+73 -17
View File
@@ -149,23 +149,38 @@ class VideoEngine(BaseService):
service_name = "video_engine"
async def _roboco_project(self) -> ProjectTable | None:
"""The fixed RoboCo project the release/spotlight hooks author
against (``self_heal_project_slug``). The on-demand caller instead
supplies its own ``project_id`` see ``resolve_authoring_project``."""
slug = (settings.self_heal_project_slug or "roboco-api").strip()
return await get_project_service(self.session).get_by_slug(slug)
async def _opted_in_project(self, occasion: str) -> ProjectTable | None:
"""The RoboCo project if resolvable AND opted into video, else None.
async def resolve_authoring_project(
self, *, project_id: UUID | None, occasion: str
) -> ProjectTable | None:
"""The project to author this video against, or None when
unresolvable or not opted into the video engine.
Two skip reasons, both logged: unresolvable project (warning a
config gap) vs. project not opted in (info the operator hasn't
flipped the per-project ``video_engine_enabled`` toggle). The global
flag arms the subsystem; the project's flag opts this repo into
authoring against its ``motion/`` dir (mirrors ``ci_watch_enabled``).
``project_id`` (the on-demand ``/video/request`` caller, and the
render loop's own per-task resolution) resolves by id; omitted (the
release/spotlight hooks), it falls back to the fixed RoboCo project.
Both paths share the same two skip reasons, both logged: unresolvable
project (warning a config/data gap) vs. project not opted in (info
the operator hasn't flipped the per-project ``video_engine_enabled``
toggle). The global flag arms the subsystem; the project's flag opts
that repo into authoring against its own ``motion/`` dir (mirrors
``ci_watch_enabled``).
"""
project = await self._roboco_project()
project = (
await get_project_service(self.session).get(project_id)
if project_id is not None
else await self._roboco_project()
)
if project is None or project.id is None:
self.log.warning(
"video-engine: RoboCo project not resolvable; skipping video task",
"video-engine: project not resolvable; skipping video task",
occasion=occasion,
project_id=str(project_id) if project_id else None,
)
return None
if not getattr(project, "video_engine_enabled", False):
@@ -231,16 +246,22 @@ class VideoEngine(BaseService):
platforms: list[str],
brief: str,
suggested_input_props: dict[str, Any] | None = None,
project_id: UUID | None = None,
) -> TaskTable | None:
"""Originate ONE UX/UI authoring task for a bespoke video, or None.
No-ops when the global flag is off, the RoboCo project hasn't opted in
(``video_engine_enabled``), a task for this occasion is already open
(authoring or held draft), the open cap is reached, or the RoboCo
project isn't resolvable. The opened task is a normal, ASSIGNED
delivery task (``source=VIDEO_SOURCE``, ``confirmed_by_human=True``)
NOT held so it dispatches straight to the assigned ux-dev like any
other pre-assigned code task.
No-ops when the global flag is off, the target project hasn't opted
in (``video_engine_enabled``), a task for this occasion is already
open (authoring or held draft), the open cap is reached, or the
target project isn't resolvable. The opened task is a normal,
ASSIGNED delivery task (``source=VIDEO_SOURCE``,
``confirmed_by_human=True``) NOT held so it dispatches straight to
the assigned ux-dev like any other pre-assigned code task.
``project_id`` scopes authoring to a specific project (the on-demand
``/video/request`` caller); omitted, the release/spotlight hooks
default to the fixed RoboCo project see
``resolve_authoring_project``.
``brief`` is enriched (brand-voice + motion design-bar pointer
appended) before becoming the task description and the marker's
@@ -263,7 +284,9 @@ class VideoEngine(BaseService):
occasion=occasion,
)
return None
project = await self._opted_in_project(occasion)
project = await self.resolve_authoring_project(
project_id=project_id, occasion=occasion
)
if project is None:
return None
from sqlalchemy.exc import SQLAlchemyError
@@ -425,6 +448,39 @@ class VideoEngine(BaseService):
)
return task
# ---- re-render (CEO-triggered retry) -----------------------------------
async def rerender(self, task_id: UUID) -> TaskTable | None:
"""Clear ``render_status``/``render_attempts``/``render_error`` off a
completed video-authoring task's ``video_draft`` marker, so the next
render cycle's scan (``render_status`` unset) re-picks it up and
re-renders it e.g. after the CEO fixes something and wants a fresh
pass, or wants to retry past a terminal ``failed`` state.
None (a 404 to the route) when there is no such completed authoring
task, or the dev hasn't called ``propose_video`` yet (no
``composition_id`` nothing to render).
"""
task = await get_task_service(self.session).get(task_id)
if (
task is None
or task.source != VIDEO_SOURCE
or task.status != TaskStatus.COMPLETED
):
return None
draft = markers.get_video_draft(task) or {}
if not draft.get("composition_id"):
return None
cleared = {
k: v
for k, v in draft.items()
if k not in ("render_status", "render_attempts", "render_error")
}
markers.set_video_draft(task, cleared)
await self.session.flush()
self.log.info("video-engine: re-render requested", task_id=str(task.id))
return task
def get_video_engine(session: AsyncSession) -> VideoEngine:
"""Build a VideoEngine for ``session``."""
+27
View File
@@ -223,6 +223,33 @@ async def test_list_projects_includes_default_branch(
assert listed[payload["slug"]]["default_branch"] == "master"
@pytest.mark.asyncio
async def test_list_projects_includes_video_engine_enabled(
project_client: AsyncClient,
) -> None:
payload = _payload()
create = await project_client.post("/api/projects", json=payload, headers=_HDR)
assert create.status_code == HTTPStatus.CREATED
default_listed = {
p["slug"]: p
for p in (await project_client.get("/api/projects", headers=_HDR)).json()
}
assert default_listed[payload["slug"]]["video_engine_enabled"] is False
patched = await project_client.patch(
f"/api/projects/{payload['slug']}",
json={"video_engine_enabled": True},
headers=_HDR,
)
assert patched.status_code == HTTPStatus.OK
response = await project_client.get("/api/projects", headers=_HDR)
assert response.status_code == HTTPStatus.OK
listed = {p["slug"]: p for p in response.json()}
assert listed[payload["slug"]]["video_engine_enabled"] is True
@pytest.mark.asyncio
async def test_get_project_by_slug_not_found(project_client: AsyncClient) -> None:
response = await project_client.get(
+307 -12
View File
@@ -260,14 +260,17 @@ async def test_request_video_opens_authoring_task(
) -> None:
await _seed(db_session)
monkeypatch.setattr(cfg, "video_engine_enabled", True)
monkeypatch.setattr(cfg, "self_heal_project_slug", SLUG)
monkeypatch.setattr(cfg, "video_max_open_posts", 5)
project = (
await db_session.execute(select(ProjectTable).where(ProjectTable.slug == SLUG))
).scalar_one()
resp = await ceo_client.post(
"/api/video/request",
json={
"occasion": "CEO on-demand: launch teaser",
"brief": "A short teaser for the new dashboard",
"platforms": ["x", "tiktok"],
"project_id": str(project.id),
},
)
assert resp.status_code == HTTPStatus.OK
@@ -283,6 +286,7 @@ async def test_request_video_opens_authoring_task(
assert task is not None
assert task.source == VIDEO_SOURCE
assert task.status == TaskStatus.PENDING
assert task.project_id == project.id
finally:
# The route's commit durably persists this task past this test's own
# rollback teardown — a non-terminal source=video row left behind
@@ -304,7 +308,12 @@ async def test_request_video_disabled_returns_clear_response(
before = len(await get_task_service(db_session).list_open_video_posts())
resp = await ceo_client.post(
"/api/video/request",
json={"occasion": "occ-disabled", "brief": "brief", "platforms": ["x"]},
json={
"occasion": "occ-disabled",
"brief": "brief",
"platforms": ["x"],
"project_id": str(uuid4()),
},
)
assert resp.status_code == HTTPStatus.OK
body = resp.json()
@@ -315,27 +324,91 @@ async def test_request_video_disabled_returns_clear_response(
@pytest.mark.asyncio
async def test_request_video_not_opened_when_project_unresolvable(
async def test_request_video_404s_on_unresolvable_project_id(
db_session: AsyncSession, ceo_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""An unresolvable project makes open_video_task no-op — a clear
``not_opened`` response, not a 500 or a fabricated task."""
"""A project_id that doesn't resolve to any project 404s — no fabricated
task, no silent not_opened."""
await _seed(db_session)
monkeypatch.setattr(cfg, "video_engine_enabled", True)
monkeypatch.setattr(cfg, "self_heal_project_slug", "no-such-project")
before = len(await get_task_service(db_session).list_open_video_posts())
resp = await ceo_client.post(
"/api/video/request",
json={"occasion": "occ-unresolvable", "brief": "brief", "platforms": ["x"]},
json={
"occasion": "occ-unresolvable",
"brief": "brief",
"platforms": ["x"],
"project_id": str(uuid4()),
},
)
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert body["status"] == "not_opened"
assert body["task_id"] is None
assert resp.status_code == HTTPStatus.NOT_FOUND
after = len(await get_task_service(db_session).list_open_video_posts())
assert after == before # nothing new was opened
@pytest.mark.asyncio
async def test_request_video_404s_on_non_opted_in_project_id(
db_session: AsyncSession, ceo_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A project that resolves but hasn't flipped video_engine_enabled also
404s, distinct from the unresolvable-project case above."""
await _seed(db_session)
monkeypatch.setattr(cfg, "video_engine_enabled", True)
project = ProjectTable(
id=uuid4(),
name="Not Opted In",
slug=f"not-opted-{uuid4().hex[:6]}",
git_url="https://example.com/notopted.git",
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
video_engine_enabled=False,
)
db_session.add(project)
await db_session.flush()
resp = await ceo_client.post(
"/api/video/request",
json={
"occasion": "occ-not-opted",
"brief": "brief",
"platforms": ["x"],
"project_id": str(project.id),
},
)
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_request_video_not_opened_on_duplicate_occasion(
db_session: AsyncSession, ceo_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A valid, opted-in project_id still no-ops (not a 404) for a duplicate
occasion the open-cap/dedup reasons stay a clear 200 not_opened."""
await _seed(db_session)
monkeypatch.setattr(cfg, "video_engine_enabled", True)
monkeypatch.setattr(cfg, "video_max_open_posts", 5)
project = (
await db_session.execute(select(ProjectTable).where(ProjectTable.slug == SLUG))
).scalar_one()
payload = {
"occasion": "occ-dupe",
"brief": "brief",
"platforms": ["x"],
"project_id": str(project.id),
}
first = await ceo_client.post("/api/video/request", json=payload)
assert first.status_code == HTTPStatus.OK
assert first.json()["status"] == "opened"
task_id = first.json()["task_id"]
try:
second = await ceo_client.post("/api/video/request", json=payload)
assert second.status_code == HTTPStatus.OK
assert second.json()["status"] == "not_opened"
assert second.json()["task_id"] is None
finally:
await db_session.execute(delete(TaskTable).where(TaskTable.id == UUID(task_id)))
await db_session.commit()
@pytest.mark.asyncio
async def test_list_posts_returns_open_draft(
db_session: AsyncSession, ceo_client: AsyncClient
@@ -820,7 +893,12 @@ async def test_non_ceo_is_forbidden(db_session: AsyncSession) -> None:
async with AsyncClient(transport=transport, base_url="http://test") as client:
request_resp = await client.post(
"/api/video/request",
json={"occasion": "occ", "brief": "brief", "platforms": ["x"]},
json={
"occasion": "occ",
"brief": "brief",
"platforms": ["x"],
"project_id": str(uuid4()),
},
)
list_resp = await client.get("/api/video/posts")
media_resp = await client.get(f"/api/video/posts/{task.id}/media?cut=vertical")
@@ -977,3 +1055,220 @@ async def test_media_falls_back_to_local_file_when_minio_missing(
assert resp.headers["content-type"] == "video/mp4"
assert resp.content == b"local-file-bytes" # FileResponse fallback, not MinIO
app.dependency_overrides.clear()
# --- re-render (task 3, 2026-07-10) -------------------------------------------
@pytest.mark.asyncio
async def test_rerender_clears_state_and_returns_pipeline_item(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_authoring_task(
db_session,
status=TaskStatus.COMPLETED,
draft_extra={
"composition_id": "Intro",
"render_status": "failed",
"render_attempts": markers.MAX_VIDEO_RENDER_ATTEMPTS,
"render_error": "sidecar timeout",
},
)
resp = await ceo_client.post(f"/api/video/pipeline/{task.id}/rerender")
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert body["task_id"] == str(task.id)
assert body["render_status"] is None
assert body["render_attempts"] == 0
assert body["render_error"] is None
draft = markers.get_video_draft(task)
assert draft is not None
assert "render_status" not in draft
assert "render_attempts" not in draft
assert "render_error" not in draft
assert draft["composition_id"] == "Intro" # everything else preserved
@pytest.mark.asyncio
async def test_rerender_missing_task_is_404(ceo_client: AsyncClient) -> None:
resp = await ceo_client.post(f"/api/video/pipeline/{uuid4()}/rerender")
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_rerender_non_completed_task_is_404(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_authoring_task(
db_session,
status=TaskStatus.IN_PROGRESS,
draft_extra={"composition_id": "Intro"},
)
resp = await ceo_client.post(f"/api/video/pipeline/{task.id}/rerender")
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_rerender_without_composition_id_is_404(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_authoring_task(db_session, status=TaskStatus.COMPLETED)
resp = await ceo_client.post(f"/api/video/pipeline/{task.id}/rerender")
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_rerender_non_ceo_is_forbidden(db_session: AsyncSession) -> None:
task = await _seed_authoring_task(
db_session,
status=TaskStatus.COMPLETED,
draft_extra={"composition_id": "Intro"},
)
app = _build_app(db_session, AgentRole.DEVELOPER, uuid4())
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post(f"/api/video/pipeline/{task.id}/rerender")
assert resp.status_code == HTTPStatus.FORBIDDEN
app.dependency_overrides.clear()
# --- preview proxy (task 3, 2026-07-10) ---------------------------------------
def _fake_workspace_service(root: Path) -> object:
"""A ``get_workspace_service``-shaped stub whose ``ensure_read_clone``
returns a fixed local dir no real git clone touched."""
class _Svc:
async def ensure_read_clone(self, _slug: str, *, force: bool = False) -> Path:
_ = force
return root
return _Svc()
def test_resolve_preview_path_serves_file_inside_root(tmp_path: Path) -> None:
root = (tmp_path / "clone").resolve()
(root / "motion" / "compositions" / "Intro").mkdir(parents=True)
target = root / "motion" / "compositions" / "Intro" / "vertical.html"
target.write_text("<html></html>")
resolved = video_module._resolve_preview_path(
root, "motion/compositions/Intro/vertical.html"
)
assert resolved == target.resolve()
def test_resolve_preview_path_blocks_dot_dot_traversal(tmp_path: Path) -> None:
root = (tmp_path / "clone").resolve()
(root / "motion").mkdir(parents=True)
secret = tmp_path / "secret.txt"
secret.write_text("nope")
assert video_module._resolve_preview_path(root, "../secret.txt") is None
assert video_module._resolve_preview_path(root, "motion/../../secret.txt") is None
def test_resolve_preview_path_blocks_absolute_path_override(tmp_path: Path) -> None:
"""A leading '/' in file_path would otherwise let pathlib's ``/``
operator discard ``root`` entirely and resolve straight to the absolute
path the ``lstrip("/")`` guard neutralizes that."""
root = (tmp_path / "clone").resolve()
root.mkdir()
outside = tmp_path / "outside.txt"
outside.write_text("nope")
assert video_module._resolve_preview_path(root, str(outside)) is None
def test_resolve_preview_path_missing_file_is_none(tmp_path: Path) -> None:
root = (tmp_path / "clone").resolve()
root.mkdir()
assert video_module._resolve_preview_path(root, "motion/nope.html") is None
@pytest.mark.asyncio
async def test_preview_serves_composition_html_and_sibling_kit_asset(
db_session: AsyncSession,
ceo_client: AsyncClient,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
comp_dir = tmp_path / "motion" / "compositions" / "Intro"
comp_dir.mkdir(parents=True)
(comp_dir / "vertical.html").write_text("<html>intro</html>")
kit_dir = tmp_path / "motion" / "kit"
kit_dir.mkdir(parents=True)
(kit_dir / "kit.css").write_text("body{}")
monkeypatch.setattr(
video_module,
"get_workspace_service",
lambda _db: _fake_workspace_service(tmp_path),
)
task = await _seed_authoring_task(
db_session,
status=TaskStatus.IN_PROGRESS,
draft_extra={"composition_id": "Intro"},
)
resp = await ceo_client.get(
f"/api/video/preview/{task.id}/motion/compositions/Intro/vertical.html"
)
assert resp.status_code == HTTPStatus.OK
assert resp.text == "<html>intro</html>"
assert resp.headers.get("x-frame-options") == "SAMEORIGIN"
assert "frame-ancestors" in resp.headers.get("content-security-policy", "")
kit_resp = await ceo_client.get(f"/api/video/preview/{task.id}/motion/kit/kit.css")
assert kit_resp.status_code == HTTPStatus.OK
assert kit_resp.text == "body{}"
@pytest.mark.asyncio
async def test_preview_missing_file_is_404(
db_session: AsyncSession,
ceo_client: AsyncClient,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
(tmp_path / "motion").mkdir()
monkeypatch.setattr(
video_module,
"get_workspace_service",
lambda _db: _fake_workspace_service(tmp_path),
)
task = await _seed_authoring_task(
db_session,
status=TaskStatus.IN_PROGRESS,
draft_extra={"composition_id": "Intro"},
)
resp = await ceo_client.get(
f"/api/video/preview/{task.id}/motion/compositions/Intro/vertical.html"
)
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_preview_missing_task_is_404(ceo_client: AsyncClient) -> None:
resp = await ceo_client.get(f"/api/video/preview/{uuid4()}/vertical.html")
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_preview_non_video_task_is_404(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_draft(db_session) # source=video_post, not video
resp = await ceo_client.get(f"/api/video/preview/{task.id}/vertical.html")
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_preview_non_ceo_is_forbidden(db_session: AsyncSession) -> None:
task = await _seed_authoring_task(
db_session,
status=TaskStatus.IN_PROGRESS,
draft_extra={"composition_id": "Intro"},
)
app = _build_app(db_session, AgentRole.DEVELOPER, uuid4())
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get(f"/api/video/preview/{task.id}/vertical.html")
assert resp.status_code == HTTPStatus.FORBIDDEN
app.dependency_overrides.clear()
@@ -42,6 +42,7 @@ UX_DEV_2_UUID = _foundation.AGENTS["ux-dev-2"].uuid
SLUG = "roboco"
ONE = 1
TWO = 2
FOUR = 4
def _orch() -> Any:
@@ -333,6 +334,35 @@ async def test_render_video_task_renders_both_cuts_and_materializes_post(
assert source_draft["render_status"] == "rendered"
@pytest.mark.asyncio
async def test_render_video_task_resolves_workspace_from_task_project_not_settings(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The render loop resolves the read-clone from the authoring task's OWN
project_id flipping self_heal_project_slug to a bogus value AFTER the
task was authored must not affect the render, proving the loop no longer
reads that setting live."""
await _seed(db_session)
_enable(monkeypatch)
task = await _make_completed_video_task(
db_session, occasion="own-project-not-settings", composition_id="Intro"
)
monkeypatch.setattr(cfg, "self_heal_project_slug", "no-such-project-anymore")
renderer = _FakeRenderer()
workspace = _fake_workspace()
orch = _orch()
p1, p2 = _render_patches(renderer, workspace)
with p1, p2:
await orch._render_video_task(db_session, task)
# Still resolved via the task's own project_id -> slug "roboco", not the
# now-bogus self_heal_project_slug.
workspace.ensure_read_clone.assert_awaited_once_with(SLUG)
posts = await get_task_service(db_session).list_open_video_posts()
assert len(posts) == ONE
@pytest.mark.asyncio
async def test_render_video_task_second_call_is_idempotent(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
@@ -356,6 +386,45 @@ async def test_render_video_task_second_call_is_idempotent(
assert len(posts) == ONE
@pytest.mark.asyncio
async def test_rerender_clears_state_so_next_cycle_re_renders(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""CEO re-render flow end to end: a rendered task is a no-op on a second
render pass (idempotent); clearing render_status/render_attempts via
VideoEngine.rerender makes the NEXT pass pick it up and render it again."""
await _seed(db_session)
_enable(monkeypatch)
task = await _make_completed_video_task(
db_session, occasion="rerender-cycle", composition_id="Intro"
)
renderer = _FakeRenderer()
workspace = _fake_workspace()
orch = _orch()
p1, p2 = _render_patches(renderer, workspace)
with p1, p2:
await orch._render_video_task(db_session, task)
assert len(renderer.calls) == TWO
draft = markers.get_video_draft(task)
assert draft is not None
assert draft["render_status"] == "rendered"
rerendered = await VideoEngine(db_session).rerender(task.id)
assert rerendered is not None
draft = markers.get_video_draft(task)
assert draft is not None
assert "render_status" not in draft
assert "render_attempts" not in draft
with p1, p2:
await orch._render_video_task(db_session, task) # re-picked up
assert len(renderer.calls) == FOUR # rendered a second time, not skipped
draft = markers.get_video_draft(task)
assert draft is not None
assert draft["render_status"] == "rendered"
@pytest.mark.asyncio
async def test_render_video_task_skips_task_without_composition_id(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
+56
View File
@@ -0,0 +1,56 @@
"""Guard the hard-wrap reflow gate: wiring into `make quality` + the check exit code.
Nothing previously pinned that `make quality` actually runs
`scripts/reflow_md.py --check` a `Makefile` edit could silently drop the
line and no test would notice the regression. These tests pin the wiring and
the script's own pass/fail exit codes behind the I/O shell.
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).parents[3]
SCRIPT = ROOT / "scripts" / "reflow_md.py"
MAKEFILE = ROOT / "Makefile"
def test_quality_target_wires_in_reflow_check() -> None:
text = MAKEFILE.read_text()
quality_block = text.split("\n.PHONY: quality\n", 1)[1].split("\n.PHONY: ", 1)[0]
assert "scripts/reflow_md.py --check" in quality_block, (
"make quality must run scripts/reflow_md.py --check "
"(the hard-wrap reflow gate) — wiring was removed"
)
def test_check_passes_on_repo_as_committed() -> None:
result = subprocess.run(
[sys.executable, str(SCRIPT), "--check"],
cwd=ROOT,
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stdout + result.stderr
assert "OK: no hard-wrapped markdown prose in scope." in result.stdout
def test_check_fails_on_a_hard_wrapped_file(tmp_path: Path) -> None:
wrapped = tmp_path / "sample.md"
wrapped.write_text(
"This is a paragraph that has been\n"
"hard-wrapped mid-sentence across two\n"
"separate lines for no good reason.\n"
)
result = subprocess.run(
[sys.executable, str(SCRIPT), "--check"],
cwd=tmp_path,
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 1
assert "hard-wrapped prose" in result.stdout
+174 -2
View File
@@ -8,8 +8,9 @@ Secretary-owned and held for the CEO. Asserted against a real Postgres DB.
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, cast
from unittest.mock import AsyncMock
from uuid import UUID, uuid4
import pytest
from roboco.config import settings as cfg
@@ -307,6 +308,93 @@ async def test_open_video_task_insert_error_returns_none_session_usable(
assert check.scalar_one_or_none() is not None
# --------------------------------------------------------------------------- #
# open_video_task(project_id=...) — the on-demand caller's own project scope
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_open_video_task_with_explicit_project_id_ignores_self_heal_slug(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A caller-supplied project_id resolves independent of
self_heal_project_slug the authoring task lands on THAT project, not
the fixed RoboCo one."""
await _seed(db_session)
_enable(monkeypatch)
other = ProjectTable(
name="Other Repo",
slug="other-repo",
git_url="https://github.com/x/other.git",
default_branch="master",
protected_branches=["master"],
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
is_active=True,
video_engine_enabled=True,
)
db_session.add(other)
await db_session.flush()
monkeypatch.setattr(cfg, "self_heal_project_slug", "no-such-slug")
engine = video_engine_module.VideoEngine(db_session)
task = await engine.open_video_task(
occasion="on-demand other repo",
script="s",
platforms=["x"],
brief="b",
project_id=cast("UUID", other.id),
)
assert task is not None
assert task.project_id == other.id
@pytest.mark.asyncio
async def test_open_video_task_explicit_project_id_not_opted_in_opens_nothing(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
other = ProjectTable(
name="Not Opted In",
slug="not-opted-in",
git_url="https://github.com/x/notopted.git",
default_branch="master",
protected_branches=["master"],
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
is_active=True,
video_engine_enabled=False,
)
db_session.add(other)
await db_session.flush()
engine = video_engine_module.VideoEngine(db_session)
task = await engine.open_video_task(
occasion="on-demand not opted",
script="s",
platforms=["x"],
brief="b",
project_id=cast("UUID", other.id),
)
assert task is None
@pytest.mark.asyncio
async def test_open_video_task_explicit_project_id_unresolvable_opens_nothing(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
engine = video_engine_module.VideoEngine(db_session)
task = await engine.open_video_task(
occasion="on-demand missing project",
script="s",
platforms=["x"],
brief="b",
project_id=uuid4(),
)
assert task is None
# --------------------------------------------------------------------------- #
# _originate_video_post
# --------------------------------------------------------------------------- #
@@ -644,5 +732,89 @@ async def test_draft_release_video_dedupes_same_version(
second = await engine.draft_release_video(version="1.0.0", changelog=_CHANGELOG)
assert first is not None
assert second is None
# --------------------------------------------------------------------------- #
# rerender — CEO-triggered clear of the render idempotency keys
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_rerender_clears_render_state_keeps_the_rest(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
engine = video_engine_module.VideoEngine(db_session)
task = await engine.open_video_task(
occasion="rerender-me", script="s", platforms=["x"], brief="b"
)
assert task is not None
draft = markers.get_video_draft(task) or {}
markers.set_video_draft(
task,
{
**draft,
"composition_id": "Intro",
"render_status": "failed",
"render_attempts": THREE,
"render_error": "sidecar timeout",
},
)
task.status = TS.COMPLETED
await db_session.flush()
result = await engine.rerender(cast("UUID", task.id))
assert result is not None
cleared = markers.get_video_draft(result)
assert cleared is not None
assert "render_status" not in cleared
assert "render_attempts" not in cleared
assert "render_error" not in cleared
assert cleared["composition_id"] == "Intro" # everything else preserved
@pytest.mark.asyncio
async def test_rerender_none_for_non_completed_task(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
engine = video_engine_module.VideoEngine(db_session)
task = await engine.open_video_task(
occasion="not-yet-done", script="s", platforms=["x"], brief="b"
)
assert task is not None
draft = markers.get_video_draft(task) or {}
markers.set_video_draft(task, {**draft, "composition_id": "Intro"})
await db_session.flush() # still PENDING, not COMPLETED
result = await engine.rerender(cast("UUID", task.id))
assert result is None
@pytest.mark.asyncio
async def test_rerender_none_without_composition_id(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
engine = video_engine_module.VideoEngine(db_session)
task = await engine.open_video_task(
occasion="no-composition-yet", script="s", platforms=["x"], brief="b"
)
assert task is not None
task.status = TS.COMPLETED
await db_session.flush() # no propose_video call yet -> no composition_id
result = await engine.rerender(cast("UUID", task.id))
assert result is None
@pytest.mark.asyncio
async def test_rerender_none_for_missing_task(db_session: AsyncSession) -> None:
engine = video_engine_module.VideoEngine(db_session)
result = await engine.rerender(uuid4())
assert result is None
open_tasks = await get_task_service(db_session).list_open_video_posts()
assert len(open_tasks) == ONE
assert open_tasks == []