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
+256 -2
View File
@@ -12,6 +12,7 @@ from unittest.mock import AsyncMock, patch
from uuid import uuid4
import pytest
from roboco.config import settings as cfg
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers
@@ -126,6 +127,40 @@ async def _seed_draft(
return task
_FEATURE_SLUG = "org-memory"
_FEATURE_TITLE = "Organizational Memory Loop"
async def _seed_feature_draft(
session: AsyncSession,
*,
wants_video: bool = True,
video_script: str = "",
body: str = "Draft body",
) -> TaskTable:
"""An X_FEATURE_SOURCE draft carrying the x_feature_ref marker
``propose_feature_spotlight`` stamps (Task 4, 2026-07-09 pipeline fixes):
slug/title always, plus wants_video/video_script when a companion video
was requested at authoring time."""
task = await _seed_draft(session, source=X_FEATURE_SOURCE, body=body)
markers.set_x_feature_ref(
task,
{
"slug": _FEATURE_SLUG,
"title": _FEATURE_TITLE,
"wants_video": wants_video,
"video_script": video_script,
},
)
await session.flush()
return task
def _enable_video(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(cfg, "video_engine_enabled", True)
monkeypatch.setattr(cfg, "video_on_spotlight", True)
def _svc(session: AsyncSession) -> XPostService:
return get_x_post_service(session)
@@ -324,8 +359,9 @@ async def test_list_open_posts_excludes_terminal(db_session: AsyncSession) -> No
async def test_approve_posts_feature_spotlight_draft(
db_session: AsyncSession,
) -> None:
"""The feature-spotlight source needs zero service changes: it rides the
same generic approve path as x_post/x_reply."""
"""The feature-spotlight source rides the same generic post path as
x_post/x_reply; it only branches for the best-effort video hook below
(a no-op here since this draft carries no x_feature_ref marker)."""
task = await _seed_draft(db_session, source=X_FEATURE_SOURCE)
client = _StubClient()
with (
@@ -463,3 +499,221 @@ async def test_approve_does_not_flush_edited_body_before_lock(
assert result.status == "already_posted"
await db_session.refresh(task)
assert markers.get_x_draft_body(task) == original_body
# --------------------------------------------------------------------------- #
# Spotlight video hook (Task 4, 2026-07-09 pipeline fixes): moved from
# authoring time (propose_feature_spotlight) to this posted-success branch so
# a ux-dev never burns a cycle on a spotlight the CEO then rejects.
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_approve_feature_spotlight_with_video_opens_video_task(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
_enable_video(monkeypatch)
task = await _seed_feature_draft(db_session, video_script="Custom voiceover script")
client = _StubClient()
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
video_engine.open_video_task.assert_awaited_once()
kwargs = video_engine.open_video_task.call_args.kwargs
assert kwargs["occasion"] == "spotlight org-memory"
assert kwargs["platforms"] == ["x", "tiktok"]
assert kwargs["script"] == "Custom voiceover script"
assert kwargs["brief"] == "Organizational Memory Loop: Draft body"
@pytest.mark.asyncio
async def test_approve_feature_spotlight_video_falls_back_to_brief_script(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""No explicit video_script -> script falls back to the brief, mirroring
the fallback the authoring-time hook used to do."""
_enable_video(monkeypatch)
task = await _seed_feature_draft(db_session)
client = _StubClient()
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
kwargs = video_engine.open_video_task.call_args.kwargs
expected_brief = "Organizational Memory Loop: Draft body"
assert kwargs["script"] == expected_brief
assert kwargs["brief"] == expected_brief
@pytest.mark.asyncio
async def test_approve_feature_spotlight_reapprove_does_not_reopen_video(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Idempotent re-approve: the second call short-circuits on the already-
COMPLETED check before ever reaching _post/_open_spotlight_video again."""
_enable_video(monkeypatch)
task = await _seed_feature_draft(db_session)
client = _StubClient()
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
),
):
svc = _svc(db_session)
first = await svc.approve(_id(task))
second = await svc.approve(_id(task))
assert first is not None
assert first.status == "posted"
assert second is not None
assert second.status == "already_posted"
video_engine.open_video_task.assert_awaited_once()
@pytest.mark.asyncio
async def test_approve_plain_x_post_never_opens_video(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A plain x_post draft carries no x_feature_ref, so the source check
alone keeps the video hook from ever firing for it."""
_enable_video(monkeypatch)
task = await _seed_draft(db_session, source=X_POST_SOURCE)
client = _StubClient()
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
video_engine.open_video_task.assert_not_called()
@pytest.mark.asyncio
async def test_reject_feature_spotlight_with_wants_video_opens_none(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Rejecting a spotlight draft never posts, so the video hook (which only
fires from the posted-success branch of _post) never runs either."""
_enable_video(monkeypatch)
task = await _seed_feature_draft(db_session)
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
):
updated = await _svc(db_session).reject(_id(task), "not on-brand")
assert updated is not None
assert updated.status == TS.CANCELLED
video_engine.open_video_task.assert_not_called()
@pytest.mark.asyncio
async def test_approve_feature_spotlight_video_flags_off_skips(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(cfg, "video_engine_enabled", False)
monkeypatch.setattr(cfg, "video_on_spotlight", False)
task = await _seed_feature_draft(db_session)
client = _StubClient()
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
video_engine.open_video_task.assert_not_called()
@pytest.mark.asyncio
async def test_approve_feature_spotlight_without_wants_video_skips(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Flags on but the draft's author didn't request a video (wants_video
absent/False on the marker) -> no video task, distinct from the
flags-off case above."""
_enable_video(monkeypatch)
task = await _seed_feature_draft(db_session, wants_video=False)
client = _StubClient()
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
video_engine.open_video_task.assert_not_called()
@pytest.mark.asyncio
async def test_approve_feature_spotlight_video_failure_does_not_break_post(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Best-effort: a video-engine blow-up must not affect the already-
succeeded post."""
_enable_video(monkeypatch)
task = await _seed_feature_draft(db_session)
client = _StubClient()
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
side_effect=RuntimeError("video-engine boom"),
),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
await db_session.refresh(task)
assert task.status == TS.COMPLETED