feat: add public routes for video presentations and audio streaming

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-03-21 23:29:23 -07:00
parent d90b6d35ce
commit eb8cfd296c
3 changed files with 222 additions and 19 deletions

View file

@ -21,6 +21,7 @@ from app.services.public_chat_service import (
get_public_chat,
get_snapshot_podcast,
get_snapshot_report,
get_snapshot_video_presentation,
)
from app.users import current_active_user
@ -117,6 +118,119 @@ async def stream_public_podcast(
)
@router.get("/{share_token}/video-presentations/{video_presentation_id}")
async def get_public_video_presentation(
share_token: str,
video_presentation_id: int,
session: AsyncSession = Depends(get_async_session),
):
"""
Get video presentation details from a public chat snapshot.
No authentication required - the share_token provides access.
Returns slide data (with public audio URLs) and scene codes.
"""
vp_info = await get_snapshot_video_presentation(
session, share_token, video_presentation_id
)
if not vp_info:
raise HTTPException(status_code=404, detail="Video presentation not found")
slides = vp_info.get("slides") or []
public_slides = _replace_audio_paths_with_public_urls(
share_token, video_presentation_id, slides
)
return {
"id": vp_info.get("original_id"),
"title": vp_info.get("title"),
"status": "ready",
"slides": public_slides,
"scene_codes": vp_info.get("scene_codes"),
"slide_count": len(slides) if slides else None,
}
@router.get(
"/{share_token}/video-presentations/{video_presentation_id}/slides/{slide_number}/audio"
)
async def stream_public_slide_audio(
share_token: str,
video_presentation_id: int,
slide_number: int,
session: AsyncSession = Depends(get_async_session),
):
"""
Stream a slide's audio from a public chat snapshot.
No authentication required - the share_token provides access.
"""
from pathlib import Path
vp_info = await get_snapshot_video_presentation(
session, share_token, video_presentation_id
)
if not vp_info:
raise HTTPException(status_code=404, detail="Video presentation not found")
slides = vp_info.get("slides") or []
slide_data = None
for s in slides:
if s.get("slide_number") == slide_number:
slide_data = s
break
if not slide_data:
raise HTTPException(status_code=404, detail=f"Slide {slide_number} not found")
file_path = slide_data.get("audio_file")
if not file_path or not os.path.isfile(file_path):
raise HTTPException(status_code=404, detail="Slide audio file not found")
ext = Path(file_path).suffix.lower()
media_type = "audio/wav" if ext == ".wav" else "audio/mpeg"
def iterfile():
with open(file_path, mode="rb") as file_like:
yield from file_like
return StreamingResponse(
iterfile(),
media_type=media_type,
headers={
"Accept-Ranges": "bytes",
"Content-Disposition": f"inline; filename={Path(file_path).name}",
},
)
def _replace_audio_paths_with_public_urls(
share_token: str,
video_presentation_id: int,
slides: list[dict],
) -> list[dict]:
"""Replace server-local audio_file paths with public streaming API URLs."""
result = []
for slide in slides:
slide_copy = dict(slide)
slide_number = slide_copy.get("slide_number")
audio_file = slide_copy.pop("audio_file", None)
if audio_file and slide_number is not None:
slide_copy["audio_url"] = (
f"/api/v1/public/{share_token}"
f"/video-presentations/{video_presentation_id}"
f"/slides/{slide_number}/audio"
)
else:
slide_copy["audio_url"] = None
result.append(slide_copy)
return result
@router.get("/{share_token}/reports/{report_id}/content")
async def get_public_report_content(
share_token: str,

View file

@ -32,6 +32,8 @@ from app.db import (
Report,
SearchSpaceMembership,
User,
VideoPresentation,
VideoPresentationStatus,
)
from app.utils.rbac import check_permission
@ -40,6 +42,7 @@ UI_TOOLS = {
"link_preview",
"generate_podcast",
"generate_report",
"generate_video_presentation",
"scrape_webpage",
"multi_link_preview",
}
@ -199,6 +202,8 @@ async def create_snapshot(
podcast_ids_seen: set[int] = set()
reports_data = []
report_ids_seen: set[int] = set()
video_presentations_data = []
video_presentation_ids_seen: set[int] = set()
for msg in sorted(thread.messages, key=lambda m: m.created_at):
author = await get_author_display(session, msg.author_id, user_cache)
@ -225,6 +230,18 @@ async def create_snapshot(
# Update status to "ready" so frontend renders PodcastPlayer
part["result"] = {**result_data, "status": "ready"}
elif tool_name == "generate_video_presentation":
result_data = part.get("result", {})
vp_id = result_data.get("video_presentation_id")
if vp_id and vp_id not in video_presentation_ids_seen:
vp_info = await _get_video_presentation_for_snapshot(
session, vp_id
)
if vp_info:
video_presentations_data.append(vp_info)
video_presentation_ids_seen.add(vp_id)
part["result"] = {**result_data, "status": "ready"}
elif tool_name == "generate_report":
result_data = part.get("result", {})
report_id = result_data.get("report_id")
@ -283,6 +300,7 @@ async def create_snapshot(
"messages": messages_data,
"podcasts": podcasts_data,
"reports": reports_data,
"video_presentations": video_presentations_data,
}
# Create new snapshot
@ -326,6 +344,27 @@ async def _get_podcast_for_snapshot(
}
async def _get_video_presentation_for_snapshot(
session: AsyncSession,
video_presentation_id: int,
) -> dict | None:
"""Get video presentation info for embedding in snapshot_data."""
result = await session.execute(
select(VideoPresentation).filter(VideoPresentation.id == video_presentation_id)
)
vp = result.scalars().first()
if not vp or vp.status != VideoPresentationStatus.READY:
return None
return {
"original_id": vp.id,
"title": vp.title,
"slides": vp.slides,
"scene_codes": vp.scene_codes,
}
async def _get_report_for_snapshot(
session: AsyncSession,
report_id: int,
@ -769,6 +808,31 @@ async def get_snapshot_podcast(
return None
async def get_snapshot_video_presentation(
session: AsyncSession,
share_token: str,
video_presentation_id: int,
) -> dict | None:
"""
Get video presentation info from a snapshot by original video presentation ID.
Used for rendering video presentation in public view.
Looks up the presentation by its original_id in the snapshot's video_presentations array.
"""
snapshot = await get_snapshot_by_token(session, share_token)
if not snapshot:
return None
video_presentations = snapshot.snapshot_data.get("video_presentations", [])
for vp in video_presentations:
if vp.get("original_id") == video_presentation_id:
return vp
return None
async def get_snapshot_report(
session: AsyncSession,
share_token: str,

View file

@ -10,6 +10,7 @@ import {
Presentation,
X,
} from "lucide-react";
import { useParams, usePathname } from "next/navigation";
import { z } from "zod";
import { Spinner } from "@/components/ui/spinner";
import { baseApiService } from "@/lib/apis/base-api.service";
@ -157,9 +158,11 @@ function CompilationLoadingState({ title }: { title: string }) {
function VideoPresentationPlayer({
presentationId,
title,
shareToken,
}: {
presentationId: number;
title: string;
shareToken?: string | null;
}) {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -181,9 +184,11 @@ function VideoPresentationPlayer({
setIsLoading(true);
setError(null);
try {
const raw = await baseApiService.get<unknown>(
`/api/v1/video-presentations/${presentationId}`,
);
const apiPath = shareToken
? `/api/v1/public/${shareToken}/video-presentations/${presentationId}`
: `/api/v1/video-presentations/${presentationId}`;
const raw = await baseApiService.get<unknown>(apiPath);
const data = parseStatusResponse(raw);
if (!data) throw new Error("Invalid response");
if (data.status !== "ready") throw new Error(`Unexpected status: ${data.status}`);
@ -224,23 +229,30 @@ function VideoPresentationPlayer({
throw new Error("No slides compiled successfully");
}
// Pre-fetch all audio files with auth headers and convert to blob URLs.
// Remotion's <Audio> uses a plain <audio> element which can't send auth
// headers, so we fetch the audio ourselves and hand it a blob: URL.
// Pre-fetch audio and convert to blob URLs.
// For public routes the audio endpoints don't need auth, but we
// still use blob URLs so Remotion's plain <audio> element works.
const withBlobs = await Promise.all(
compiled.map(async (slide) => {
if (!slide.audioUrl) return slide;
try {
const resp = await authenticatedFetch(slide.audioUrl, {
method: "GET",
});
if (!resp.ok) {
console.warn(
`Audio fetch ${resp.status} for slide "${slide.title}"`,
let blob: Blob;
if (shareToken) {
blob = await baseApiService.getBlob(
new URL(slide.audioUrl).pathname,
);
return { ...slide, audioUrl: undefined };
} else {
const resp = await authenticatedFetch(slide.audioUrl, {
method: "GET",
});
if (!resp.ok) {
console.warn(
`Audio fetch ${resp.status} for slide "${slide.title}"`,
);
return { ...slide, audioUrl: undefined };
}
blob = await resp.blob();
}
const blob = await resp.blob();
const blobUrl = URL.createObjectURL(blob);
audioBlobUrlsRef.current.push(blobUrl);
return { ...slide, audioUrl: blobUrl };
@ -258,7 +270,7 @@ function VideoPresentationPlayer({
} finally {
setIsLoading(false);
}
}, [presentationId, backendUrl]);
}, [presentationId, backendUrl, shareToken]);
useEffect(() => {
loadPresentation();
@ -542,9 +554,11 @@ function VideoPresentationPlayer({
function StatusPoller({
presentationId,
title,
shareToken,
}: {
presentationId: number;
title: string;
shareToken?: string | null;
}) {
const [status, setStatus] = useState<VideoPresentationStatusResponse | null>(null);
const pollingRef = useRef<NodeJS.Timeout | null>(null);
@ -552,9 +566,11 @@ function StatusPoller({
useEffect(() => {
const poll = async () => {
try {
const raw = await baseApiService.get<unknown>(
`/api/v1/video-presentations/${presentationId}`,
);
const apiPath = shareToken
? `/api/v1/public/${shareToken}/video-presentations/${presentationId}`
: `/api/v1/video-presentations/${presentationId}`;
const raw = await baseApiService.get<unknown>(apiPath);
const response = parseStatusResponse(raw);
if (response) {
setStatus(response);
@ -578,7 +594,7 @@ function StatusPoller({
clearInterval(pollingRef.current);
}
};
}, [presentationId]);
}, [presentationId, shareToken]);
if (!status || status.status === "pending" || status.status === "generating") {
return <GeneratingState title={title} />;
@ -593,6 +609,7 @@ function StatusPoller({
<VideoPresentationPlayer
presentationId={status.id}
title={status.title || title}
shareToken={shareToken}
/>
);
}
@ -606,6 +623,12 @@ export const GenerateVideoPresentationToolUI = makeAssistantToolUI<
>({
toolName: "generate_video_presentation",
render: function GenerateVideoPresentationUI({ args, result, status }) {
const params = useParams();
const pathname = usePathname();
const isPublicRoute = pathname?.startsWith("/public/");
const shareToken =
isPublicRoute && typeof params?.token === "string" ? params.token : null;
const title = args.video_title || "SurfSense Presentation";
if (status.type === "running" || status.type === "requires-action") {
@ -666,6 +689,7 @@ export const GenerateVideoPresentationToolUI = makeAssistantToolUI<
<StatusPoller
presentationId={result.video_presentation_id}
title={result.title || title}
shareToken={shareToken}
/>
);
}
@ -675,6 +699,7 @@ export const GenerateVideoPresentationToolUI = makeAssistantToolUI<
<VideoPresentationPlayer
presentationId={result.video_presentation_id}
title={result.title || title}
shareToken={shareToken}
/>
);
}