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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user