Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 52 additions & 16 deletions app/api/classroom-media/[classroomId]/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { promises as fs, createReadStream } from 'fs';
import { promises as fs, createReadStream, type ReadStream } from 'fs';
import path from 'path';
import { NextRequest, NextResponse } from 'next/server';
import { CLASSROOMS_DIR, isValidClassroomId } from '@/lib/server/classroom-storage';
import { parseRangeHeader } from '@/lib/server/http-range';
import { createLogger } from '@/lib/logger';

const log = createLogger('ClassroomMedia');
Expand All @@ -20,8 +21,24 @@ const MIME_TYPES: Record<string, string> = {
'.aac': 'audio/aac',
};

const CACHE_HEADERS = { 'Cache-Control': 'public, max-age=86400, immutable' } as const;

/** Bridge a fs ReadStream into a web ReadableStream, propagating errors and cancel. */
function toWebStream(stream: ReadStream): ReadableStream {
return new ReadableStream({
start(controller) {
stream.on('data', (chunk: Buffer | string) => controller.enqueue(chunk));
stream.on('end', () => controller.close());
stream.on('error', (err) => controller.error(err));
},
cancel() {
stream.destroy();
},
});
}

export async function GET(
_req: NextRequest,
req: NextRequest,
{ params }: { params: Promise<{ classroomId: string; path: string[] }> },
) {
const { classroomId, path: pathSegments } = await params;
Expand Down Expand Up @@ -61,25 +78,44 @@ export async function GET(
const ext = path.extname(realPath).toLowerCase();
const contentType = MIME_TYPES[ext] || 'application/octet-stream';

// Stream the file to avoid loading large videos into memory
const stream = createReadStream(realPath);
const webStream = new ReadableStream({
start(controller) {
stream.on('data', (chunk: Buffer | string) => controller.enqueue(chunk));
stream.on('end', () => controller.close());
stream.on('error', (err) => controller.error(err));
},
cancel() {
stream.destroy();
},
});
// Range requests enable progressive playback and seeking for hosted media
// (e.g. <video> streams the moov atom first, then fetches on seek).
const range = parseRangeHeader(req.headers.get('range'), stat.size);

if (range.kind === 'unsatisfiable') {
// Never cache a range error: an immutable/public 416 would poison the
// media URL in shared and browser caches, breaking later valid requests.
return new NextResponse(null, {
status: 416,
headers: {
'Cache-Control': 'no-store',
'Content-Range': `bytes */${stat.size}`,
},
});
}

return new NextResponse(webStream, {
if (range.kind === 'range') {
const stream = createReadStream(realPath, { start: range.start, end: range.end });
return new NextResponse(toWebStream(stream), {
status: 206,
headers: {
...CACHE_HEADERS,
'Content-Type': contentType,
'Content-Length': String(range.end - range.start + 1),
'Content-Range': `bytes ${range.start}-${range.end}/${stat.size}`,
'Accept-Ranges': 'bytes',
},
});
}

// Stream the file to avoid loading large videos into memory
return new NextResponse(toWebStream(createReadStream(realPath)), {
status: 200,
headers: {
...CACHE_HEADERS,
'Content-Type': contentType,
'Content-Length': String(stat.size),
'Cache-Control': 'public, max-age=86400, immutable',
'Accept-Ranges': 'bytes',
},
});
} catch (error) {
Expand Down
3 changes: 2 additions & 1 deletion app/classroom/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ export default function ClassroomDetailPage() {
applyStageAndScenes: applyClassroomStageAndScenes,
}),
loadRestoredMediaTasks: defaultClassroomLoadDeps.loadRestoredMediaTasks,
applyRestoredMediaTasks: defaultClassroomLoadDeps.applyRestoredMediaTasks,
applyRestoredMediaTasks: (restored) =>
defaultClassroomLoadDeps.applyRestoredMediaTasks(restored, isCurrent),
discardRestoredMediaTasks: defaultClassroomLoadDeps.discardRestoredMediaTasks,
loadLegacyAgentFallbacks: defaultClassroomLoadDeps.loadLegacyAgentFallbacks,
commitMigratedAgentConfigs: defaultClassroomLoadDeps.commitMigratedAgentConfigs,
Expand Down
3 changes: 2 additions & 1 deletion components/classroom/ClassroomSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,8 @@ export function ClassroomSurface({
applyStageAndScenes: applyClassroomStageAndScenes,
}),
loadRestoredMediaTasks: defaultClassroomLoadDeps.loadRestoredMediaTasks,
applyRestoredMediaTasks: defaultClassroomLoadDeps.applyRestoredMediaTasks,
applyRestoredMediaTasks: (restored) =>
defaultClassroomLoadDeps.applyRestoredMediaTasks(restored, isCurrent),
discardRestoredMediaTasks: defaultClassroomLoadDeps.discardRestoredMediaTasks,
loadLegacyAgentFallbacks: defaultClassroomLoadDeps.loadLegacyAgentFallbacks,
commitMigratedAgentConfigs: defaultClassroomLoadDeps.commitMigratedAgentConfigs,
Expand Down
26 changes: 2 additions & 24 deletions components/edit/SlideNavRail/ThumbItem.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { memo, useCallback, useRef, useState } from 'react';
import { Reorder } from 'motion/react';
import { MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils';
Expand All @@ -13,6 +13,7 @@ import {
import { SceneThumbnailContent } from '@/components/stage/scene-thumbnail-content';
import { SCENE_CREATION_ENABLED } from '@/lib/edit/scene-creation-enabled';
import { sceneHasIssues } from '@/lib/edit/content-validation';
import { useNearViewport } from '@/lib/hooks/use-near-viewport';
import type { Scene } from '@/lib/types/stage';
import { useCanvasStore } from '@/lib/store/canvas';
import { useStageStore } from '@/lib/store/stage';
Expand Down Expand Up @@ -275,26 +276,3 @@ function ThumbItemComponent({
* read-only inside Pro mode (no auto-exit on click).
*/
export const ThumbItem = memo(ThumbItemComponent);

/**
* Cheap "near viewport" IntersectionObserver so off-screen thumbs
* skip the live ThumbnailSlide render (which mounts a downscaled
* slide-renderer scene). Items within 200px of the viewport remain
* eager so scrolling feels instant.
*/
function useNearViewport(ref: React.RefObject<Element | null>) {
const [visible, setVisible] = useState(true);
useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver === 'undefined') return;
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) setVisible(e.isIntersecting);
},
{ root: null, rootMargin: '200px 0px', threshold: 0 },
);
io.observe(el);
return () => io.disconnect();
}, [ref]);
return visible;
}
39 changes: 38 additions & 1 deletion components/stage/scene-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { SlideThumbnail } from '@/components/slide-renderer/SlideThumbnail';
import { ThumbnailInteractive } from '@/components/slide-renderer/components/ThumbnailInteractive';
import { useStageStore, useCanvasStore } from '@/lib/store';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useNearViewport } from '@/lib/hooks/use-near-viewport';
import type { SceneType, SlideContent, InteractiveContent } from '@/lib/types/stage';
import { PENDING_SCENE_ID } from '@/lib/store/stage';

