diff --git a/docs/map/video-engine.md b/docs/map/video-engine.md index 5c8a5ec1..53358788 100644 --- a/docs/map/video-engine.md +++ b/docs/map/video-engine.md @@ -17,7 +17,8 @@ The RoboCo video engine: a default-off subsystem that authors bespoke short mark | `roboco/services/gateway/content_actions.py` `propose_video` | Server-side action: team-gated (`_caller_team` rejects be-dev/fe-dev), resolves the caller's open video task, `markers.set_video_draft` with the metadata. | — | | `roboco/services/gateway/content_actions.py` `request_render` | Do-verb (developer/QA): renders the caller's ACTUAL composition to keyframe PNGs via the sidecar's frames mode and stamps the `render_preview` marker — dev renders their own tree (worktree-aware, `head_sha`/`dirty` stamped), QA a read-only branch export (`WorkspaceService.export_branch_motion`). Frames land at the container-shared `{workspaces_root}/{project}/.previews/{task8}/{orientation}/`. | — | | `roboco/foundation/policy/tracing.py` `RENDER_VERIFIED` | `i_am_done` requirement on `source=video` tasks: no stamped `render_preview` → tracing gap naming `render_preview` (hint: call `request_render`, Read every frame). Mirrored in the possibilities-matrix fast path. | — | -| `roboco/api/routes/video.py` `GET /video/preview-frames/{task_id}` / `.../{orientation}/{filename}` | CEO-gated routes serving the dev's already-rendered `request_render` preview frames — a `source=video` authoring task reaches `awaiting_ceo_approval` with no MP4 yet (rendering only happens post-completion), so the CEO otherwise has nothing to review. Frame listing is parsed from the self-describing `.previews/{task8}/{orientation}/frame--of--at-s.png` filenames (`_FRAME_NAME_RE`) rather than the `render_preview` marker (which only holds the LAST call's single orientation); the per-frame route streams a PNG behind the same `_resolve_preview_path` confinement guard the composition-HTML proxy already used. | — | +| `roboco/api/routes/video.py` `GET /video/preview-frames/{task_id}` / `.../{orientation}/{filename}` | CEO-gated thin route handlers serving the dev's already-rendered `request_render` preview frames — a `source=video` authoring task reaches `awaiting_ceo_approval` with no MP4 yet (rendering only happens post-completion), so the CEO otherwise has nothing to review. `list_preview_frames` delegates listing to `previews_root` + `list_orientation_frames` in `roboco/utils/video.py` (frame index/timestamp parsed there from the self-describing `.previews/{task8}/{orientation}/frame--of--at-s.png` filenames via `_FRAME_NAME_RE`, rather than the `render_preview` marker which only holds the LAST call's single orientation); `get_preview_frame` streams a PNG behind `resolve_preview_path` reused from `video_engine` (the same confinement guard the composition-HTML proxy uses). Response schemas `PreviewFrameResponse` / `VideoPreviewFramesResponse` live in `roboco/api/schemas/video.py`. | — | +| `roboco/utils/video.py` | Pure path/listing helpers for the CEO preview-frames routes — no DB access, no route definitions. `previews_root(task_id, project_slug)` returns the container-shared `{workspaces_root}/{project}/.previews/{task8}/` dir; `list_orientation_frames(orientation_dir)` parses the `frame--of--at-s.png` filenames (`_FRAME_NAME_RE`) into `ParsedFrame` namedtuples sorted by index. Extracted out of the route module so the route layer holds only `@router`-decorated handlers. | 55 | | `alembic/versions/062_tiktok_credentials.py` | Migration 062 — the `tiktok_credentials` singleton row (Fernet-encrypted OAuth2 secrets, all-or-nothing set/clear, mirroring the git-token / `x_credentials` pattern). | 44 | | `video-renderer/` | The sidecar: `server.js` (HTTP+tarball boundary), `render.js` (`@hyperframes/producer` `createRenderJob` + `executeRenderJob`, system `ffmpeg`, headless Chromium). Credential-free and git-free — reads only what's POSTed. pnpm-managed (`pnpm-lock.yaml`, no npm `package-lock.json`); `@hyperframes/producer` pinned exact at `0.7.36` (`0.7.60` fails every render). | — | | `docker/video-renderer.Dockerfile` | Sidecar image (`roboco-video-renderer`): Node + Chromium + system `ffmpeg`; installs `@hyperframes/producer`. No RoboCo source, no creds. | — | @@ -47,6 +48,7 @@ CEO ACT: `GET /api/video/posts` lists held drafts (including `mp4_paths`); `GET ## Changes Since Baseline - **2026-07-20** (#608, `a5d8c6bd`, "CEO can preview a video authoring task's frames before approving"): two CEO-gated routes serve the `request_render` preview frames (see Files above) so the CEO has something concrete to review at `awaiting_ceo_approval` before any MP4 exists. The task-detail Overview gains a Video preview card — a 9:16/1:1 toggle + prev/next/scrubber frame stepper with composition id, duration, and a dirty badge — shown for a video task with preview frames or awaiting CEO approval. +- **2026-08-01** (PR #792, `26560bcb`, "Restore deleted preview-frames handlers + schemas + tests"): a prior route-helper extraction (#769) wrongly deleted both preview-frames `@router.get` handlers, the `PreviewFrameResponse`/`VideoPreviewFramesResponse` schemas, and all 10 integration tests instead of only moving the non-decorated helpers. This restores the two handlers in `roboco/api/routes/video.py` as thin `@router.get` delegators (route layer keeps the decorated handlers; only module-level helpers move), the two schemas in `roboco/api/schemas/video.py`, and the 10 integration tests in `tests/integration/test_video_routes.py` (listing, metadata, 404s, non-CEO forbidden, symlink traversal confinement). The listing/path helpers now live in `roboco/utils/video.py` (`previews_root`, `list_orientation_frames`); `resolve_preview_path` is reused from `video_engine`. No observable behavior, route path, or schema shape change — placement-only. - **2026-07-17** (PR #543, `3e801697`): Two renderer root causes fixed — `@hyperframes/producer` was floating (`^0.7.36`, no lockfile), so image builds silently picked up `0.7.60`, which fails EVERY render ("Cannot access 'rt' before initialization"); pinned exact (`0.7.36`, no caret) and committed a lockfile (regenerated as `pnpm-lock.yaml` by the immediate follow-up `a12fefcb`, not the npm `package-lock.json` this PR first wrote — this package is pnpm-managed). Second: the producer's per-clip visibility scheduler runs on a clock that lags ~50% behind the encoded timeline on a long cut, so tail scenes (past roughly the halfway mark) were silently missing from the MP4 regardless of authoring — fixed by treating `class="clip"` + `data-start`/`data-duration` as a structural-layer-only primitive and driving every beat with base-hidden styles + a delayed CSS animation instead (documented in `motion/README.md`'s "Clip windows are for structural layers only" rule). Also added the two choreography engines to `motion/kit/kit.js` (`choreographCursor` / `choreographCamera`, see Files above) plus a "Cinematography & rhythm" section in `motion/README.md` and a craft-bar block in the dev video spawn prompt (`roboco/runtime/orchestrator.py`) so a locked-off camera or a popping/freezing cursor reads as an automatic revision. - **2026-07-17** (PR #544, `fd621f0d`): The three craft capabilities wired one hop closer to the hands doing video work — vendored the vendor's own official HyperFrames agent skills (`hyperframes-core`/`-creative`/`-keyframes.md`, see Files above; supersedes the external-pointer-only version briefly added by the intervening `1416bd1d`); registered the `playwright` MCP for a ux-dev spawned onto a `source=video` task (`_is_video_authoring_spawn`, fail-closed role/team/task-source probe — gating-only, `agent-ux`'s image already bakes the browser); and added a video-mode override to the `ux_ui` team prompt's design bar ("video-authoring tasks are FILMS, not UI — these dials do not apply") so a video task no longer reads its own "dense product UI → motion 2-3" dial as license to ship a static slideshow. - **2026-07-17** (Wave 6, PR #550): Authoring craft, not engine code — `motion/README.md` gained `## Visual design bar (demo/kit register)` (spacing/hierarchy, beat pacing, `pk-chip`/`pk-pill` semantic discipline, camera+cursor+rhythm, anti-generic tells for the `kit/` register), four upstream HyperFrames craft references vendored verbatim under `motion/skills/references/` (fixing `hyperframes-creative.md`'s previously-dead `references/` pointers), and a new `motion/skills/hyperframes-catalog-index.md` (133-entry HyperFrames catalog vocabulary index, read-on-demand). No service/verb/schema change; the render/post pipeline documented above is untouched. diff --git a/roboco/api/routes/video.py b/roboco/api/routes/video.py index 1d31d2b2..9a5d9da2 100644 --- a/roboco/api/routes/video.py +++ b/roboco/api/routes/video.py @@ -14,6 +14,7 @@ from fastapi.responses import FileResponse, StreamingResponse from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role from roboco.api.schemas.video import ( + PreviewFrameResponse, TikTokCredentialsSetRequest, TikTokCredentialsStatus, VideoPipelineItemResponse, @@ -22,6 +23,7 @@ from roboco.api.schemas.video import ( VideoPostHistoryResponse, VideoPostRejectRequest, VideoPostResponse, + VideoPreviewFramesResponse, VideoRequestBody, VideoRequestResponse, task_to_pipeline_item, @@ -29,6 +31,7 @@ from roboco.api.schemas.video import ( task_to_video_post_response, ) 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.project import get_project_service @@ -45,6 +48,7 @@ from roboco.services.video_post_service import ( resolve_video_cut, ) from roboco.services.workspace import WorkspaceError, get_workspace_service +from roboco.utils.video import list_orientation_frames, previews_root router = APIRouter() tiktok_router = APIRouter() @@ -151,6 +155,84 @@ async def rerender_video_task( return task_to_pipeline_item(task) +@router.get("/preview-frames/{task_id}", response_model=VideoPreviewFramesResponse) +async def list_preview_frames( + task_id: UUID, db: DbSession, agent: CurrentAgentContext +) -> VideoPreviewFramesResponse: + """A video-authoring task's ``request_render`` preview frames — the CEO's + only look at the rendered artifact before the post-completion render loop + produces the real MP4. Frame listing is parsed from the self-describing + ``.previews/{task8}/{orientation}/frame--of--at-s.png`` + filenames on disk; metadata (composition_id, duration, head_sha, dirty, + rendered_at) comes from the last ``render_preview`` marker. CEO-only.""" + require_ceo_role(agent.role, action="view or act on the video engine") + 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" + ) + root = previews_root(task_id, project.slug) + frames: dict[str, list[PreviewFrameResponse]] = {} + for orientation in _VALID_CUTS: + parsed = list_orientation_frames(root / orientation) + if parsed: + frames[orientation] = [ + PreviewFrameResponse( + index=f.frame_index, + file=f.file, + timestamp_seconds=f.timestamp_seconds, + ) + for f in parsed + ] + meta = markers.get_render_preview(task) or {} + return VideoPreviewFramesResponse( + task_id=str(task_id), + composition_id=meta.get("composition_id"), + duration_seconds=meta.get("duration_seconds"), + head_sha=meta.get("head_sha"), + dirty=meta.get("dirty"), + rendered_at=meta.get("at"), + frames=frames, + ) + + +@router.get("/preview-frames/{task_id}/{orientation}/{filename}", response_model=None) +async def get_preview_frame( + task_id: UUID, + orientation: str, + filename: str, + db: DbSession, + agent: CurrentAgentContext, +) -> FileResponse: + """Serve one ``request_render`` preview-frame PNG — the panel preview + card's ``. Confined to the task's ``.previews/{task8}/`` root via + ``resolve_preview_path`` so a symlink or ``..`` can't traverse out. + CEO-only.""" + require_ceo_role(agent.role, action="view or act on the video engine") + 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" + ) + root = previews_root(task_id, project.slug) + resolved = resolve_preview_path(root, f"{orientation}/{filename}") + if resolved is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="No such preview frame" + ) + return FileResponse(resolved, media_type="image/png") + + @router.get("/preview/{task_id}/{file_path:path}", response_model=None) async def get_video_preview( task_id: UUID, diff --git a/roboco/api/schemas/video.py b/roboco/api/schemas/video.py index 8e785679..8fff300c 100644 --- a/roboco/api/schemas/video.py +++ b/roboco/api/schemas/video.py @@ -117,6 +117,32 @@ class VideoPipelineItemResponse(BaseModel): project_name: str | None = None +class PreviewFrameResponse(BaseModel): + """One extracted ``request_render`` preview frame — index/timestamp decoded + server-side from the sidecar's self-describing filename.""" + + index: int + file: str + timestamp_seconds: float + + +class VideoPreviewFramesResponse(BaseModel): + """A video-authoring task's ``request_render`` preview — the CEO's only + look at the rendered artifact before the post-completion render loop + produces the real MP4 (an ``awaiting_ceo_approval`` task otherwise has + nothing to watch). Frames keyed by orientation; an absent/empty key was + never rendered. Metadata (composition_id, duration_seconds, head_sha, + dirty, rendered_at) comes from the last ``render_preview`` marker.""" + + task_id: str + composition_id: str | None = None + duration_seconds: float | None = None + head_sha: str | None = None + dirty: bool | None = None + rendered_at: str | None = None + frames: dict[str, list[PreviewFrameResponse]] = Field(default_factory=dict) + + class TikTokCredentialsStatus(BaseModel): """Whether the four OAuth2 secrets are stored. Never the secrets themselves.""" diff --git a/roboco/utils/video.py b/roboco/utils/video.py new file mode 100644 index 00000000..6993d7a3 --- /dev/null +++ b/roboco/utils/video.py @@ -0,0 +1,54 @@ +"""Video preview-frame helpers — path resolution and frame-filename parsing +for the CEO preview-frames routes. Pure path/listing utilities; no DB access, +no route definitions.""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import TYPE_CHECKING, NamedTuple + +from roboco.config import settings + +if TYPE_CHECKING: + from uuid import UUID + +_FRAME_NAME_RE = re.compile(r"^frame-(\d+)-of-(\d+)-at-([\d.]+)s\.png$") + + +class ParsedFrame(NamedTuple): + """One preview frame parsed from its self-describing filename.""" + + frame_index: int + file: str + timestamp_seconds: float + + +def previews_root(task_id: UUID, project_slug: str) -> Path: + """The container-shared preview-frames dir for a video-authoring task: + ``{workspaces_root}/{project}/.previews/{task8}/``. Every agent container + mounts the same ``/data/workspaces``, so the CEO's container reads the + frames the dev's container rendered.""" + return Path(settings.workspaces_root) / project_slug / ".previews" / task_id.hex[:8] + + +def list_orientation_frames(orientation_dir: Path) -> list[ParsedFrame]: + """List the preview frames in ``orientation_dir``, parsed from the + self-describing ``frame--of--at-s.png`` filenames. Returns + ``ParsedFrame`` namedtuples sorted by index. Returns an empty list when + the directory doesn't exist or holds no matching files.""" + frames: list[ParsedFrame] = [] + if not orientation_dir.is_dir(): + return frames + for entry in sorted(orientation_dir.iterdir()): + m = _FRAME_NAME_RE.match(entry.name) + if m is None or not entry.is_file(): + continue + frames.append( + ParsedFrame( + frame_index=int(m.group(1)), + file=entry.name, + timestamp_seconds=float(m.group(3)), + ) + ) + return frames diff --git a/tests/integration/test_video_routes.py b/tests/integration/test_video_routes.py index acf84ba5..1d07c32d 100644 --- a/tests/integration/test_video_routes.py +++ b/tests/integration/test_video_routes.py @@ -47,6 +47,8 @@ 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 +VERTICAL_FRAME_COUNT = 2 +RENDER_DURATION_SECONDS = 6.4 async def _seed(session: AsyncSession) -> None: @@ -1288,3 +1290,236 @@ async def test_preview_non_ceo_is_forbidden(db_session: AsyncSession) -> None: resp = await client.get(f"/api/video/preview/{task.id}/vertical.html") assert resp.status_code == HTTPStatus.FORBIDDEN app.dependency_overrides.clear() + + +# --- preview-frames (request_render frame strip) ------------------------------ +# CEO-gated routes serving the dev's already-rendered request_render preview +# frames. Frame listing is parsed from the self-describing +# .previews/{task8}/{orientation}/frame--of--at-s.png filenames. + + +def _write_frame(orientation_dir: Path, idx: int, total: int, ts: float) -> Path: + """Write one self-describing frame PNG into ``orientation_dir``.""" + orientation_dir.mkdir(parents=True, exist_ok=True) + path = orientation_dir / f"frame-{idx:02d}-of-{total}-at-{ts}s.png" + path.write_bytes(b"fake-png-bytes") + return path + + +async def _seed_video_task_with_project( + db_session: AsyncSession, +) -> tuple[TaskTable, str]: + """Seed a source=video authoring task and return (task, project_slug).""" + task = await _seed_authoring_task( + db_session, + status=TaskStatus.AWAITING_CEO_APPROVAL, + draft_extra={"composition_id": "Intro"}, + ) + project = await db_session.get(ProjectTable, task.project_id) + assert project is not None and project.slug is not None + return task, project.slug + + +@pytest.mark.asyncio +async def test_preview_frames_lists_frames_for_task( + db_session: AsyncSession, + ceo_client: AsyncClient, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The CEO GET /preview-frames/{task_id} returns per-orientation frame + lists parsed from the self-describing filenames on disk.""" + task, slug = await _seed_video_task_with_project(db_session) + monkeypatch.setattr(cfg, "workspaces_root", str(tmp_path)) + root = tmp_path / slug / ".previews" / str(task.id).replace("-", "")[:8] + _write_frame(root / "vertical", 1, 2, 1.5) + _write_frame(root / "vertical", 2, 2, 4.5) + _write_frame(root / "square", 1, 1, 3.0) + + resp = await ceo_client.get(f"/api/video/preview-frames/{task.id}") + assert resp.status_code == HTTPStatus.OK + body = resp.json() + assert body["task_id"] == str(task.id) + assert set(body["frames"].keys()) == {"vertical", "square"} + v = body["frames"]["vertical"] + assert len(v) == VERTICAL_FRAME_COUNT + assert v[0] == { + "index": 1, + "file": "frame-01-of-2-at-1.5s.png", + "timestamp_seconds": 1.5, + } + assert v[1] == { + "index": 2, + "file": "frame-02-of-2-at-4.5s.png", + "timestamp_seconds": 4.5, + } + s = body["frames"]["square"] + assert len(s) == 1 + assert s[0] == { + "index": 1, + "file": "frame-01-of-1-at-3.0s.png", + "timestamp_seconds": 3.0, + } + + +@pytest.mark.asyncio +async def test_preview_frames_includes_render_metadata( + db_session: AsyncSession, + ceo_client: AsyncClient, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The response carries composition_id, duration_seconds, head_sha, dirty, + and rendered_at from the last render_preview marker stamp.""" + task, slug = await _seed_video_task_with_project(db_session) + monkeypatch.setattr(cfg, "workspaces_root", str(tmp_path)) + root = tmp_path / slug / ".previews" / str(task.id).replace("-", "")[:8] + _write_frame(root / "vertical", 1, 2, 1.5) + markers.set_render_preview( + task, + { + "at": "2026-07-19T12:00:00Z", + "composition_id": "Intro", + "orientation": "vertical", + "frame_count": 2, + "duration_seconds": 6.4, + "frames": [str(root / "vertical" / "frame-01-of-2-at-1.5s.png")], + "head_sha": "abc1234", + "dirty": False, + "rendered_by": "ux-dev-1", + "source": "worktree", + }, + ) + await db_session.flush() + + resp = await ceo_client.get(f"/api/video/preview-frames/{task.id}") + body = resp.json() + assert body["composition_id"] == "Intro" + assert body["duration_seconds"] == RENDER_DURATION_SECONDS + assert body["head_sha"] == "abc1234" + assert body["dirty"] is False + assert body["rendered_at"] == "2026-07-19T12:00:00Z" + + +@pytest.mark.asyncio +async def test_preview_frames_missing_task_is_404(ceo_client: AsyncClient) -> None: + resp = await ceo_client.get(f"/api/video/preview-frames/{uuid4()}") + assert resp.status_code == HTTPStatus.NOT_FOUND + + +@pytest.mark.asyncio +async def test_preview_frames_non_video_task_is_404( + db_session: AsyncSession, ceo_client: AsyncClient +) -> None: + """A video_post draft (not a video authoring task) is not a preview-frames + source — 404, not a frame listing.""" + task = await _seed_draft(db_session) # source=video_post + resp = await ceo_client.get(f"/api/video/preview-frames/{task.id}") + assert resp.status_code == HTTPStatus.NOT_FOUND + + +@pytest.mark.asyncio +async def test_preview_frames_non_ceo_is_forbidden(db_session: AsyncSession) -> None: + task, _slug = await _seed_video_task_with_project(db_session) + 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-frames/{task.id}") + assert resp.status_code == HTTPStatus.FORBIDDEN + app.dependency_overrides.clear() + + +@pytest.mark.asyncio +async def test_preview_frames_empty_when_no_renders( + db_session: AsyncSession, + ceo_client: AsyncClient, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A video task with no rendered frames returns an empty frames dict — + not a 404, not an error. The panel shows a muted empty state.""" + task, _slug = await _seed_video_task_with_project(db_session) + monkeypatch.setattr(cfg, "workspaces_root", str(tmp_path)) + resp = await ceo_client.get(f"/api/video/preview-frames/{task.id}") + assert resp.status_code == HTTPStatus.OK + body = resp.json() + assert body["task_id"] == str(task.id) + assert body["frames"] == {} + + +@pytest.mark.asyncio +async def test_preview_frame_serves_png( + db_session: AsyncSession, + ceo_client: AsyncClient, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The per-frame route streams the PNG bytes for a specific frame.""" + task, slug = await _seed_video_task_with_project(db_session) + monkeypatch.setattr(cfg, "workspaces_root", str(tmp_path)) + root = tmp_path / slug / ".previews" / str(task.id).replace("-", "")[:8] + _write_frame(root / "vertical", 1, 2, 1.5) + + resp = await ceo_client.get( + f"/api/video/preview-frames/{task.id}/vertical/frame-01-of-2-at-1.5s.png" + ) + assert resp.status_code == HTTPStatus.OK + assert resp.headers["content-type"] == "image/png" + assert resp.content == b"fake-png-bytes" + + +@pytest.mark.asyncio +async def test_preview_frame_missing_file_is_404( + db_session: AsyncSession, + ceo_client: AsyncClient, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A non-existent frame file is a 404, not a crash.""" + task, _slug = await _seed_video_task_with_project(db_session) + monkeypatch.setattr(cfg, "workspaces_root", str(tmp_path)) + resp = await ceo_client.get( + f"/api/video/preview-frames/{task.id}/vertical/frame-99-of-99-at-9.9s.png" + ) + assert resp.status_code == HTTPStatus.NOT_FOUND + + +@pytest.mark.asyncio +async def test_preview_frame_symlink_escape_is_404( + db_session: AsyncSession, + ceo_client: AsyncClient, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A symlink placed inside the orientation dir that points outside the + previews root must not serve the outside file — ``resolve_preview_path`` + follows the link before the ``is_relative_to`` confinement check, so the + escape target is caught the same as a plain ``..`` traversal.""" + task, slug = await _seed_video_task_with_project(db_session) + monkeypatch.setattr(cfg, "workspaces_root", str(tmp_path)) + root = ( + tmp_path / slug / ".previews" / str(task.id).replace("-", "")[:8] / "vertical" + ) + root.mkdir(parents=True) + outside = tmp_path / "secret.png" + outside.write_bytes(b"secret") + link = root / "frame-01-of-1-at-0.0s.png" + link.symlink_to(outside) + + resp = await ceo_client.get( + f"/api/video/preview-frames/{task.id}/vertical/frame-01-of-1-at-0.0s.png" + ) + assert resp.status_code == HTTPStatus.NOT_FOUND + + +@pytest.mark.asyncio +async def test_preview_frame_non_ceo_is_forbidden(db_session: AsyncSession) -> None: + task, _slug = await _seed_video_task_with_project(db_session) + 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-frames/{task.id}/vertical/frame-01-of-2-at-1.5s.png" + ) + assert resp.status_code == HTTPStatus.FORBIDDEN + app.dependency_overrides.clear()