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
@@ -13,8 +13,10 @@ import type {
OrganizationAsset,
RegenerateAssetThumbnailRequest,
SelectAssetThumbnailRequest,
TranscriptSegment,
UpdateAssetData,
UpdateAssetRequest,
UpdateAssetTranscriptRequest,
YouTubeMetadata
} from '../utils/types';
import type {
@@ -68,8 +70,18 @@ export class MediaApi extends BaseApiWithErrors {
return;
}
const { page, limit, kind, status, search, includeExternal } = parsed.data;
const rpcQuery = {
page: String(page),
limit: String(limit),
includeExternal: String(includeExternal),
...(kind !== undefined ? { kind } : {}),
...(status !== undefined ? { status } : {}),
...(search !== undefined ? { search } : {})
};
await this.execute<ListAssetsRequest>({
requestFn: () => classroomio.organization.assets.$get({ query: parsed.data }),
requestFn: () => classroomio.organization.assets.$get({ query: rpcQuery }),
logContext: 'listing organization assets',
onSuccess: (response) => {
this.assets = response.data;
@@ -88,8 +100,13 @@ export class MediaApi extends BaseApiWithErrors {
return;
}
const rpcQuery = {
includeArchived: String(parsed.data.includeArchived),
includeExternal: String(parsed.data.includeExternal)
};
await this.execute<typeof classroomio.organization.assets.storage.$get>({
requestFn: () => classroomio.organization.assets.storage.$get({ query: parsed.data }),
requestFn: () => classroomio.organization.assets.storage.$get({ query: rpcQuery }),
logContext: 'fetching media manager storage summary',
onSuccess: (response) => {
this.storageSummary = response.data;
@@ -406,6 +423,29 @@ export class MediaApi extends BaseApiWithErrors {
return data;
}
/** Save edited transcript segments (org admins only). Returns the updated payload or null on failure. */
async updateAssetTranscript(assetId: string, segments: TranscriptSegment[]): Promise<AssetTranscriptPayload | null> {
let data: AssetTranscriptPayload | null = null;
await this.execute<UpdateAssetTranscriptRequest>({
requestFn: () =>
classroomio.organization.assets[':assetId'].transcript.$put({
param: { assetId },
json: { segments }
}),
logContext: 'updating asset transcript',
onSuccess: (response) => {
data = response.data;
snackbar.success('snackbar.media_manager.transcript_saved');
},
onError: () => {
snackbar.error('snackbar.media_manager.transcript_save_failed');
}
});
return data;
}
/** Persist the chosen thumbnail URL (must be a generated candidate or a fresh upload). */
async selectThumbnail(assetId: string, thumbnailUrl: string): Promise<OrganizationAsset | null> {
let updated: OrganizationAsset | null = null;
@@ -4,6 +4,7 @@
import * as FileDropZone from '@cio/ui/custom/file-drop-zone';
import type { FileRejectedReason } from '@cio/ui/custom/file-drop-zone';
import { Skeleton } from '@cio/ui/base/skeleton';
import MediaPlayer from '@cio/ui/custom/media-player/media-player.svelte';
import CheckIcon from '@lucide/svelte/icons/check';
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
@@ -12,6 +13,18 @@
import { snackbar } from '$features/ui/snackbar/store';
import { t } from '$lib/utils/functions/translations';
import { uploadImage } from '$lib/utils/services/upload';
import { classroomio } from '$lib/utils/services/api';
function buildHlsUrl(asset: OrganizationAsset): string | null {
if (!asset.hlsManifestKey) return null;
const built = classroomio.hls[':assetId']['*'].$url({ param: { assetId: asset.id } });
return built.toString().replace(/\/\*$/, '') + '/master.m3u8';
}
async function mintHlsCookie(assetId: string): Promise<void> {
await classroomio.organization.assets[':assetId'].hls.cookie.$post({ param: { assetId } });
}
interface Props {
open?: boolean;
@@ -19,7 +32,7 @@
onThumbnailSaved?: (asset: OrganizationAsset) => void;
}
const THUMBNAIL_MAX_SIZE = 5 * FileDropZone.MEGABYTE;
const THUMBNAIL_MAX_SIZE = 1 * FileDropZone.MEGABYTE;
const REGEN_POLL_INTERVAL_MS = 2000;
const REGEN_POLL_TIMEOUT_MS = 60_000;
const SLOT_KEYS = ['start', 'middle', 'end'] as const;
@@ -30,6 +43,7 @@
let selectedUrl = $state<string | null>(null);
let customUrl = $state<string | null>(null);
let videoUrl = $state<string | null>(null);
let playerRef = $state<ReturnType<typeof MediaPlayer> | null>(null);
let isVideoLoading = $state(false);
let isUploading = $state(false);
let isSaving = $state(false);
@@ -77,6 +91,7 @@
function selectCandidate(url: string) {
selectedUrl = url;
playerRef?.setPoster(url);
}
async function handleCustomUpload(files: File[]) {
@@ -88,6 +103,7 @@
const url = await uploadImage(file);
customUrl = url;
selectedUrl = url;
playerRef?.setPoster(url);
snackbar.success('snackbar.media_manager.thumbnail_uploaded');
} catch {
snackbar.error('snackbar.media_manager.thumbnail_upload_failed');
@@ -157,128 +173,156 @@
if (!isOpen) resetState();
}}
>
<Dialog.Content class="max-h-[85vh] w-[95%] max-w-3xl overflow-y-auto">
<Dialog.Content
class="ui:max-h-[85vh] ui:w-[95%] ui:max-w-3xl ui:overflow-hidden ui:grid-rows-[auto_minmax(0,1fr)_auto] ui:pb-0"
>
<Dialog.Header>
<Dialog.Title>{$t('media_manager.thumbnails.manage_title')}</Dialog.Title>
<Dialog.Description>{$t('media_manager.thumbnails.manage_description')}</Dialog.Description>
</Dialog.Header>
{#if asset}
<div class="space-y-5 p-1">
<div class="ui:bg-muted/40 aspect-video w-full overflow-hidden rounded-md border">
{#if isVideoLoading}
<Skeleton class="h-full w-full" />
{:else if videoUrl}
<!-- svelte-ignore a11y_media_has_caption -->
<video controls preload="metadata" src={videoUrl} class="h-full w-full bg-black"></video>
{:else}
<div class="ui:text-muted-foreground flex h-full w-full items-center justify-center text-sm">
{$t('media_manager.thumbnails.preview_unavailable')}
</div>
{/if}
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<p class="text-sm font-medium">{$t('media_manager.thumbnails.generated')}</p>
{#if allCandidatesMissing}
<p class="ui:text-muted-foreground text-xs">
{$t('media_manager.thumbnails.none_generated_warning')}
</p>
{@const activeAsset = asset}
<div class="ui:min-h-0 ui:overflow-y-auto">
<div class="space-y-5 p-1">
<div class="ui:bg-muted/40 aspect-video w-full overflow-hidden rounded-md border">
{#if isVideoLoading}
<Skeleton class="h-full w-full" />
{:else if activeAsset.hlsManifestKey}
{@const hlsUrl = buildHlsUrl(activeAsset)}
{#if hlsUrl}
<MediaPlayer
bind:this={playerRef}
source={{
type: 'upload',
url: hlsUrl,
hls: true,
metadata: { thumbnailUrl: selectedUrl ?? customUrl ?? activeAsset.thumbnailUrl ?? undefined }
}}
options={{
controls: true,
playsinline: true,
onBeforeHlsLoad: () => mintHlsCookie(activeAsset.id)
}}
/>
{/if}
{:else if videoUrl}
<!-- svelte-ignore a11y_media_has_caption -->
<video
controls
preload="metadata"
poster={selectedUrl ?? customUrl ?? activeAsset.thumbnailUrl ?? undefined}
src={videoUrl}
class="h-full w-full bg-black"
></video>
{:else}
<div class="ui:text-muted-foreground flex h-full w-full items-center justify-center text-sm">
{$t('media_manager.thumbnails.preview_unavailable')}
</div>
{/if}
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
{#each SLOT_KEYS as slotKey, index (slotKey)}
{@const url = candidates[index] ?? null}
{@const isSelected = url !== null && url === selectedUrl}
{#if isRegenerating}
<Skeleton class="aspect-video w-full rounded-md" />
{:else if url}
<button
type="button"
onclick={() => selectCandidate(url)}
class={`ui:focus-visible:ring-ring relative aspect-video w-full overflow-hidden rounded-md border text-left transition focus-visible:ring-2 focus-visible:outline-none ${
isSelected ? 'ring-primary border-primary ring-2' : 'hover:border-primary/50'
}`}
aria-pressed={isSelected}
>
<img
src={url}
alt={$t(`media_manager.thumbnails.slot_${slotForIndex(index)}`)}
class="h-full w-full object-cover"
/>
<span
class="ui:bg-background/85 absolute bottom-1 left-1 rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase"
<div class="space-y-2">
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-medium">{$t('media_manager.thumbnails.generated')}</p>
<div class="flex items-center gap-3">
{#if allCandidatesMissing}
<p class="ui:text-muted-foreground text-xs">
{$t('media_manager.thumbnails.none_generated_warning')}
</p>
{/if}
<Button variant="ghost" size="sm" onclick={regenerate} loading={isRegenerating} disabled={isSaving}>
<RefreshCcwIcon class="size-4" />
{$t('media_manager.thumbnails.regenerate')}
</Button>
</div>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
{#each SLOT_KEYS as slotKey, index (slotKey)}
{@const url = candidates[index] ?? null}
{@const isSelected = url !== null && url === selectedUrl}
{#if isRegenerating}
<Skeleton class="aspect-video w-full rounded-md" />
{:else if url}
<button
type="button"
onclick={() => selectCandidate(url)}
class={`ui:focus-visible:ring-ring relative aspect-video w-full overflow-hidden rounded-md border text-left transition focus-visible:ring-2 focus-visible:outline-none ${
isSelected ? 'ring-primary border-primary ring-2' : 'hover:border-primary/50'
}`}
aria-pressed={isSelected}
>
<img
src={url}
alt={$t(`media_manager.thumbnails.slot_${slotForIndex(index)}`)}
class="h-full w-full object-cover"
/>
<span
class="ui:bg-background/85 absolute bottom-1 left-1 rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase"
>
{$t(`media_manager.thumbnails.slot_${slotForIndex(index)}`)}
</span>
{#if isSelected}
<span
class="ui:bg-primary ui:text-primary-foreground absolute top-1 right-1 flex h-5 w-5 items-center justify-center rounded-full"
>
<CheckIcon class="custom size-3" />
</span>
{/if}
</button>
{:else}
<div
class="ui:bg-muted/30 ui:text-muted-foreground flex aspect-video w-full items-center justify-center rounded-md border border-dashed text-xs"
>
{$t(`media_manager.thumbnails.slot_${slotForIndex(index)}`)}
</span>
{#if isSelected}
<span
class="ui:bg-primary ui:text-primary-foreground absolute top-1 right-1 flex h-5 w-5 items-center justify-center rounded-full"
>
<CheckIcon class="size-3" />
</span>
{/if}
</button>
{:else}
<div
class="ui:bg-muted/30 ui:text-muted-foreground flex aspect-video w-full items-center justify-center rounded-md border border-dashed text-xs"
>
{$t(`media_manager.thumbnails.slot_${slotForIndex(index)}`)}
</div>
{/if}
{/each}
</div>
</div>
<div class="space-y-2">
<p class="text-sm font-medium">{$t('media_manager.thumbnails.upload_custom')}</p>
<div class={isUploading ? 'ui:pointer-events-none ui:opacity-50' : ''}>
<FileDropZone.Root
accept={FileDropZone.ACCEPT_IMAGE}
maxFiles={1}
fileCount={0}
maxFileSize={THUMBNAIL_MAX_SIZE}
onUpload={handleCustomUpload}
onFileRejected={handleUploadRejected}
>
<FileDropZone.Trigger
label={$t('media_manager.form.thumbnail_drop_label')}
formatMaxSize={() => $t('media_manager.form.thumbnail_drop_size')}
/>
</FileDropZone.Root>
</div>
{#if customUrl}
<div class="flex items-center gap-3 rounded-md border p-2">
<img
src={customUrl}
alt={$t('media_manager.thumbnails.custom_preview_alt')}
class="h-16 w-24 rounded object-cover"
/>
<p class="ui:text-muted-foreground text-xs">
{$t('media_manager.thumbnails.custom_selected_hint')}
</p>
</div>
{/if}
{/each}
</div>
{/if}
</div>
<div class="space-y-2">
<p class="text-sm font-medium">{$t('media_manager.thumbnails.upload_custom')}</p>
<div class={isUploading ? 'ui:pointer-events-none ui:opacity-50' : ''}>
<FileDropZone.Root
accept={FileDropZone.ACCEPT_IMAGE}
maxFiles={1}
fileCount={0}
maxFileSize={THUMBNAIL_MAX_SIZE}
onUpload={handleCustomUpload}
onFileRejected={handleUploadRejected}
>
<FileDropZone.Trigger
label={$t('media_manager.form.thumbnail_drop_label')}
formatMaxSize={() => $t('media_manager.form.thumbnail_drop_size')}
/>
</FileDropZone.Root>
</div>
{#if customUrl}
<div class="flex items-center gap-3 rounded-md border p-2">
<img
src={customUrl}
alt={$t('media_manager.thumbnails.custom_preview_alt')}
class="h-16 w-24 rounded object-cover"
/>
<p class="ui:text-muted-foreground text-xs">
{$t('media_manager.thumbnails.custom_selected_hint')}
</p>
</div>
{/if}
</div>
</div>
</div>
{/if}
<Dialog.Footer class="ui:gap-2 flex-col sm:flex-row sm:justify-between">
<Button variant="outline" onclick={regenerate} loading={isRegenerating} disabled={isSaving}>
<RefreshCcwIcon class="size-4" />
{$t('media_manager.thumbnails.regenerate')}
<Dialog.Footer class="ui:border-border ui:-mx-6 ui:border-t ui:px-6 ui:py-4">
<Button variant="outline" onclick={() => (open = false)} disabled={isSaving || isRegenerating}>
{$t('media_manager.edit.cancel')}
</Button>
<Button onclick={save} loading={isSaving} disabled={!hasSelection || isRegenerating || isUploading}>
{$t('media_manager.thumbnails.save_selection')}
</Button>
<div class="flex gap-2">
<Button variant="outline" onclick={() => (open = false)} disabled={isSaving || isRegenerating}>
{$t('media_manager.edit.cancel')}
</Button>
<Button onclick={save} loading={isSaving} disabled={!hasSelection || isRegenerating || isUploading}>
{$t('media_manager.thumbnails.save_selection')}
</Button>
</div>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -57,6 +57,12 @@ export type GetAssetTranscriptResponse = InferResponseType<GetAssetTranscriptReq
export type GetAssetTranscriptSuccess = Extract<GetAssetTranscriptResponse, { success: true }>;
export type AssetTranscriptPayload = GetAssetTranscriptSuccess['data'];
export type UpdateAssetTranscriptRequest =
(typeof classroomio.organization)['assets'][':assetId']['transcript']['$put'];
export type UpdateAssetTranscriptResponse = InferResponseType<UpdateAssetTranscriptRequest>;
export type UpdateAssetTranscriptSuccess = Extract<UpdateAssetTranscriptResponse, { success: true }>;
export type TranscriptSegment = NonNullable<AssetTranscriptPayload>['segments'][number];
export type SelectAssetThumbnailRequest = (typeof classroomio.organization)['assets'][':assetId']['thumbnail']['$put'];
export type SelectAssetThumbnailResponse = InferResponseType<SelectAssetThumbnailRequest>;
export type SelectAssetThumbnailSuccess = Extract<SelectAssetThumbnailResponse, { success: true }>;