Expand Down Expand Up @@ -202,7 +203,7 @@ export function SceneSidebar({
<div className="relative aspect-video w-full rounded overflow-hidden bg-gray-100 dark:bg-gray-800 ring-1 ring-black/5 dark:ring-white/5">
<div className="absolute inset-0 flex items-center justify-center">
{isSlide && slideContent ? (
<SlideThumbnail
<LazySlideThumbnail
slide={slideContent.canvas}
sceneId={scene.id}
viewportSize={viewportSize}
Expand Down Expand Up @@ -558,3 +559,39 @@ export function SceneSidebar({
</div>
);
}

/**
* Viewport-gated slide thumbnail for the playback sidebar. Scenes far outside
* the viewport render SlideThumbnail's cheap placeholder instead of a full
* SlideCanvas — which also spares every off-screen video element its
* `preload="metadata"` fetch when the classroom opens. The placeholder keeps
* the same box size, so gating never shifts layout.
*/
function LazySlideThumbnail({
slide,
sceneId,
viewportSize,
viewportRatio,
size,
}: {
readonly slide: SlideContent['canvas'];
readonly sceneId: string;
readonly viewportSize: number;
readonly viewportRatio: number;
readonly size: number;
}) {
const ref = useRef<HTMLDivElement>(null);
const visible = useNearViewport(ref);
return (
<div ref={ref} className="flex h-full w-full items-center justify-center">
<SlideThumbnail
slide={slide}
sceneId={sceneId}
viewportSize={viewportSize}
viewportRatio={viewportRatio}
size={size}
visible={visible}
/>
</div>
);
}
Loading
Loading