mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Video pipeline fixes: visibility strip, rich briefs, spotlight timing + fps (#369)
* feat(video): pipeline visibility — strip, state-aware queue, render-error capture Task 1 of the 2026-07-09 video-pipeline review. New CEO-gated GET /video/pipeline lists every in-flight video item (authoring statuses, rendering attempt n/max, terminal failures with the error — now stamped onto the video_draft marker instead of dying as a log line). source_task_id exposed on both video schemas. Panel: pipeline strip on the Social page, state-aware queue empty copy, title/script on queue rows, missing cuts disabled instead of a blank player, notifications deep-link related_task_id. MAX_VIDEO_RENDER_ATTEMPTS moved to the markers policy layer (single source of truth). * feat(video): rich authoring briefs — changelog section, brand voice, kit pointer Task 2 of the 2026-07-09 video-pipeline review. The release brief is now a structured block (full CHANGELOG section capped at 4000 chars + highlights) instead of one LLM-compressed sentence; brand_voice and a motion/kit design-bar pointer are appended centrally in open_video_task so release, spotlight, and on-demand paths all inherit them. suggested_input_props seeded on the video_draft marker; third acceptance criterion pins the design bar; propose_video docstring points at the kit. * fix(video): spotlight video drafts on CEO approval, renderer honors data-fps Task 4 of the 2026-07-09 video-pipeline review. The companion-video hook moves from propose_feature_spotlight (HoM authoring time) to XPostService approve's posted-success branch for x_feature drafts, mirroring the release-publish seam — a rejected spotlight no longer burns a ux-dev cycle; wants_video/video_script ride the x_feature_ref marker. Best-effort: a video-engine failure never breaks the post. render.js reads data-fps from the composition HTML (clamped 24-60, fallback 30) instead of hardcoding 30; parseFps covered by node --test. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -45,6 +45,7 @@ SYSTEM_UUID = _foundation.AGENTS["system"].uuid
|
||||
UX_DEV_1_UUID = _foundation.AGENTS["ux-dev-1"].uuid
|
||||
UX_DEV_2_UUID = _foundation.AGENTS["ux-dev-2"].uuid
|
||||
HISTORY_LIMIT = 2
|
||||
RETRY_ATTEMPTS = 2
|
||||
|
||||
|
||||
async def _seed(session: AsyncSession) -> None:
|
||||
@@ -168,6 +169,55 @@ async def _seed_draft(
|
||||
return task
|
||||
|
||||
|
||||
async def _seed_authoring_task(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
status: TaskStatus = TaskStatus.IN_PROGRESS,
|
||||
draft_extra: dict[str, object] | None = None,
|
||||
pr_number: int | None = None,
|
||||
) -> TaskTable:
|
||||
"""A ``source=video`` UX/UI authoring task — the pipeline route's basis.
|
||||
Mirrors ``_seed_draft`` but for the pre-render authoring stage."""
|
||||
system = await _seed_agent(session, AgentRole.SYSTEM, "system")
|
||||
ux_dev = await _seed_agent(session, AgentRole.DEVELOPER, "ux-dev")
|
||||
project = ProjectTable(
|
||||
id=uuid4(),
|
||||
name="RoboCo",
|
||||
slug=f"roboco-{uuid4().hex[:6]}",
|
||||
git_url="https://example.com/roboco.git",
|
||||
assigned_cell=Team.UX_UI,
|
||||
created_by=system.id,
|
||||
)
|
||||
session.add(project)
|
||||
await session.flush()
|
||||
task = TaskTable(
|
||||
id=uuid4(),
|
||||
title="Video: launch teaser",
|
||||
description="A short teaser for the launch",
|
||||
acceptance_criteria=["dev builds the composition"],
|
||||
status=status,
|
||||
priority=2,
|
||||
task_type=TaskType.CODE,
|
||||
nature=TaskNature.TECHNICAL,
|
||||
estimated_complexity=Complexity.LOW,
|
||||
project_id=project.id,
|
||||
created_by=system.id,
|
||||
assigned_to=ux_dev.id,
|
||||
team=Team.UX_UI,
|
||||
source=VIDEO_SOURCE,
|
||||
confirmed_by_human=True,
|
||||
pr_number=pr_number,
|
||||
)
|
||||
session.add(task)
|
||||
await session.flush()
|
||||
markers.set_video_draft(
|
||||
task,
|
||||
{"occasion": "launch teaser", "script": "script", **(draft_extra or {})},
|
||||
)
|
||||
await session.flush()
|
||||
return task
|
||||
|
||||
|
||||
def _build_app(
|
||||
db_session: AsyncSession | None, role: AgentRole, agent_id: UUID
|
||||
) -> FastAPI:
|
||||
@@ -304,6 +354,139 @@ async def test_list_posts_returns_open_draft(
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_posts_includes_source_task_id(
|
||||
db_session: AsyncSession, ceo_client: AsyncClient
|
||||
) -> None:
|
||||
"""source_task_id round-trips from the marker to the response — the
|
||||
panel's basis for a future draft->authoring-task deep link."""
|
||||
source_task_id = uuid4()
|
||||
task = await _seed_draft(db_session)
|
||||
draft = markers.get_video_draft(task) or {}
|
||||
markers.set_video_draft(task, {**draft, "source_task_id": str(source_task_id)})
|
||||
await db_session.flush()
|
||||
resp = await ceo_client.get("/api/video/posts")
|
||||
assert resp.status_code == HTTPStatus.OK
|
||||
body = resp.json()
|
||||
assert body[0]["source_task_id"] == str(source_task_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_includes_source_task_id(
|
||||
db_session: AsyncSession, ceo_client: AsyncClient
|
||||
) -> None:
|
||||
source_task_id = uuid4()
|
||||
task = await _seed_draft(db_session)
|
||||
draft = markers.get_video_draft(task) or {}
|
||||
markers.set_video_draft(task, {**draft, "source_task_id": str(source_task_id)})
|
||||
await db_session.flush()
|
||||
with _LOCKED[0], _LOCKED[1]:
|
||||
await ceo_client.post(
|
||||
f"/api/video/posts/{task.id}/reject", json={"reason": "off-brand"}
|
||||
)
|
||||
resp = await ceo_client.get("/api/video/posts/history")
|
||||
body = resp.json()
|
||||
row = next(r for r in body if r["task_id"] == str(task.id))
|
||||
assert row["source_task_id"] == str(source_task_id)
|
||||
|
||||
|
||||
# --- pipeline strip (task 1, 2026-07-09) --------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_lists_non_terminal_authoring_task(
|
||||
db_session: AsyncSession, ceo_client: AsyncClient
|
||||
) -> None:
|
||||
task = await _seed_authoring_task(db_session, status=TaskStatus.IN_PROGRESS)
|
||||
resp = await ceo_client.get("/api/video/pipeline")
|
||||
assert resp.status_code == HTTPStatus.OK
|
||||
body = resp.json()
|
||||
row = next(r for r in body if r["task_id"] == str(task.id))
|
||||
assert row["status"] == "in_progress"
|
||||
assert row["occasion"] == "launch teaser"
|
||||
assert row["render_status"] is None
|
||||
assert row["render_attempts"] == 0
|
||||
assert row["max_attempts"] == markers.MAX_VIDEO_RENDER_ATTEMPTS
|
||||
assert row["render_error"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_shows_completed_unrendered_with_attempts(
|
||||
db_session: AsyncSession, ceo_client: AsyncClient
|
||||
) -> None:
|
||||
"""A COMPLETED authoring task the render loop hasn't finished with
|
||||
(render_status unset) stays visible with its retry count."""
|
||||
task = await _seed_authoring_task(
|
||||
db_session,
|
||||
status=TaskStatus.COMPLETED,
|
||||
draft_extra={"composition_id": "Intro", "render_attempts": RETRY_ATTEMPTS},
|
||||
)
|
||||
resp = await ceo_client.get("/api/video/pipeline")
|
||||
body = resp.json()
|
||||
row = next(r for r in body if r["task_id"] == str(task.id))
|
||||
assert row["render_attempts"] == RETRY_ATTEMPTS
|
||||
assert row["render_status"] is None
|
||||
assert row["composition_id"] == "Intro"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_shows_failed_render_with_error(
|
||||
db_session: AsyncSession, ceo_client: AsyncClient
|
||||
) -> None:
|
||||
task = await _seed_authoring_task(
|
||||
db_session,
|
||||
status=TaskStatus.COMPLETED,
|
||||
draft_extra={
|
||||
"composition_id": "Intro",
|
||||
"render_status": "failed",
|
||||
"render_attempts": markers.MAX_VIDEO_RENDER_ATTEMPTS,
|
||||
"render_error": "sidecar timeout",
|
||||
},
|
||||
)
|
||||
resp = await ceo_client.get("/api/video/pipeline")
|
||||
body = resp.json()
|
||||
row = next(r for r in body if r["task_id"] == str(task.id))
|
||||
assert row["render_status"] == "failed"
|
||||
assert row["render_attempts"] == markers.MAX_VIDEO_RENDER_ATTEMPTS
|
||||
assert row["render_error"] == "sidecar timeout"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_excludes_rendered_completed_task(
|
||||
db_session: AsyncSession, ceo_client: AsyncClient
|
||||
) -> None:
|
||||
"""A rendered task already materialized its video_post draft — it must
|
||||
not double-appear in the pipeline strip."""
|
||||
task = await _seed_authoring_task(
|
||||
db_session,
|
||||
status=TaskStatus.COMPLETED,
|
||||
draft_extra={"composition_id": "Intro", "render_status": "rendered"},
|
||||
)
|
||||
resp = await ceo_client.get("/api/video/pipeline")
|
||||
ids = [row["task_id"] for row in resp.json()]
|
||||
assert str(task.id) not in ids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_excludes_cancelled_task(
|
||||
db_session: AsyncSession, ceo_client: AsyncClient
|
||||
) -> None:
|
||||
task = await _seed_authoring_task(db_session, status=TaskStatus.CANCELLED)
|
||||
resp = await ceo_client.get("/api/video/pipeline")
|
||||
ids = [row["task_id"] for row in resp.json()]
|
||||
assert str(task.id) not in ids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_non_ceo_is_forbidden(db_session: AsyncSession) -> None:
|
||||
app = _build_app(db_session, AgentRole.DEVELOPER, uuid4())
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.get("/api/video/pipeline")
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_media_returns_the_rendered_cut(
|
||||
db_session: AsyncSession,
|
||||
|
||||
Reference in New Issue
Block a user