Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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>
);
}
33 changes: 23 additions & 10 deletions lib/action/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,18 @@ export function resolveActionVideoMedia(
return undefined;
}

/**
* Renderer-aligned playability: a task is playable only once its bytes exist
* (`done` + objectUrl). A deferred restore is `done` without an objectUrl and
* the renderer still treats it as pending (`resolveMediaRef` maps it to
* 'pending'), so starting playback in that state would set
* playingVideoElementId while no <video> exists — and the later hydration
* would not retrigger play, leaving the action stuck until its timeout.
*/
function isPlayableVideoTask(task: MediaTask): boolean {
return task.status === 'done' && !!task.objectUrl;
}

// ==================== ActionEngine ====================

/** Callback for sending messages to widget iframe */
Expand Down Expand Up @@ -351,10 +363,11 @@ export class ActionEngine {
);
const binding = resolveBinding();

if (binding) {
const task = binding.task;
if (task && task.status !== 'done') {
// Wait for media to be ready (or fail)
if (binding?.task) {
// Wait while the task is not yet playable: pending/generating, or a
// deferred restore that is done but still byte-less.
if (!isPlayableVideoTask(binding.task) && binding.task.status !== 'failed') {
// Wait for media to be playable (or fail)
await new Promise<void>((resolve) => {
let unsubscribe = () => {};
const finish = () => {
Expand All @@ -364,24 +377,24 @@ export class ActionEngine {
};
unsubscribe = useMediaGenerationStore.subscribe((state) => {
const t = resolveActionVideoMedia(this.stageStore, state.tasks, action.elementId)?.task;
if (!t || t.status === 'done' || t.status === 'failed') {
if (!t || isPlayableVideoTask(t) || t.status === 'failed') {
finish();
}
});
options.signal?.addEventListener('abort', finish, { once: true });
// Check again in case it resolved between getState and subscribe
const current = resolveBinding()?.task;
if (!current || current.status === 'done' || current.status === 'failed') {
if (!current || isPlayableVideoTask(current) || current.status === 'failed') {
finish();
}
});

if (options.signal?.aborted) return;
}

// If failed, skip playback
if (resolveBinding()?.task?.status === 'failed') {
return;
}
// If failed, skip playback
if (resolveBinding()?.task?.status === 'failed') {
return;
}
}

Expand Down
Loading
Loading