feat(import): video-folder and pdf-deck jobs, deck cache

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 03:15:44 +01:00
1 parent 07e2223c8a
commit 9738da458d
4 files changed
+212

No files matched your search

+49
View File
@@ -0,0 +1,49 @@
import { basename, extname, join } from "node:path";
import { copyFile, mkdir } from "node:fs/promises";
import { coverCacheDir, deckCacheDir, exists, type Deps, type ManagedCourse } from "./context";
import { putFile } from "./media";
import { renderPdf } from "./pdf";
import { slugify, uniqueId } from "./util/text";
export interface AttachedDeck { deckId: string; file: string; pages?: number; warning?: string }
/** All lesson and module ids of a raw manifest, for validating "attach to". */
export function attachTargets(manifest: Record<string, any>): Set<string> {
const ids = new Set<string>();
for (const m of manifest.modules ?? []) {
ids.add(m.id);
for (const l of m.lessons ?? []) ids.add(l.id);
}
return ids;
}
/**
* Copies a PDF into the course (media/decks/), registers it in the (in-memory) manifest, and builds the derived cache:
* page images + text.json under <dataDir>/cache/decks/<course>/<deck>/ and the first page as a cover candidate.
* A failure of the PDF tool only degrades to a warning: the deck stays attached and the cache can be rebuilt.
* The caller writes the manifest.
*/
export async function attachDeck(d: Deps, course: ManagedCourse, pdfPath: string, attachTo: string, opts: { title?: string; mode?: "copy" | "link"; onProgress?: (fraction: number) => void } = {}): Promise<AttachedDeck> {
if (!attachTarget(course, attachTo)) throw new Error(`attachTo "${attachTo}" is not a lesson or module of course "${course.id}"`);
const decks: any[] = (course.manifest.decks ??= []);
const baseName = basename(pdfPath, extname(pdfPath));
const deckId = uniqueId(`d-${slugify(baseName)}`, (x) => decks.some((k) => k.id === x));
let file = `media/decks/${basename(pdfPath)}`;
for (let n = 2; await exists(join(course.dir, file)); n++) file = `media/decks/${baseName}-${n}${extname(pdfPath)}`;
await putFile(pdfPath, join(course.dir, file), opts.mode ?? "copy");
decks.push({ id: deckId, file, title: opts.title ?? baseName.replace(/[_]+/g, " ").trim(), attachTo });
const out: AttachedDeck = { deckId, file };
try {
const r = await renderPdf(d.python, join(course.dir, file), deckCacheDir(d, course.id, deckId), { onPage: (p, t) => opts.onProgress?.(p / t) });
out.pages = r.pages;
await mkdir(coverCacheDir(d, course.id), { recursive: true });
await copyFile(join(deckCacheDir(d, course.id, deckId), "p-001.jpg"), join(coverCacheDir(d, course.id), `deck-${deckId}.jpg`));
} catch (e) {
out.warning = `deck "${basename(pdfPath)}" is attached, but page images and text could not be built (${e instanceof Error ? e.message.split("\n")[0] : e}); STC_PYTHON must point to Python with PyMuPDF`;
}
return out;
}
const attachTarget = (c: ManagedCourse, id: string) => attachTargets(c.manifest).has(id);
@@ -0,0 +1,26 @@
import { stat } from "node:fs/promises";
import { join } from "node:path";
import type { ImportResult, JobContext } from "@stc/contracts";
import { finish, managedCourse, saveManifest, type Deps } from "../context";
import { attachDeck } from "../deck";
import { optStr, parsePayload } from "./common";
export async function importPdfDeck(d: Deps, payload: unknown, jc: JobContext): Promise<ImportResult> {
const { path, options } = parsePayload(payload, "pdf-deck");
const warnings: string[] = [];
if (!(await stat(path).catch(() => null))?.isFile() || !/\.pdf$/i.test(path)) throw new Error(`not a PDF file: ${path}`);
const course = await managedCourse(d, optStr(options, "courseId"));
const attachTo = optStr(options, "attachTo");
if (!attachTo) throw new Error("options.attachTo is required: the lesson or module id the deck belongs to");
await jc.reportProgress(0.05, "Copying the deck");
const r = await attachDeck(d, course, path, attachTo, {
title: optStr(options, "title"),
mode: options.media === "link" ? "link" : "copy",
onProgress: (f) => void jc.reportProgress(0.1 + 0.8 * f, "Rendering slides"),
});
if (r.warning) warnings.push(r.warning);
await saveManifest(course.dir, course.manifest);
await finish(d, course.id, warnings);
await jc.reportProgress(1, r.pages ? `Deck attached (${r.pages} slides)` : "Deck attached");
return { courseId: course.id, manifestPath: join(course.dir, "stcourse.yaml"), warnings };
}
@@ -0,0 +1,62 @@
import { stat } from "node:fs/promises";
import { basename, extname, join } from "node:path";
import type { ImportResult, JobContext } from "@stc/contracts";
import { finish, frameCandidates, freshCourseId, publishCourse, saveManifest, sourceBlock, stageDir, type Deps } from "../context";
import { probeDuration, putFile } from "../media";
import { naturalSort } from "../util/text";
import { openSource } from "../util/source";
import { adoptCover, maybeQueueTranscription, optBool, optStr, parsePayload, withStage } from "./common";
const VIDEO = /\.(mp4|m4v|webm|mov|mkv|avi)$/i;
const BROWSER_OK = /\.(mp4|m4v|webm|mov)$/i;
export function lessonTitle(file: string): string {
const base = basename(file, extname(file));
const t = base.replace(/^\s*\d+\s*[-_.)\s]+(?=\S)/, "").replace(/_/g, " ").replace(/\s+/g, " ").trim();
return t || base;
}
export async function importVideoFolder(d: Deps, payload: unknown, jc: JobContext): Promise<ImportResult> {
const { path, options } = parsePayload(payload, "video-folder");
const warnings: string[] = [];
if (!(await stat(path).catch(() => null))?.isDirectory()) throw new Error(`not a folder: ${path}`);
const src = await openSource(path);
const files = naturalSort(src.entries.filter((e) => VIDEO.test(e) && !e.split("/").some((p) => p.startsWith("."))));
if (files.length === 0) throw new Error(`no video files found in ${path}`);
const title = optStr(options, "title") ?? basename(path).replace(/[_]+/g, " ");
const id = await freshCourseId(d, optStr(options, "courseId"), title);
const mode = options.media === "link" ? "link" : "copy";
const stage = await stageDir(d, "videos");
return withStage(stage, async () => {
const lessons: any[] = [];
const placed: string[] = [];
const used = new Set<string>();
for (const [i, f] of files.entries()) {
await jc.reportProgress(0.05 + 0.7 * (i / files.length), `Copying ${basename(f)}`);
let rel = `media/${f}`;
if (used.has(rel.toLowerCase())) rel = `media/${i + 1}-${basename(f)}`;
used.add(rel.toLowerCase());
await putFile(join(path, f), join(stage, rel), mode);
placed.push(join(stage, rel));
if (!BROWSER_OK.test(f)) warnings.push(`${f} may not play in browsers (${extname(f)}); consider converting it to MP4`);
const duration = await probeDuration(d.tools, join(stage, rel));
lessons.push({ id: `l${i + 1}`, title: lessonTitle(f), type: "video", file: rel, ...(duration ? { duration } : {}) });
}
await jc.reportProgress(0.8, "Making cover candidates");
const covers = await frameCandidates(d, id, placed);
if (covers.length === 0) warnings.push("could not extract cover frames (is ffmpeg available?)");
const cover = await adoptCover(d, id, stage, covers[0]);
await saveManifest(stage, {
schema: 1, id, title, visibility: "restricted", ...(cover ? { cover } : {}),
source: sourceBlock("video-folder", path),
modules: [{ id: "m1", title: "Videos", lessons }],
});
await jc.reportProgress(0.92, "Publishing the course");
const dir = await publishCourse(d, stage, id);
await finish(d, id, warnings);
await maybeQueueTranscription(d, id, optBool(options, "transcribe"), warnings);
await jc.reportProgress(1, `Imported ${files.length} videos`);
return { courseId: id, manifestPath: join(dir, "stcourse.yaml"), warnings };
});
}