feat: HLS streaming, transcripts, agent course generation & landing-page course previews (#663)

New features:
- HLS video encoding/streaming (hls routes, hls-encoder) and a media
  transcripts pipeline (transcripts route + media-transcript queries)
- AI assistant chat vs. run model: agent-course-generation worker/queue,
  run-monitor pane, chat-changes card, content ask-ai bar
- Per-theme course-page previews across org landing-page themes (bold,
  classic, corporate, editorial, etc.) with a shared course token/component set
- View-as-student mode and login-link / link-invite invite flows

Refactor:
- Extract packages/core consolidating config (env, storage, dashboard-url),
  services and utils previously duplicated under apps/api/src/config
- Remove legacy agent service modules (chat-tools, chat-context,
  context-window, document, fetch-url, lesson-content)
- Relax Cross-Origin-Resource-Policy to allow cross-origin media embedding
- Add format script to tenant-router so turbo run format covers it
This commit is contained in:
Rotimi Best
2026-06-07 18:43:05 +01:00
committed by GitHub
parent a2e70779a0
commit d5b07d356e
496 changed files with 19050 additions and 8312 deletions
@@ -0,0 +1,830 @@
/**
* Browser-side HLS encoder driven by Mediabunny.
*
* Default upload generates a source-capped 360p/720p ABR ladder. A manual
* follow-up flow can add a p1080 rendition later without re-encoding the
* base ladder. All transcoding happens on the user's machine via WebCodecs —
* we never spend a server CPU-second on encoding, and the source MP4 is
* never uploaded. The data path is `File → WebCodecs → R2`.
*/
import {
BlobSource,
BufferTarget,
CanvasSink,
Conversion,
HlsOutputFormat,
Input,
MpegTsOutputFormat,
Mp4OutputFormat,
Output,
PathedTarget,
ALL_FORMATS,
type ConversionVideoOptions,
type InputVideoTrack
} from 'mediabunny';
import { classroomio } from '$lib/utils/services/api';
export type HlsEncodeStage = 'probing' | 'encoding' | 'uploading' | 'thumbnail' | 'finalizing' | 'done' | 'failed';
export interface HlsEncodeProgress {
stage: HlsEncodeStage;
/** 0–100 overall completion estimate. */
percent: number;
/** Total bytes uploaded so far (segments + playlists + audio + thumbs). */
uploadedBytes: number;
/** Current rung label for the encoding stage, e.g. "p720". */
rung?: string;
/** Files uploaded so far / total files uploaded. */
uploadedCount: number;
/** Optional error message when stage === 'failed'. */
error?: string;
}
export type Hls1080Status = 'none' | 'generating' | 'ready' | 'failed';
export interface HlsEncodeResult {
assetId: string;
manifestKey: string;
thumbnailUrl?: string;
sourceWidth: number;
sourceHeight: number;
hlsRenditions: string[];
hls1080Status: Hls1080Status;
}
export interface HlsRungConfig {
name: string;
width: number;
height: number;
videoBitrate: number;
}
export const ALL_HLS_RUNGS: HlsRungConfig[] = [
{ name: 'p360', width: 640, height: 360, videoBitrate: 800_000 },
{ name: 'p720', width: 1280, height: 720, videoBitrate: 2_500_000 },
{ name: 'p1080', width: 1920, height: 1080, videoBitrate: 5_000_000 }
];
export const P1080_RUNG = ALL_HLS_RUNGS.find((rung) => rung.name === 'p1080')!;
const DEFAULT_MAX_RUNG_HEIGHT = 720;
/** Source-capped rungs up to `maxHeight` — never upscales beyond the source. */
export function selectRungsForSource(track: InputVideoTrack, maxHeight = DEFAULT_MAX_RUNG_HEIGHT): HlsRungConfig[] {
const sourceHeight = track.displayHeight ?? 0;
return ALL_HLS_RUNGS.filter((rung) => rung.height <= Math.min(maxHeight, sourceHeight));
}
const AUDIO_BITRATE = 128_000;
const HLS_TARGET_SEGMENT_SECONDS = 4;
const THUMBNAIL_FRACTIONS = [0.3, 0.5, 0.7];
/** Hard ceiling for a generated thumbnail (matches the server job). */
const THUMBNAIL_MAX_BYTES = 500 * 1024;
/** JPEG quality steps tried (best first) until a frame fits under the size cap. */
const THUMBNAIL_QUALITY_LADDER = [0.9, 0.8, 0.7, 0.6, 0.5];
export interface EncodeAndUploadOptions {
file: File;
title?: string;
rungs?: HlsRungConfig[];
onProgress?: (progress: HlsEncodeProgress) => void;
signal?: AbortSignal;
}
export interface EncodeAndUpload1080Options {
file: File;
assetId: string;
onProgress?: (progress: HlsEncodeProgress) => void;
signal?: AbortSignal;
}
/**
* End-to-end: encode the file to HLS, push to R2, finalize the asset.
* Resolves with the new `assetId` and HLS manifest key.
*/
export async function encodeAndUploadHls(opts: EncodeAndUploadOptions): Promise<HlsEncodeResult> {
const progress: HlsEncodeProgress = {
stage: 'probing',
percent: 0,
uploadedBytes: 0,
uploadedCount: 0
};
function emit(patch: Partial<HlsEncodeProgress>) {
Object.assign(progress, patch);
opts.onProgress?.({ ...progress });
}
let reservedAssetId: string | null = null;
try {
if (typeof VideoEncoder === 'undefined') {
throw new HlsEncoderError('webcodecs_unsupported', 'Browser missing WebCodecs encoder support');
}
// --- Probe input ---------------------------------------------------
const input = new Input({ source: new BlobSource(opts.file), formats: ALL_FORMATS });
const duration = await input.computeDuration();
const videoTrack = await input.getPrimaryVideoTrack();
const audioTrack = await input.getPrimaryAudioTrack();
if (!videoTrack) {
throw new HlsEncoderError('encode_failed', 'No video track found in source');
}
const aspectRatio = computeAspectRatioLabel(videoTrack);
const sourceWidth = videoTrack.displayWidth ?? 0;
const sourceHeight = videoTrack.displayHeight ?? 0;
const rungs = opts.rungs ?? selectRungsForSource(videoTrack, DEFAULT_MAX_RUNG_HEIGHT);
if (rungs.length === 0) {
throw new HlsEncoderError('encode_failed', 'Source resolution is too low for HLS encoding');
}
// --- Reserve assetId ----------------------------------------------
const init = await classroomio.organization.assets.hls.init.$post({
json: {
fileName: opts.file.name,
byteSize: opts.file.size,
mimeType: opts.file.type || 'application/octet-stream',
title: opts.title
}
});
const initBody = await init.json();
if (!('success' in initBody) || !initBody.success) {
throw new HlsEncoderError('encode_failed', 'Failed to init HLS asset');
}
const { assetId } = initBody.data;
reservedAssetId = assetId;
// --- Pre-presign every file Mediabunny will write ------------------
// Segments are deterministic from rung name + segment index, so we
// can predict the full path list before encoding starts and ask the
// server for all presigned URLs in one round-trip. The upload step
// then becomes a synchronous map lookup — no more one-POST-per-segment.
emit({ stage: 'encoding', percent: 1 });
const expectedPaths = predictHlsPaths(rungs, duration, Boolean(audioTrack));
const presignedUrls = await batchPresignPaths(assetId, expectedPaths);
const uploader = new HlsUploader({
assetId,
presignedUrls,
onUploaded: ({ totalBytes, count }) => {
emit({ uploadedBytes: totalBytes, uploadedCount: count });
},
signal: opts.signal
});
// Map a video playlist → rung folder name. Mediabunny counts the
// audio playlist as its own entry, so `n` from `HlsOutputPlaylistInfo`
// doesn't line up 1:1 with our rung array. We assign rung names in
// the order video playlists arrive (which mirrors the order we passed
// them into `Conversion.init`'s `video` array). Audio playlists land
// under `audio/`. The Map keys by track identity so a duplicate
// callback invocation for the same playlist resolves to the same path.
const playlistNames = new Map<unknown, string>();
let videoPlaylistsAssigned = 0;
function resolvePlaylistFolder(tracks: { isVideoTrack: () => boolean }[]): string {
const videoTrack = tracks.find((track) => track.isVideoTrack());
if (!videoTrack) return 'audio';
const existing = playlistNames.get(videoTrack);
if (existing) return existing;
const rung = rungs[videoPlaylistsAssigned];
videoPlaylistsAssigned += 1;
const name = rung?.name ?? `v${videoPlaylistsAssigned}`;
playlistNames.set(videoTrack, name);
return name;
}
const hlsOutput = new Output({
format: new HlsOutputFormat({
segmentFormat: new MpegTsOutputFormat(),
targetDuration: HLS_TARGET_SEGMENT_SECONDS,
getPlaylistPath: ({ tracks }) => `${resolvePlaylistFolder(tracks)}/playlist.m3u8`,
// Segment paths are relative to the containing playlist's path.
// Just the file name puts segments alongside their playlist
// (e.g. `p720/seg-00001.ts`) rather than nested under a duplicate
// rung folder.
getSegmentPath: ({ n }) => `seg-${pad(n, 5)}.ts`
}),
target: new PathedTarget('master.m3u8', (request) => uploader.targetFor(request.path, request.mimeType))
});
const videoVariants: ConversionVideoOptions[] = rungs.map((rung) => ({
width: rung.width,
height: rung.height,
fit: 'contain',
bitrate: rung.videoBitrate,
// Keyframe at least every targetDuration so HLS segment boundaries land on key frames.
keyFrameInterval: HLS_TARGET_SEGMENT_SECONDS,
// Prefer the platform's hardware H.264 encoder (VideoToolbox / Media
// Foundation / VAAPI) — typically several times faster than the
// browser's software encoder. Falls back to software automatically
// when no hardware encoder can satisfy the config.
hardwareAcceleration: 'prefer-hardware'
}));
const conversion = await Conversion.init({
input,
output: hlsOutput,
video: videoVariants,
audio: audioTrack ? { codec: 'aac', bitrate: AUDIO_BITRATE, numberOfChannels: 2 } : { discard: true },
showWarnings: false
});
conversion.onProgress = (fraction) => {
// Reserve 70% of the bar for the encode itself; uploads finish behind it.
emit({ percent: Math.min(72, 2 + Math.round(fraction * 70)) });
};
if (!conversion.isValid) {
throw new HlsEncoderError(
'encode_failed',
`Conversion invalid (${conversion.discardedTracks.length} discarded tracks)`
);
}
await conversion.execute();
await uploader.flush();
// --- Audio-only output for transcription --------------------------
let audioKey: string | undefined;
if (audioTrack) {
const audioOutput = new Output({
format: new Mp4OutputFormat(),
target: new BufferTarget({
onFinalize: async (buffer) => {
audioKey = await uploader.uploadSingle('audio.m4a', 'audio/mp4', buffer);
}
})
});
const audioConversion = await Conversion.init({
input,
output: audioOutput,
video: { discard: true },
audio: { codec: 'aac', bitrate: AUDIO_BITRATE, numberOfChannels: 1, sampleRate: 16_000 },
showWarnings: false
});
if (audioConversion.isValid) {
await audioConversion.execute();
}
}
// --- Thumbnails ----------------------------------------------------
emit({ stage: 'thumbnail', percent: 86 });
const thumbnailUrls = await extractAndUploadThumbnails({
videoTrack,
duration,
file: opts.file
});
// --- Finalize -----------------------------------------------------
emit({ stage: 'finalizing', percent: 96 });
const finalize = await classroomio.organization.assets[':assetId'].hls.finalize.$post({
param: { assetId },
json: {
manifestKey: `${assetId}/master.m3u8`,
audioKey,
durationSeconds: duration,
aspectRatio,
byteSize: progress.uploadedBytes,
videoCodec: 'avc',
audioCodec: audioTrack ? 'aac' : undefined,
sourceWidth,
sourceHeight,
hlsRenditions: rungs.map((rung) => rung.name),
hls1080Status: 'none',
thumbnailUrl: thumbnailUrls[0],
thumbnailCandidateUrls: thumbnailUrls,
title: opts.title
}
});
const finalizeBody = await finalize.json();
if (!('success' in finalizeBody) || !finalizeBody.success) {
throw new HlsEncoderError('finalize_failed', 'Server rejected finalize payload');
}
emit({ stage: 'done', percent: 100 });
reservedAssetId = null;
return {
assetId,
manifestKey: `${assetId}/master.m3u8`,
thumbnailUrl: thumbnailUrls[0],
sourceWidth,
sourceHeight,
hlsRenditions: rungs.map((rung) => rung.name),
hls1080Status: 'none'
};
} catch (error) {
const code = error instanceof HlsEncoderError ? error.code : 'encode_failed';
const message = error instanceof Error ? error.message : 'Unknown encoder error';
emit({ stage: 'failed', error: `${code}: ${message}` });
// Best-effort cleanup: tell the server to drop the reserved row + any
// partial R2 objects it managed to write. Swallow errors — the user
// already sees the original failure, and a stuck `processing` row is
// recoverable via an admin sweep anyway.
if (reservedAssetId) {
try {
await classroomio.organization.assets[':assetId'].hls.$delete({ param: { assetId: reservedAssetId } });
} catch (cleanupError) {
console.warn('Failed to abort HLS asset after encode error', cleanupError);
}
}
throw error;
}
}
/**
* Generate and upload only the p1080 rendition for an existing HLS asset,
* patch the master manifest, and finalize rendition metadata server-side.
*/
export async function encodeAndUploadHls1080(opts: EncodeAndUpload1080Options): Promise<{
hlsRenditions: string[];
hls1080Status: Hls1080Status;
}> {
const progress: HlsEncodeProgress = {
stage: 'probing',
percent: 0,
uploadedBytes: 0,
uploadedCount: 0
};
function emit(patch: Partial<HlsEncodeProgress>) {
Object.assign(progress, patch);
opts.onProgress?.({ ...progress });
}
try {
if (typeof VideoEncoder === 'undefined') {
throw new HlsEncoderError('webcodecs_unsupported', 'Browser missing WebCodecs encoder support');
}
const input = new Input({ source: new BlobSource(opts.file), formats: ALL_FORMATS });
const duration = await input.computeDuration();
const videoTrack = await input.getPrimaryVideoTrack();
if (!videoTrack) {
throw new HlsEncoderError('encode_failed', 'No video track found in source');
}
const sourceHeight = videoTrack.displayHeight ?? 0;
if (sourceHeight < 1080) {
throw new HlsEncoderError('encode_failed', 'Source video does not support 1080p');
}
const rungs = [P1080_RUNG];
emit({ stage: 'encoding', percent: 1, rung: 'p1080' });
const expectedPaths = predictHlsPaths(rungs, duration, false);
const presignedUrls = await batchPresign1080Paths(opts.assetId, expectedPaths);
const capturedFiles = new Map<string, Uint8Array>();
const uploader = new HlsUploader({
assetId: opts.assetId,
presignedUrls,
presignEndpoint: '1080',
skipPaths: new Set(['master.m3u8']),
capturedFiles,
onUploaded: ({ totalBytes, count }) => {
emit({ uploadedBytes: totalBytes, uploadedCount: count });
},
signal: opts.signal
});
const hlsOutput = new Output({
format: new HlsOutputFormat({
segmentFormat: new MpegTsOutputFormat(),
targetDuration: HLS_TARGET_SEGMENT_SECONDS,
getPlaylistPath: () => 'p1080/playlist.m3u8',
getSegmentPath: ({ n }) => `seg-${pad(n, 5)}.ts`
}),
target: new PathedTarget('master.m3u8', (request) => uploader.targetFor(request.path, request.mimeType))
});
const conversion = await Conversion.init({
input,
output: hlsOutput,
video: [
{
width: P1080_RUNG.width,
height: P1080_RUNG.height,
fit: 'contain',
bitrate: P1080_RUNG.videoBitrate,
keyFrameInterval: HLS_TARGET_SEGMENT_SECONDS,
hardwareAcceleration: 'prefer-hardware'
}
],
audio: { discard: true },
showWarnings: false
});
conversion.onProgress = (fraction) => {
emit({ percent: Math.min(72, 2 + Math.round(fraction * 70)), rung: 'p1080' });
};
if (!conversion.isValid) {
throw new HlsEncoderError(
'encode_failed',
`Conversion invalid (${conversion.discardedTracks.length} discarded tracks)`
);
}
await conversion.execute();
await uploader.flush();
emit({ stage: 'uploading', percent: 78 });
const generatedMaster = capturedFiles.get('master.m3u8');
if (!generatedMaster) {
throw new HlsEncoderError('encode_failed', 'Encoder did not produce a master manifest');
}
const existingMaster = await fetchExistingMasterManifest(opts.assetId, opts.signal);
const mergedMaster = mergeP1080IntoMaster(existingMaster, new TextDecoder().decode(generatedMaster));
const mergedBytes = new TextEncoder().encode(mergedMaster);
const uploadedBytesBeforeMaster = progress.uploadedBytes;
await uploader.uploadSingle('master.m3u8', 'application/vnd.apple.mpegurl', mergedBytes, { force: true });
emit({ stage: 'finalizing', percent: 96 });
const finalize = await classroomio.organization.assets[':assetId'].hls['1080'].finalize.$post({
param: { assetId: opts.assetId },
json: {
additionalByteSize: uploadedBytesBeforeMaster,
sourceWidth: videoTrack.displayWidth ?? undefined,
sourceHeight: videoTrack.displayHeight ?? undefined
}
});
const finalizeBody = await finalize.json();
if (!('success' in finalizeBody) || !finalizeBody.success) {
throw new HlsEncoderError('finalize_failed', 'Server rejected 1080p finalize payload');
}
const metadata = finalizeBody.data.metadata as { hlsRenditions?: string[]; hls1080Status?: Hls1080Status } | null;
emit({ stage: 'done', percent: 100 });
return {
hlsRenditions: metadata?.hlsRenditions ?? ['p360', 'p720', 'p1080'],
hls1080Status: metadata?.hls1080Status ?? 'ready'
};
} catch (error) {
const code = error instanceof HlsEncoderError ? error.code : 'encode_failed';
const message = error instanceof Error ? error.message : 'Unknown encoder error';
emit({ stage: 'failed', error: `${code}: ${message}` });
throw error;
}
}
class HlsEncoderError extends Error {
constructor(
public code: 'webcodecs_unsupported' | 'encode_failed' | 'upload_failed' | 'finalize_failed',
message: string
) {
super(message);
this.name = 'HlsEncoderError';
}
}
interface PresignedUrl {
url: string;
key: string;
}
/**
* Predict every path Mediabunny will write so we can presign them all in
* a single request before encoding starts. Segment counts come from
* duration / target-duration with a small safety margin — over-presigning
* is free (unused presigned URLs simply expire) while under-presigning
* forces a fallback per-file presign at upload time.
*
* When the source has audio, Mediabunny's HLS output emits a separate
* `audio/` playlist + its own segments alongside the video rungs (the
* master playlist groups them via `#EXT-X-MEDIA`). We presign those too.
* The trailing `audio.m4a` is the separate transcription-only output we
* run after the HLS conversion.
*/
function predictHlsPaths(rungs: HlsRungConfig[], duration: number, hasAudio: boolean): string[] {
const paths: string[] = ['master.m3u8'];
const segmentCount = Math.max(1, Math.ceil(duration / HLS_TARGET_SEGMENT_SECONDS)) + 5;
for (const rung of rungs) {
paths.push(`${rung.name}/playlist.m3u8`);
for (let i = 1; i <= segmentCount; i++) {
paths.push(`${rung.name}/seg-${pad(i, 5)}.ts`);
}
}
if (hasAudio) {
paths.push('audio/playlist.m3u8');
for (let i = 1; i <= segmentCount; i++) {
paths.push(`audio/seg-${pad(i, 5)}.ts`);
}
paths.push('audio.m4a');
}
return paths;
}
async function batchPresignPaths(assetId: string, paths: string[]): Promise<Map<string, PresignedUrl>> {
const response = await classroomio.organization.assets[':assetId'].hls.presign.$post({
param: { assetId },
json: { paths }
});
return parsePresignResponse(paths, response);
}
async function batchPresign1080Paths(assetId: string, paths: string[]): Promise<Map<string, PresignedUrl>> {
const response = await classroomio.organization.assets[':assetId'].hls['1080'].presign.$post({
param: { assetId },
json: { paths }
});
return parsePresignResponse(paths, response);
}
async function parsePresignResponse(paths: string[], response: Response): Promise<Map<string, PresignedUrl>> {
const map = new Map<string, PresignedUrl>();
const body = await response.json();
if (!('success' in body) || !body.success) {
throw new HlsEncoderError('upload_failed', 'Failed to presign HLS uploads');
}
for (const path of paths) {
const url = body.data.urls[path];
const key = body.data.keys[path];
if (url && key) map.set(path, { url, key });
}
return map;
}
/**
* Uploads files Mediabunny emits while encoding. All expected paths are
* pre-presigned at start; PathedTarget.onFinalize is a synchronous map
* lookup. The rare path that wasn't predicted (e.g. encoder produced one
* extra segment) falls back to a single per-file presign.
*/
class HlsUploader {
private totalBytes = 0;
private uploadedCount = 0;
private inflight: Promise<void>[] = [];
private uploadFailures: unknown[] = [];
constructor(
private readonly options: {
assetId: string;
presignedUrls: Map<string, PresignedUrl>;
onUploaded: (state: { totalBytes: number; count: number }) => void;
signal?: AbortSignal;
presignEndpoint?: 'default' | '1080';
skipPaths?: Set<string>;
capturedFiles?: Map<string, Uint8Array>;
}
) {}
targetFor(path: string, mimeType: string): BufferTarget {
return new BufferTarget({
onFinalize: async (buffer) => {
await this.uploadSingle(path, mimeType, buffer);
}
});
}
async uploadSingle(
path: string,
mimeType: string,
buffer: ArrayBuffer | Uint8Array,
options?: { force?: boolean }
): Promise<string> {
const upload = (async () => {
const u8 = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
if (!options?.force && this.options.skipPaths?.has(path)) {
this.options.capturedFiles?.set(path, u8);
return `${this.options.assetId}/${path}`;
}
let presigned = this.options.presignedUrls.get(path);
if (!presigned) {
presigned = await this.fallbackPresign(path);
this.options.presignedUrls.set(path, presigned);
}
const owned = new Uint8Array(u8.byteLength);
owned.set(u8);
const body = new Blob([owned], { type: mimeType });
const res = await fetch(presigned.url, {
method: 'PUT',
headers: { 'Content-Type': mimeType },
body,
signal: this.options.signal
});
if (!res.ok) {
throw new HlsEncoderError('upload_failed', `PUT ${path} → ${res.status}`);
}
this.totalBytes += owned.byteLength;
this.uploadedCount += 1;
this.options.onUploaded({ totalBytes: this.totalBytes, count: this.uploadedCount });
return presigned.key;
})();
const tracked = upload.then(
() => undefined,
(error) => {
this.uploadFailures.push(error);
throw error;
}
);
this.inflight.push(tracked);
tracked.finally(() => {
const index = this.inflight.indexOf(tracked);
if (index >= 0) this.inflight.splice(index, 1);
});
return upload;
}
private async fallbackPresign(path: string): Promise<PresignedUrl> {
const response =
this.options.presignEndpoint === '1080'
? await classroomio.organization.assets[':assetId'].hls['1080'].presign.$post({
param: { assetId: this.options.assetId },
json: { paths: [path] }
})
: await classroomio.organization.assets[':assetId'].hls.presign.$post({
param: { assetId: this.options.assetId },
json: { paths: [path] }
});
const body = await response.json();
if (!('success' in body) || !body.success) {
throw new HlsEncoderError('upload_failed', `Failed to presign ${path}`);
}
const url = body.data.urls[path];
const key = body.data.keys[path];
if (!url || !key) {
throw new HlsEncoderError('upload_failed', `Presign returned no url for ${path}`);
}
return { url, key };
}
async flush(): Promise<void> {
while (this.inflight.length) {
const pending = this.inflight.slice();
const results = await Promise.allSettled(pending);
const failure = results.find((result) => result.status === 'rejected');
if (failure?.status === 'rejected') {
throw failure.reason;
}
}
if (this.uploadFailures.length > 0) {
throw this.uploadFailures[0];
}
}
}
async function extractAndUploadThumbnails(input: {
videoTrack: InputVideoTrack;
duration: number;
file: File;
}): Promise<string[]> {
if (!input.duration || input.duration <= 0) return [];
const sink = new CanvasSink(input.videoTrack, { width: 1280, fit: 'contain', poolSize: 1 });
const timestamps = THUMBNAIL_FRACTIONS.map((f) => input.duration * f);
const candidates: { url: string; score: number }[] = [];
let index = 0;
for await (const wrapped of sink.canvasesAtTimestamps(timestamps)) {
if (!wrapped) {
index += 1;
continue;
}
const canvas = wrapped.canvas as HTMLCanvasElement | OffscreenCanvas;
const blob = await canvasToJpegBlobUnderCap(canvas, THUMBNAIL_MAX_BYTES);
if (!blob) {
index += 1;
continue;
}
const score = await averageLuma(canvas);
const fileName = input.file.name.replace(/\.[^.]+$/, '') + `-thumb-${index}.jpg`;
const file = new File([blob], fileName, { type: 'image/jpeg' });
try {
const response = await classroomio.media.image.$post({ form: { file } });
const body = await response.json();
if ('success' in body && body.success && body.url) {
candidates.push({ url: body.url, score });
}
} catch (error) {
console.warn('Thumbnail upload failed', error);
}
index += 1;
}
candidates.sort((a, b) => b.score - a.score);
return candidates.map((c) => c.url);
}
async function canvasToJpegBlob(canvas: HTMLCanvasElement | OffscreenCanvas, quality: number): Promise<Blob | null> {
if (canvas instanceof OffscreenCanvas) {
return canvas.convertToBlob({ type: 'image/jpeg', quality });
}
return new Promise((resolve) => canvas.toBlob((blob) => resolve(blob), 'image/jpeg', quality));
}
/**
* Encode the canvas as JPEG, stepping quality down until the blob fits under
* `maxBytes`. Returns the first blob within the cap, or the smallest produced
* if even the lowest quality overshoots.
*/
async function canvasToJpegBlobUnderCap(
canvas: HTMLCanvasElement | OffscreenCanvas,
maxBytes: number
): Promise<Blob | null> {
let smallest: Blob | null = null;
for (const quality of THUMBNAIL_QUALITY_LADDER) {
const blob = await canvasToJpegBlob(canvas, quality);
if (!blob) continue;
if (!smallest || blob.size < smallest.size) smallest = blob;
if (blob.size <= maxBytes) return blob;
}
return smallest;
}
async function averageLuma(canvas: HTMLCanvasElement | OffscreenCanvas): Promise<number> {
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null;
if (!ctx) return 0;
const { width, height } = canvas;
const sampleSize = Math.min(64, width, height);
const data = ctx.getImageData(0, 0, sampleSize, sampleSize).data;
let sum = 0;
for (let i = 0; i < data.length; i += 4) {
sum += 0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2];
}
return sum / (data.length / 4);
}
function computeAspectRatioLabel(track: InputVideoTrack): string {
const w = track.displayWidth;
const h = track.displayHeight;
if (!w || !h) return '16:9';
const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b));
const g = gcd(w, h);
return `${w / g}:${h / g}`;
}
function pad(n: number, width: number): string {
return n.toString().padStart(width, '0');
}
async function fetchExistingMasterManifest(assetId: string, signal?: AbortSignal): Promise<string> {
await classroomio.organization.assets[':assetId'].hls.cookie.$post({ param: { assetId } });
const response = await fetch(`/hls/${assetId}/master.m3u8`, { credentials: 'include', signal });
if (!response.ok) {
throw new HlsEncoderError('upload_failed', `Failed to fetch master manifest (${response.status})`);
}
return response.text();
}
function extractAudioGroupId(manifest: string): string | null {
const match = manifest.match(/#EXT-X-MEDIA:TYPE=AUDIO[^\n]*GROUP-ID="([^"]+)"/);
return match?.[1] ?? null;
}
function mergeP1080IntoMaster(existingManifest: string, generatedManifest: string): string {
if (existingManifest.includes('p1080/playlist.m3u8')) {
return existingManifest;
}
const generatedLines = generatedManifest.split('\n');
let streamInf: string | null = null;
for (let index = 0; index < generatedLines.length; index += 1) {
const line = generatedLines[index]?.trim() ?? '';
if (!line.startsWith('#EXT-X-STREAM-INF')) continue;
const playlistLine = generatedLines[index + 1]?.trim() ?? '';
if (playlistLine === 'p1080/playlist.m3u8') {
streamInf = line;
break;
}
}
const audioGroupId = extractAudioGroupId(existingManifest);
if (!streamInf) {
streamInf = `#EXT-X-STREAM-INF:BANDWIDTH=${P1080_RUNG.videoBitrate},RESOLUTION=${P1080_RUNG.width}x${P1080_RUNG.height},CODECS="avc1.640028,mp4a.40.2"`;
}
if (audioGroupId && !streamInf.includes('AUDIO=')) {
streamInf = `${streamInf},AUDIO="${audioGroupId}"`;
}
const lines = existingManifest.trimEnd().split('\n');
lines.push(streamInf);
lines.push('p1080/playlist.m3u8');
return `${lines.join('\n')}\n`;
}
@@ -24,6 +24,11 @@ class LessonVideoBus {
this.seekFn(seconds);
}
/** Replace the active transcript (e.g. after an edit is saved). */
setTranscript(transcript: AssetTranscriptPayload | null) {
this.transcript = transcript;
}
/** Reset all per-lesson state (called when the upload asset changes or the player unmounts). */
reset(opts?: { keepAssetId?: boolean }) {
if (!opts?.keepAssetId) {
@@ -1,29 +1,206 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { MediaPlayer } from '@cio/ui/custom/media-player';
import { presignApi } from '$features/course/api/presign.svelte';
import { mediaApi } from '$features/media/api';
import { jobsApi, JobPoller, type MediaJobEnvelope } from '$features/jobs';
import { t } from '$lib/utils/functions/translations';
import { sidePanel } from '$features/side-panel';
import { classroomio } from '$lib/utils/services/api';
import type { AssetTranscriptPayload } from '$features/media/utils/types';
import type { LessonVideo } from './video-card-utils';
import { lessonVideoBus } from './lesson-video-bus.svelte';
import { TRANSCRIPT_PANEL_ID } from './transcript-panel-definition';
/**
* HLS playback flag — set by the upload flow when the asset was encoded
* via Mediabunny and stored as a manifest. `video.metadata` is untyped
* `Record<string, unknown>` at the call site, so the cast keeps the
* boolean lookup honest.
*/
function isHlsVideo(v: LessonVideo): boolean {
return Boolean((v.metadata as { hls?: boolean } | undefined)?.hls);
}
/**
* Build a fully-qualified URL for an HLS manifest or segment from the
* relative `/hls/{assetId}/{rest}` form stored in `lesson.videos[].link`.
* Uses the typed Hono client so the URL goes through the same base every
* other API call uses (PUBLIC_SERVER_URL locally, `${origin}/proxy/...`
* in Cloud production — the tenant-router Worker intercepts the latter
* and streams directly from R2).
*/
function resolveHlsUrl(rawUrl: string): string {
if (/^https?:\/\//i.test(rawUrl)) return rawUrl;
const match = rawUrl.match(/^\/?hls\/([^/]+)\/(.+)$/);
if (!match) return rawUrl;
const [, assetId, rest] = match;
const built = classroomio.hls[':assetId']['*'].$url({ param: { assetId } });
return built.toString().replace(/\/\*$/, '') + '/' + rest;
}
async function mintHlsCookie(assetId: string): Promise<void> {
await classroomio.organization.assets[':assetId'].hls.cookie.$post({
param: { assetId }
});
}
/**
* Fetch the transcript VTT through the typed RPC client and turn it
* into a `blob:` URL. We can't point the `<track>` element directly at
* the api URL: `<video crossorigin>` would force CORS on every video
* subresource (poster on r2.dev, Plyr's `blank.mp4`, …) and break
* those. Without `crossorigin` the browser refuses cross-origin
* tracks. `blob:` URLs side-step both: same-origin from the browser's
* POV, and the fetch itself runs through the same auth'd path as
* every other API call.
*/
let blobTrackUrl = $state<string | null>(null);
let revokeBlobTrackUrl: (() => void) | null = null;
async function loadTranscriptTrack(rawUrl: string): Promise<void> {
const match = rawUrl.match(/^\/?transcripts\/([^/]+)\/track\.vtt$/);
if (!match) {
blobTrackUrl = /^https?:\/\//i.test(rawUrl) ? rawUrl : null;
return;
}
const [, assetId] = match;
try {
const response = await classroomio.transcripts[':assetId']['track.vtt'].$get({ param: { assetId } });
if (!response.ok) return;
const blob = await response.blob();
const url = URL.createObjectURL(blob);
revokeBlobTrackUrl?.();
revokeBlobTrackUrl = () => URL.revokeObjectURL(url);
blobTrackUrl = url;
} catch (error) {
console.warn('Failed to load transcript track', error);
}
}
interface Props {
video: LessonVideo;
}
let { video }: Props = $props();
let localTranscript = $state<AssetTranscriptPayload | null>(null);
let localTranscriptLoading = $state(false);
// The parent keys this component on the upload assetId / link, so each
// mount maps to a single asset; lifecycle is plain onMount/onDestroy.
const uploadAssetId =
video.type === 'upload' ? ((video as LessonVideo & { assetId?: string }).assetId ?? null) : null;
const uploadStorageKey =
video.type === 'upload' && typeof video.key === 'string' && video.key.length > 0 ? video.key : null;
/** Refresh ~10 minutes before server presign expiry (1 hour). */
const PLAYBACK_URL_REFRESH_MS = 50 * 60 * 1000;
let isMounted = true;
const isHls = $derived(isHlsVideo(video));
let playbackUrl = $state(isHlsVideo(video) ? resolveHlsUrl(video.link) : video.link);
let playbackUrlIssuedAt = $state(Date.now());
let playbackRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let vttRefetchTimer: ReturnType<typeof setTimeout> | null = null;
let jobPoller: JobPoller<MediaJobEnvelope> | null = null;
let hasAutoOpenedTranscript = false;
let localSeekFn: (seconds: number) => void = () => {};
function ownsVideoBus(): boolean {
return uploadAssetId !== null && lessonVideoBus.assetId === uploadAssetId;
}
function syncVideoBus(): void {
if (!uploadAssetId) return;
lessonVideoBus.assetId = uploadAssetId;
lessonVideoBus.transcript = localTranscript;
lessonVideoBus.transcriptLoading = localTranscriptLoading;
lessonVideoBus.setSeekFn(localSeekFn);
}
$effect(() => {
const rawUrl = localTranscript?.vttUrl;
if (!rawUrl) {
revokeBlobTrackUrl?.();
revokeBlobTrackUrl = null;
blobTrackUrl = null;
return;
}
void loadTranscriptTrack(rawUrl);
});
$effect(() => {
playbackUrl = isHlsVideo(video) ? resolveHlsUrl(video.link) : video.link;
playbackUrlIssuedAt = Date.now();
schedulePlaybackUrlRefresh();
});
function clearPlaybackRefreshTimer() {
if (playbackRefreshTimer) {
clearTimeout(playbackRefreshTimer);
playbackRefreshTimer = null;
}
}
function schedulePlaybackUrlRefresh() {
clearPlaybackRefreshTimer();
if (!uploadStorageKey || isHls) return;
const delayMs = PLAYBACK_URL_REFRESH_MS - (Date.now() - playbackUrlIssuedAt);
if (delayMs <= 0) {
void refreshPlaybackUrlIfIdle();
return;
}
playbackRefreshTimer = setTimeout(() => {
void refreshPlaybackUrlIfIdle();
}, delayMs);
}
async function refreshPlaybackUrl(): Promise<string | null> {
if (!uploadStorageKey || !isMounted) return null;
const urls = await presignApi.getVideoDownloadUrls([uploadStorageKey]);
const freshUrl = urls[uploadStorageKey];
if (!freshUrl || !isMounted) return null;
playbackUrl = freshUrl;
playbackUrlIssuedAt = Date.now();
schedulePlaybackUrlRefresh();
return freshUrl;
}
async function refreshPlaybackUrlIfIdle(): Promise<string | null> {
if (lessonVideoBus.hasPlayed) return null;
return refreshPlaybackUrl();
}
async function handlePlaybackReload(): Promise<boolean> {
pendingResumeSeconds = lessonVideoBus.currentTimeSeconds;
if (uploadStorageKey) {
return Boolean(await refreshPlaybackUrl());
}
return true;
}
let pendingResumeSeconds: number | null = null;
function handleSourceLoaded(element: HTMLVideoElement) {
if (pendingResumeSeconds == null || pendingResumeSeconds <= 0) return;
element.currentTime = pendingResumeSeconds;
pendingResumeSeconds = null;
}
function clearVttRefetchTimer() {
if (vttRefetchTimer) {
@@ -58,7 +235,10 @@
async function loadTranscript(): Promise<void> {
if (!uploadAssetId) {
if (isMounted) {
lessonVideoBus.transcript = null;
localTranscript = null;
if (ownsVideoBus()) {
lessonVideoBus.transcript = null;
}
}
return;
@@ -66,24 +246,33 @@
if (!isMounted) return;
lessonVideoBus.transcriptLoading = true;
localTranscriptLoading = true;
if (ownsVideoBus()) {
lessonVideoBus.transcriptLoading = true;
}
try {
const data = await mediaApi.getAssetTranscript(uploadAssetId);
if (!isMounted) return;
lessonVideoBus.transcript = data;
localTranscript = data;
scheduleVttRefetch(data?.vttUrlExpiresAt);
if (ownsVideoBus()) {
lessonVideoBus.transcript = data;
}
} finally {
if (isMounted) {
lessonVideoBus.transcriptLoading = false;
localTranscriptLoading = false;
if (ownsVideoBus()) {
lessonVideoBus.transcriptLoading = false;
}
}
}
}
async function watchJobsUntilTerminalThenReload(): Promise<void> {
if (!uploadAssetId || lessonVideoBus.transcript || !isMounted) return;
if (!uploadAssetId || localTranscript || !isMounted) return;
stopJobPoller();
@@ -122,8 +311,9 @@
}
onMount(() => {
lessonVideoBus.reset();
lessonVideoBus.assetId = uploadAssetId;
if (lessonVideoBus.assetId === null || lessonVideoBus.assetId === uploadAssetId) {
syncVideoBus();
}
void (async () => {
await loadTranscript();
@@ -135,19 +325,24 @@
onDestroy(() => {
isMounted = false;
clearPlaybackRefreshTimer();
clearVttRefetchTimer();
stopJobPoller();
lessonVideoBus.reset();
sidePanel.closeIfScope('lesson');
revokeBlobTrackUrl?.();
revokeBlobTrackUrl = null;
if (ownsVideoBus()) {
lessonVideoBus.reset();
sidePanel.closeIfScope('lesson');
}
});
const tracks = $derived(
lessonVideoBus.transcript?.vttUrl
blobTrackUrl && localTranscript
? [
{
kind: 'captions' as const,
src: lessonVideoBus.transcript.vttUrl,
srclang: lessonVideoBus.transcript.language,
src: blobTrackUrl,
srclang: localTranscript.language,
label: t.get('course.navItem.lessons.materials.tabs.video.transcript.captions_label'),
default: false
}
@@ -155,30 +350,17 @@
: []
);
// Plyr can't add a <track> after construction, so when the captions URL
// appears asynchronously we remount the player once. Include a stable key when
// a transcript exists but VTT is not ready yet so optional controls (e.g. transcript panel) attach.
const playerRemountKey = $derived.by(() => {
if (!lessonVideoBus.transcript) return 'no-transcript';
return lessonVideoBus.transcript.vttUrl ?? 'transcript-awaiting-vtt';
});
function handleFirstPlay() {
syncVideoBus();
lessonVideoBus.hasPlayed = true;
if (hasAutoOpenedTranscript) return;
if (!lessonVideoBus.transcript?.segments?.length) return;
hasAutoOpenedTranscript = true;
sidePanel.open(TRANSCRIPT_PANEL_ID);
}
const transcriptPanelControl = $derived(
lessonVideoBus.transcript
localTranscript
? {
label: t.get('course.navItem.lessons.materials.tabs.video.transcript.open_side_panel'),
onClick: () => {
syncVideoBus();
sidePanel.open(TRANSCRIPT_PANEL_ID);
}
}
@@ -187,30 +369,39 @@
</script>
<div class="w-full">
{#key playerRemountKey}
<MediaPlayer
source={{
type: video.type,
url: video.link,
metadata: video.metadata as { thumbnailUrl?: string; title?: string } | undefined,
tracks
}}
options={{
maxHeight: '569px',
width: '100%',
controls: true,
playsinline: true,
onTimeUpdate: (seconds) => {
<MediaPlayer
source={{
type: video.type,
url: playbackUrl,
hls: isHls,
metadata: video.metadata as { thumbnailUrl?: string; title?: string } | undefined,
tracks
}}
options={{
maxHeight: '569px',
width: '100%',
controls: true,
playsinline: true,
onTimeUpdate: (seconds) => {
if (ownsVideoBus()) {
lessonVideoBus.currentTimeSeconds = seconds;
},
onPlayerReady: (player) => {
lessonVideoBus.setSeekFn((seconds) => {
player.currentTime = seconds;
});
},
onFirstPlay: handleFirstPlay,
transcriptPanelControl
}}
/>
{/key}
}
},
onPlayerReady: (player) => {
localSeekFn = (seconds) => {
player.currentTime = seconds;
};
if (ownsVideoBus()) {
lessonVideoBus.setSeekFn(localSeekFn);
}
},
onFirstPlay: handleFirstPlay,
onSourceLoaded: handleSourceLoaded,
onBeforeHlsLoad: isHls && uploadAssetId ? () => mintHlsCookie(uploadAssetId) : undefined,
transcriptPanelControl,
playbackErrorLabel: $t('course.navItem.lessons.materials.tabs.video.playback_error'),
playbackReloadLabel: $t('course.navItem.lessons.materials.tabs.video.playback_reload'),
onPlaybackReload: handlePlaybackReload
}}
/>
</div>
@@ -78,7 +78,7 @@
<!-- YouTube-like: thumbnail on top; avatar + stacked text + overflow menu -->
<div class="group w-full max-w-full min-w-0 {isEditMode ? 'rounded-lg border' : ''}">
<div class="ui:bg-muted relative aspect-video w-full min-w-0 overflow-hidden rounded-xl">
<div class="ui:bg-muted ui:rounded-md relative aspect-video w-full min-w-0 overflow-hidden">
{#if thumbnailUrl}
<Image src={thumbnailUrl} alt={title} className="absolute inset-0 block h-full w-full object-cover" />
{:else}
@@ -142,6 +142,7 @@
{video}
{onRemove}
onThumbnailSaved={(url) => lessonApi.updateLessonVideoThumbnail(index, url)}
onHlsMetadataUpdated={(metadata) => lessonApi.updateLessonVideoMetadata(index, metadata)}
menuPlacement="inline"
/>
{/if}
@@ -1,21 +1,31 @@
<script lang="ts">
import XIcon from '@lucide/svelte/icons/x';
import PencilIcon from '@lucide/svelte/icons/pencil';
import CaptionsIcon from '@lucide/svelte/icons/captions';
import { IconButton } from '@cio/ui/custom/icon-button';
import { Button } from '@cio/ui/base/button';
import { Textarea } from '@cio/ui/base/textarea';
import { t } from '$lib/utils/functions/translations';
import { isOrgAdmin } from '$lib/utils/store/org';
import { sidePanel } from '$features/side-panel';
import { mediaApi } from '$features/media/api';
import { lessonVideoBus } from './lesson-video-bus.svelte';
import type { AssetTranscriptPayload } from '$features/media/utils/types';
type Segment = AssetTranscriptPayload['segments'][number];
import type { TranscriptSegment } from '$features/media/utils/types';
const transcript = $derived(lessonVideoBus.transcript);
const transcriptLoading = $derived(lessonVideoBus.transcriptLoading);
const segments = $derived<Segment[]>(transcript?.segments ?? []);
const segments = $derived<TranscriptSegment[]>(transcript?.segments ?? []);
const language = $derived(transcript?.language ?? '');
const assetId = $derived(lessonVideoBus.assetId);
const currentTimeSeconds = $derived(lessonVideoBus.currentTimeSeconds);
function isActiveSegment(segment: Segment, index: number): boolean {
let editing = $state(false);
let saving = $state(false);
let draft = $state<TranscriptSegment[]>([]);
const canEdit = $derived(Boolean($isOrgAdmin) && Boolean(assetId) && segments.length > 0);
function isActiveSegment(segment: TranscriptSegment, index: number): boolean {
const isLast = index === segments.length - 1;
if (isLast) {
@@ -40,6 +50,30 @@
return `${m}:${s}`;
}
function startEditing() {
draft = segments.map((segment) => ({ ...segment }));
editing = true;
}
function cancelEditing() {
editing = false;
draft = [];
}
async function save() {
if (!assetId) return;
saving = true;
const updated = await mediaApi.updateAssetTranscript(assetId, draft);
saving = false;
if (!updated) return;
lessonVideoBus.setTranscript(updated);
editing = false;
draft = [];
}
</script>
<div class="flex min-h-0 flex-1 flex-col">
@@ -57,18 +91,55 @@
{/if}
</div>
</div>
<IconButton
onclick={() => sidePanel.close()}
variant="outline"
size="icon-xs"
tooltip={$t('side_panel.close_aria')}
>
<XIcon size={16} />
</IconButton>
<div class="flex shrink-0 items-center gap-1">
{#if editing}
<Button variant="ghost" size="sm" onclick={cancelEditing} disabled={saving}>
{$t('course.navItem.lessons.materials.tabs.video.transcript.cancel')}
</Button>
<Button size="sm" onclick={save} disabled={saving}>
{$t('course.navItem.lessons.materials.tabs.video.transcript.save')}
</Button>
{:else}
{#if canEdit}
<IconButton
onclick={startEditing}
variant="outline"
size="icon-xs"
tooltip={$t('course.navItem.lessons.materials.tabs.video.transcript.edit')}
>
<PencilIcon size={16} />
</IconButton>
{/if}
<IconButton
onclick={() => sidePanel.close()}
variant="outline"
size="icon-xs"
tooltip={$t('side_panel.close_aria')}
>
<XIcon size={16} />
</IconButton>
{/if}
</div>
</header>
<div class="min-h-0 flex-1 overflow-y-auto p-3">
{#if segments.length}
{#if editing}
{#each draft as segment, index (index)}
<div class="mb-3 last:mb-0">
<span class="ui:text-muted-foreground mb-1 block text-xs tabular-nums">
{formatTimestamp(segment.start)}
</span>
<Textarea
bind:value={draft[index].text}
rows={2}
disabled={saving}
aria-label={$t('course.navItem.lessons.materials.tabs.video.transcript.segment_label', {
timestamp: formatTimestamp(segment.start)
})}
/>
</div>
{/each}
{:else if segments.length}
{#each segments as segment, index (segment.start + segment.end + index)}
{@const active = isActiveSegment(segment, index)}
<button
@@ -7,6 +7,8 @@
import { t } from '$lib/utils/functions/translations';
import { VideoUploader } from '$lib/utils/services/courses/presign';
import { MediaUploader } from './media-uploader';
import { encodeAndUploadHls, type HlsEncodeProgress } from './hls-encoder';
import { env as publicEnv } from '$env/dynamic/public';
import { Button } from '@cio/ui/base/button';
import * as FileDropZone from '@cio/ui/custom/file-drop-zone';
import { mediaApi } from '$features/media/api';
@@ -23,6 +25,14 @@
let { lessonId: _lessonId = '' }: Props = $props();
let fileSize: number | undefined = $state();
let hlsProgress: HlsEncodeProgress | null = $state(null);
let hlsAbortController: AbortController | null = null;
/**
* HLS is the default upload path on Cloud. Self-hosted installs fall
* back to raw-MP4 upload (no R2 + tenant-router edge dependency).
*/
const hlsEnabled = $derived(publicEnv.PUBLIC_IS_SELFHOSTED !== 'true');
let formRes: {
url?: string;
@@ -110,6 +120,82 @@
fileSize = videoFile.size / (1024 * 1024);
fileName = videoFile.name;
if (hlsEnabled) {
hlsAbortController = new AbortController();
try {
const result = await encodeAndUploadHls({
file: videoFile,
title: videoFile.name,
signal: hlsAbortController.signal,
onProgress: (p) => {
hlsProgress = p;
$lessonVideoUpload.uploadProgress = p.percent;
}
});
const lessonVideoPosition = Array.isArray(lessonApi.lesson?.videos) ? lessonApi.lesson?.videos.length : 0;
if (_lessonId) {
await mediaApi.attachAsset(result.assetId, {
targetType: 'lesson',
targetId: _lessonId,
slotType: 'lesson_video',
position: lessonVideoPosition
});
}
const hlsManifestUrl = `/hls/${result.assetId}/master.m3u8`;
lessonApi.updateLessonState(
'videos',
[
{
type: 'upload',
link: hlsManifestUrl,
assetId: result.assetId,
fileName: videoFile.name,
metadata: {
fileName: videoFile.name,
createdAt: new Date().toISOString(),
hls: true,
sourceWidth: result.sourceWidth,
sourceHeight: result.sourceHeight,
hlsRenditions: result.hlsRenditions,
hls1080Status: result.hls1080Status,
...(result.thumbnailUrl ? { thumbnailUrl: result.thumbnailUrl } : {})
}
} as {
type: 'upload';
link: string;
assetId?: string;
metadata?: Record<string, unknown>;
fileName?: string;
}
],
{ append: true }
);
formRes = {
url: hlsManifestUrl,
fileKey: result.manifestKey,
status: 200,
thumbnailUrl: result.thumbnailUrl
};
startProcessingPoll(result.assetId);
isLoaded = true;
return;
} catch (err) {
console.error('HLS encode failed', err);
formRes = {
type: 'INTERNAL_ERROR',
status: 500,
message: err instanceof Error ? err.message : t.get('hls_upload.error.encode_failed')
};
isLoaded = true;
return;
} finally {
$lessonVideoUpload.isUploading = false;
}
}
try {
const { url: presignedUrl, fileKey } = await videoUploader.getPresignedUrl(videoFile);
@@ -256,6 +342,7 @@
function cancelUpload() {
videoUploader.abortController?.abort();
hlsAbortController?.abort();
cancelVideoUpload();
@@ -264,7 +351,13 @@
$lessonVideoUpload.uploadProgress = 0;
}
let helperText = $derived($lessonVideoUpload.uploadProgress + '% of ' + Math.round(fileSize || 0) + 'MB');
let helperText = $derived.by(() => {
const hp = hlsProgress;
if (!hp) return $lessonVideoUpload.uploadProgress + '% of ' + Math.round(fileSize || 0) + 'MB';
const stageLabel = t.get(`hls_upload.stage.${hp.stage}`);
return `${stageLabel} ${hp.percent}%`;
});
</script>
<UpgradeBanner className="mb-3" onClick={() => ($lessonVideoUpload.isModalOpen = false)}>
@@ -4,6 +4,7 @@
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import EyeIcon from '@lucide/svelte/icons/eye';
import ImageIcon from '@lucide/svelte/icons/image';
import SparklesIcon from '@lucide/svelte/icons/sparkles';
import Trash2Icon from '@lucide/svelte/icons/trash-2';
import { IconButton } from '@cio/ui/custom/icon-button';
import { mediaApi } from '$features/media/api';
@@ -14,7 +15,15 @@
import { snackbar } from '$features/ui/snackbar/store';
import { t } from '$lib/utils/functions/translations';
import { onDestroy } from 'svelte';
import type { LessonVideo } from './video-card-utils';
import {
canGenerateHls1080,
getHls1080Status,
getVideoMetadata,
isHlsUploadVideo,
hasHls1080Rendition,
type LessonVideo
} from './video-card-utils';
import { encodeAndUploadHls1080 } from './hls-encoder';
import { lessonVideoBus } from './lesson-video-bus.svelte';
import { TRANSCRIPT_PANEL_ID } from './transcript-panel-definition';
@@ -22,25 +31,35 @@
video: LessonVideo;
onRemove: () => void;
onThumbnailSaved?: (thumbnailUrl: string) => void;
onHlsMetadataUpdated?: (metadata: Record<string, unknown>) => void;
/** `inline` aligns with the title row (YouTube-style). `corner` is top-right overlay on the nearest `relative` ancestor. */
menuPlacement?: 'inline' | 'corner';
}
let { video, onRemove, onThumbnailSaved, menuPlacement = 'inline' }: Props = $props();
let { video, onRemove, onThumbnailSaved, onHlsMetadataUpdated, menuPlacement = 'inline' }: Props = $props();
let manageThumbsOpen = $state(false);
let manageThumbsAsset = $state<OrganizationAsset | null>(null);
let isLoadingAsset = $state(false);
let dropdownOpen = $state(false);
let isTranscribing = $state(false);
let hasTranscript = $state(false);
let hasCheckedTranscript = $state(false);
let activePoller: JobPoller<MediaJobEnvelope> | null = null;
let isGenerating1080 = $state(false);
let sourceFileInput: HTMLInputElement | undefined = $state();
const assetId = $derived((video as LessonVideo & { assetId?: string }).assetId ?? null);
const canGenerateTranscript = $derived(video.type === 'upload' && !!assetId);
const canManageThumbnails = $derived(video.type === 'upload' && !!assetId);
const hls1080Status = $derived(getHls1080Status(video));
const showGenerate1080 = $derived(canGenerateHls1080(video) && !isGenerating1080);
const show1080Ready = $derived(hls1080Status === 'ready');
const show1080Unavailable = $derived(
isHlsUploadVideo(video) && !hasHls1080Rendition(video) && !isGenerating1080 && hls1080Status === 'unavailable'
);
onDestroy(() => {
activePoller?.stop();
@@ -54,6 +73,24 @@
hasTranscript = !!data?.segments?.length;
}
async function ensureHlsMetadataChecked() {
if (!assetId || !isHlsUploadVideo(video)) return;
const metadata = getVideoMetadata(video);
if (metadata.sourceHeight != null) return;
const asset = await mediaApi.refreshAsset(assetId);
if (!asset?.metadata || typeof asset.metadata !== 'object' || Array.isArray(asset.metadata)) return;
const assetMetadata = asset.metadata as Record<string, unknown>;
onHlsMetadataUpdated?.({
...(typeof assetMetadata.sourceWidth === 'number' ? { sourceWidth: assetMetadata.sourceWidth } : {}),
...(typeof assetMetadata.sourceHeight === 'number' ? { sourceHeight: assetMetadata.sourceHeight } : {}),
...(Array.isArray(assetMetadata.hlsRenditions) ? { hlsRenditions: assetMetadata.hlsRenditions } : {}),
...(typeof assetMetadata.hls1080Status === 'string' ? { hls1080Status: assetMetadata.hls1080Status } : {})
});
}
async function startTranscriptionPoll(afterAssetId: string) {
activePoller?.stop();
activePoller = null;
@@ -131,6 +168,10 @@
async function handleManageThumbnails() {
if (!assetId || isLoadingAsset) return;
// Close the dropdown immediately so the user doesn't see it floating
// behind the dialog during the asset refresh round-trip.
dropdownOpen = false;
isLoadingAsset = true;
try {
const asset = await mediaApi.refreshAsset(assetId);
@@ -153,18 +194,78 @@
}
}
function handleGenerate1080Click() {
if (!assetId || isGenerating1080) return;
dropdownOpen = false;
sourceFileInput?.click();
}
async function handleSourceFileSelected(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const selectedFile = input.files?.[0];
input.value = '';
if (!selectedFile || !assetId) return;
isGenerating1080 = true;
onHlsMetadataUpdated?.({ hls1080Status: 'generating' });
try {
const result = await encodeAndUploadHls1080({
file: selectedFile,
assetId
});
onHlsMetadataUpdated?.({
hlsRenditions: result.hlsRenditions,
hls1080Status: result.hls1080Status
});
snackbar.success('snackbar.lesson_video.generate_1080_completed');
} catch (error) {
console.error('Failed to generate 1080p rendition', error);
onHlsMetadataUpdated?.({ hls1080Status: 'failed' });
snackbar.error('snackbar.lesson_video.generate_1080_failed');
} finally {
isGenerating1080 = false;
}
}
const generateLabel = $derived(
isTranscribing
? t.get('course.navItem.lessons.materials.tabs.video.generate_transcript_in_progress')
: t.get('course.navItem.lessons.materials.tabs.video.generate_transcript')
);
const generate1080Label = $derived(
isGenerating1080
? t.get('course.navItem.lessons.materials.tabs.video.generate_1080_in_progress')
: t.get('course.navItem.lessons.materials.tabs.video.generate_1080')
);
</script>
<input
bind:this={sourceFileInput}
type="file"
accept="video/*"
class="hidden"
onchange={(event) => {
void handleSourceFileSelected(event);
}}
/>
<DropdownMenu.Root
bind:open={dropdownOpen}
onOpenChange={(open) => {
if (open && canGenerateTranscript) {
if (!open) return;
if (canGenerateTranscript) {
void ensureTranscriptChecked();
}
if (isHlsUploadVideo(video)) {
void ensureHlsMetadataChecked();
}
}}
>
<DropdownMenu.Trigger
@@ -192,7 +293,39 @@
</span>
</DropdownMenu.Item>
{/if}
{#if canGenerateTranscript}
{#if showGenerate1080}
<DropdownMenu.Item disabled={isGenerating1080} onclick={handleGenerate1080Click}>
<span class="flex items-center gap-2">
<SparklesIcon size={14} />
{generate1080Label}
</span>
</DropdownMenu.Item>
{/if}
{#if isGenerating1080}
<DropdownMenu.Item disabled>
<span class="flex items-center gap-2">
<SparklesIcon size={14} />
{$t('course.navItem.lessons.materials.tabs.video.generate_1080_in_progress')}
</span>
</DropdownMenu.Item>
{/if}
{#if show1080Ready}
<DropdownMenu.Item disabled>
<span class="flex items-center gap-2">
<SparklesIcon size={14} />
{$t('course.navItem.lessons.materials.tabs.video.generate_1080_ready')}
</span>
</DropdownMenu.Item>
{/if}
{#if show1080Unavailable}
<DropdownMenu.Item disabled>
<span class="flex items-center gap-2">
<SparklesIcon size={14} />
{$t('course.navItem.lessons.materials.tabs.video.generate_1080_unavailable')}
</span>
</DropdownMenu.Item>
{/if}
{#if canGenerateTranscript && !hasTranscript}
<DropdownMenu.Item
disabled={isTranscribing}
onclick={() => {
@@ -204,18 +337,18 @@
{generateLabel}
</span>
</DropdownMenu.Item>
{#if hasTranscript}
<DropdownMenu.Item
onclick={() => {
void handleViewTranscript();
}}
>
<span class="flex items-center gap-2">
<EyeIcon size={14} />
{$t('course.navItem.lessons.materials.tabs.video.view_transcript')}
</span>
</DropdownMenu.Item>
{/if}
{/if}
{#if canGenerateTranscript && hasTranscript}
<DropdownMenu.Item
onclick={() => {
void handleViewTranscript();
}}
>
<span class="flex items-center gap-2">
<EyeIcon size={14} />
{$t('course.navItem.lessons.materials.tabs.video.view_transcript')}
</span>
</DropdownMenu.Item>
{/if}
<DropdownMenu.Item class="ui:text-red-600" onclick={onRemove}>
<span class="flex items-center gap-2">
@@ -2,7 +2,9 @@ import { getYoutubeVideoId } from '@cio/ui/custom/media-player';
export type LessonVideo = NonNullable<import('$features/course/utils/types').Lesson['videos']>[number];
type VideoMetadata = {
export type Hls1080Status = 'none' | 'generating' | 'ready' | 'failed';
export type VideoMetadata = {
title?: string;
fileName?: string;
aspectRatio?: string;
@@ -11,8 +13,66 @@ type VideoMetadata = {
thumbnailUrl?: string;
googleDriveFileId?: string;
mimeType?: string;
hls?: boolean;
sourceWidth?: number;
sourceHeight?: number;
hlsRenditions?: string[];
hls1080Status?: Hls1080Status;
};
export function getVideoMetadata(video: LessonVideo): VideoMetadata {
if (video.metadata && typeof video.metadata === 'object' && !Array.isArray(video.metadata)) {
return video.metadata as VideoMetadata;
}
return {};
}
export function isHlsUploadVideo(video: LessonVideo): boolean {
if (video.type !== 'upload') return false;
const metadata = getVideoMetadata(video);
if (metadata.hls === true) return true;
return typeof video.link === 'string' && video.link.includes('/hls/') && video.link.endsWith('.m3u8');
}
export function hasHls1080Rendition(video: LessonVideo): boolean {
const metadata = getVideoMetadata(video);
return metadata.hlsRenditions?.includes('p1080') ?? false;
}
export function canGenerateHls1080(video: LessonVideo): boolean {
if (!isHlsUploadVideo(video)) return false;
const assetId = (video as LessonVideo & { assetId?: string }).assetId;
if (!assetId) return false;
const metadata = getVideoMetadata(video);
const sourceHeight = metadata.sourceHeight;
if (sourceHeight != null && sourceHeight < 1080) return false;
if (hasHls1080Rendition(video)) return false;
if (metadata.hls1080Status === 'generating') return false;
return true;
}
export function getHls1080Status(video: LessonVideo): Hls1080Status | 'unavailable' | 'unknown' {
const metadata = getVideoMetadata(video);
if (!isHlsUploadVideo(video)) return 'unavailable';
if (hasHls1080Rendition(video) || metadata.hls1080Status === 'ready') {
return 'ready';
}
const sourceHeight = metadata.sourceHeight;
if (sourceHeight == null) return 'unknown';
if (sourceHeight < 1080) return 'unavailable';
return metadata.hls1080Status ?? 'none';
}
/**
* Thumbnail URL for a lesson video. YouTube uses img.youtube.com; uploads use metadata.thumbnailUrl when present.
*/
@@ -1,9 +1,52 @@
const JPEG_QUALITY = 0.85;
const DEFAULT_SEEK_SECONDS = 1;
const THUMBNAIL_TIMEOUT_MS = 15_000;
/** Cap the captured frame to this width so the preview matches the stored thumbnail. */
const MAX_THUMBNAIL_WIDTH = 1280;
/** Hard ceiling for the generated thumbnail (matches the server job). */
const THUMBNAIL_MAX_BYTES = 500 * 1024;
/** JPEG quality steps tried (best first) until the frame fits under the size cap. */
const THUMBNAIL_QUALITY_LADDER = [0.9, 0.8, 0.7, 0.6, 0.5];
export type ThumbnailResult = { blob: Blob; durationSeconds: number };
/**
* Encode the canvas as JPEG, stepping quality down until the blob fits under
* `maxBytes`. Resolves with the first blob within the cap, the smallest one
* produced if none fit, or null on failure.
*/
function canvasToJpegBlobUnderCap(canvas: HTMLCanvasElement, maxBytes: number): Promise<Blob | null> {
return new Promise((resolve) => {
let smallest: Blob | null = null;
let index = 0;
const tryNext = () => {
if (index >= THUMBNAIL_QUALITY_LADDER.length) {
resolve(smallest);
return;
}
const quality = THUMBNAIL_QUALITY_LADDER[index];
index += 1;
canvas.toBlob(
(blob) => {
if (blob) {
if (!smallest || blob.size < smallest.size) smallest = blob;
if (blob.size <= maxBytes) {
resolve(blob);
return;
}
}
tryNext();
},
'image/jpeg',
quality
);
};
tryNext();
});
}
/**
* Generates a thumbnail from a video file by capturing a frame to canvas.
* Seeks to 1s or duration/4 for short clips. Returns null on failure.
@@ -44,13 +87,15 @@ export function generateVideoThumbnail(videoFile: File): Promise<ThumbnailResult
video.addEventListener('seeked', () => {
try {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
if (canvas.width <= 0 || canvas.height <= 0) {
if (video.videoWidth <= 0 || video.videoHeight <= 0) {
finish(null);
return;
}
const scale = video.videoWidth > MAX_THUMBNAIL_WIDTH ? MAX_THUMBNAIL_WIDTH / video.videoWidth : 1;
const canvas = document.createElement('canvas');
canvas.width = Math.round(video.videoWidth * scale);
canvas.height = Math.round(video.videoHeight * scale);
const ctx = canvas.getContext('2d');
if (!ctx) {
finish(null);
@@ -58,17 +103,13 @@ export function generateVideoThumbnail(videoFile: File): Promise<ThumbnailResult
}
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const durationSeconds = video.duration;
canvas.toBlob(
(blob) => {
if (blob) {
finish({ blob, durationSeconds: Number.isFinite(durationSeconds) ? durationSeconds : 0 });
} else {
finish(null);
}
},
'image/jpeg',
JPEG_QUALITY
);
canvasToJpegBlobUnderCap(canvas, THUMBNAIL_MAX_BYTES).then((blob) => {
if (blob) {
finish({ blob, durationSeconds: Number.isFinite(durationSeconds) ? durationSeconds : 0 });
} else {
finish(null);
}
});
} catch {
finish(null);
}
@@ -53,7 +53,15 @@
</Button>
{#if videos.length}
<Item.Group class="grid! w-full grid-cols-1 gap-x-5 gap-y-8 sm:grid-cols-2 lg:grid-cols-3">
<!--
`auto-fill` + `minmax(200px, 1fr)` packs as many columns as fit while
each card stays at least 200px wide. Unlike `auto-fit`, empty tracks
are kept — so a single card on a wide container stays slim instead
of stretching across the full row. As the container narrows
(e.g. the transcript side panel opens) the grid drops columns
naturally instead of squeezing the cards.
-->
<Item.Group class="grid! w-full grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-x-5 gap-y-8">
{#each videos as video, index}
<LessonVideoSimpleCard {video} {index} isEditMode={true} onRemove={() => requestRemoveVideo(index)} />
{/each}