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:
Renzo F
2026-07-09 08:31:08 +02:00
committed by GitHub
co-authored by Renn F
parent 12b91d17a2
commit 18998c4a42
28 changed files with 1677 additions and 190 deletions
+183
View File
@@ -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,