261 lines
9.3 KiB
TypeScript
261 lines
9.3 KiB
TypeScript
/**
|
|
* `assets` pass — dedup + zip layout / naming plan.
|
|
*
|
|
* Turns the referenced narration audio, media (`play_video` targets) and base
|
|
* frames into a deterministic set of {@link AssetPlanEntry} paths, and stamps the
|
|
* resolved `assetRef` / `assetId` / `present` back onto the narration and video
|
|
* segments. It plans *layout and naming only* — the actual bytes are collected by
|
|
* the browser-side implementation in the next phase (P1d); {@link AssetSource}
|
|
* supplies just enough metadata (id, mime/format, presence) to build the plan.
|
|
*
|
|
* Deduplication is by `assetId`: the first reference owns the path, later
|
|
* references reuse it and carry `dedupOf`. A referenced-but-absent asset is kept
|
|
* in the plan as `present: false` with a `skipped-media` diagnostic, so the
|
|
* report shows the gap instead of hiding it.
|
|
*
|
|
* The filename sanitize / unique-name helpers are small reimplementations of the
|
|
* app export planner's logic (independent by design — see plan).
|
|
*
|
|
* Pure: no IO; asset metadata arrives through the injected source.
|
|
*/
|
|
import type { SpeechAction } from '@openmaic/dsl';
|
|
import type { AssetSource, AssetMeta, CompilerScene } from '../deps';
|
|
import type { AssetKind, AssetPlan, AssetPlanEntry, Diagnostic, VideoTimelineScene } from '../ir';
|
|
|
|
export interface AssetsResult {
|
|
scenes: VideoTimelineScene[];
|
|
plan: AssetPlan;
|
|
diagnostics: Diagnostic[];
|
|
}
|
|
|
|
/** Sanitize one path segment (scene title / element id) into a safe filename part. */
|
|
export function sanitizeFilenamePart(value: string): string {
|
|
const normalized = value
|
|
.normalize('NFKC')
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[\\/:*?"<>|]+/g, '-')
|
|
.replace(/[^\p{L}\p{N}._-]+/gu, '-')
|
|
.replace(/-+/g, '-')
|
|
.replace(/^[.-]+|[.-]+$/g, '');
|
|
return normalized.slice(0, 80) || 'scene';
|
|
}
|
|
|
|
/**
|
|
* File extension for an asset from its `format`/`mimeType`, falling back per
|
|
* kind. The result is sanitized to a bare, traversal-free extension token
|
|
* (alphanumeric, lowercased) so a hostile `format` such as `../../escape` cannot
|
|
* steer the planned zip path outside its directory — the ZIP-writing stage
|
|
* receives only safe extensions.
|
|
*/
|
|
function extension(meta: AssetMeta, fallback: string): string {
|
|
const raw = extensionRaw(meta, fallback);
|
|
const safe = raw
|
|
.toLowerCase()
|
|
.replace(/^\.+/, '')
|
|
.replace(/[^a-z0-9]/g, '');
|
|
return safe || fallback;
|
|
}
|
|
|
|
/** The unsanitized extension candidate from `format` / `mimeType` / fallback. */
|
|
function extensionRaw(meta: AssetMeta, fallback: string): string {
|
|
if (meta.format) return meta.format.replace(/^\./, '');
|
|
const mime = meta.mimeType;
|
|
if (mime) {
|
|
const known: Record<string, string> = {
|
|
'audio/mpeg': 'mp3',
|
|
'audio/mp3': 'mp3',
|
|
'audio/wav': 'wav',
|
|
'audio/webm': 'weba',
|
|
'image/png': 'png',
|
|
'image/jpeg': 'jpg',
|
|
'video/mp4': 'mp4',
|
|
'video/webm': 'webm',
|
|
};
|
|
if (known[mime]) return known[mime];
|
|
const sub = mime.split('/')[1];
|
|
if (sub) return sub;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
/** Planner state: tracks used paths (for collision suffixes) and asset dedup. */
|
|
class AssetPlanner {
|
|
readonly entries: AssetPlanEntry[] = [];
|
|
private readonly usedPaths = new Map<string, number>();
|
|
/** assetId → the first (owner) entry, whose path + presence every later ref inherits. */
|
|
private readonly owner = new Map<string, AssetPlanEntry>();
|
|
|
|
/**
|
|
* Plan one asset reference. Returns the path it maps to and the *authoritative*
|
|
* presence for its `assetId`.
|
|
*
|
|
* Presence is a property of the asset id, not of an individual reference: the
|
|
* first reference to an id decides it, and every later reference (and the
|
|
* caller's segment) inherits that value. This keeps the plan internally
|
|
* consistent even if an {@link AssetSource} returns inconsistent `present` for
|
|
* the same id — otherwise a dedup entry could claim a different presence than
|
|
* its owner.
|
|
*/
|
|
plan(
|
|
assetId: string,
|
|
kind: AssetKind,
|
|
desiredPath: string,
|
|
present: boolean,
|
|
): { path: string; present: boolean } {
|
|
const existing = this.owner.get(assetId);
|
|
if (existing) {
|
|
this.entries.push({
|
|
assetId,
|
|
kind,
|
|
path: existing.path,
|
|
present: existing.present,
|
|
dedupOf: assetId,
|
|
});
|
|
return { path: existing.path, present: existing.present };
|
|
}
|
|
const path = this.unique(desiredPath);
|
|
const entry: AssetPlanEntry = { assetId, kind, path, present };
|
|
this.owner.set(assetId, entry);
|
|
this.entries.push(entry);
|
|
return { path, present };
|
|
}
|
|
|
|
/** Suffix a path (`stem-2.ext`) until it is unique among planned paths. */
|
|
private unique(path: string): string {
|
|
const count = this.usedPaths.get(path) ?? 0;
|
|
this.usedPaths.set(path, count + 1);
|
|
if (count === 0) return path;
|
|
const dot = path.lastIndexOf('.');
|
|
const stem = dot >= 0 ? path.slice(0, dot) : path;
|
|
const ext = dot >= 0 ? path.slice(dot) : '';
|
|
return this.unique(`${stem}-${count + 1}${ext}`);
|
|
}
|
|
}
|
|
|
|
export function planAssets(
|
|
sourceScenes: readonly CompilerScene[],
|
|
timelineScenes: readonly VideoTimelineScene[],
|
|
assetSource: AssetSource,
|
|
): AssetsResult {
|
|
const planner = new AssetPlanner();
|
|
const diagnostics: Diagnostic[] = [];
|
|
|
|
const scenes = timelineScenes.map((scene, index) => {
|
|
const sourceScene = sourceScenes[index];
|
|
const seq = String(scene.index + 1).padStart(3, '0');
|
|
const sceneSlug = `${seq}-${sanitizeFilenamePart(scene.title)}`;
|
|
|
|
// Base frame / packaged HTML — planned for renderable scene bases.
|
|
let base = scene.base;
|
|
if (scene.base.kind === 'slide-snapshot') {
|
|
const { path } = planner.plan(`frame:${scene.id}`, 'frame', `frames/${sceneSlug}.png`, true);
|
|
base = { ...scene.base, assetRef: path };
|
|
} else if (scene.base.kind === 'interactive-html') {
|
|
const { path } = planner.plan(
|
|
scene.base.assetId,
|
|
'html',
|
|
`interactive/${sceneSlug}.html`,
|
|
true,
|
|
);
|
|
base = { ...scene.base, assetRef: path };
|
|
}
|
|
|
|
// Narration audio.
|
|
let speechSeq = 0;
|
|
const narration = scene.narration.map((seg) => {
|
|
speechSeq += 1;
|
|
const action = sourceScene?.actions?.[seg.actionIndex] as SpeechAction | undefined;
|
|
const meta = action ? assetSource.audio(action) : null;
|
|
|
|
if (!meta) {
|
|
if (seg.text.trim()) {
|
|
diagnostics.push({
|
|
severity: 'warn',
|
|
code: 'missing-audio',
|
|
sceneId: scene.id,
|
|
actionId: seg.actionId,
|
|
message: 'Narration has text but no audio asset; will fall back to estimated timing.',
|
|
});
|
|
}
|
|
return seg;
|
|
}
|
|
|
|
const { path, present } = planner.plan(
|
|
meta.id,
|
|
'audio',
|
|
`audio/${sceneSlug}/speech-${String(speechSeq).padStart(3, '0')}.${extension(meta, 'mp3')}`,
|
|
meta.present,
|
|
);
|
|
if (!present) {
|
|
diagnostics.push({
|
|
severity: 'warn',
|
|
code: 'skipped-media',
|
|
sceneId: scene.id,
|
|
actionId: seg.actionId,
|
|
message: `Audio asset "${meta.id}" is referenced but its bytes are unavailable.`,
|
|
});
|
|
}
|
|
return {
|
|
...seg,
|
|
audio: {
|
|
...seg.audio,
|
|
assetId: meta.id,
|
|
present,
|
|
...(present ? { assetRef: path } : {}),
|
|
},
|
|
};
|
|
});
|
|
|
|
// Video media (play_video targets).
|
|
const videos = scene.videos.map((seg) => {
|
|
const meta = sourceScene ? assetSource.media(seg.elementId, sourceScene) : null;
|
|
if (!meta) {
|
|
// No media asset is associated with the element at all (distinct from a
|
|
// referenced asset whose bytes are missing, below). No plan entry — there
|
|
// is no asset id to bundle. The timeline pass already gave it a 0ms
|
|
// 'skipped' dwell so later actions are not shifted.
|
|
diagnostics.push({
|
|
severity: 'warn',
|
|
code: 'skipped-media',
|
|
sceneId: scene.id,
|
|
actionId: seg.actionId,
|
|
message: `No media asset is associated with play_video element "${seg.elementId}".`,
|
|
});
|
|
return { ...seg, present: false, durationSource: 'skipped' as const };
|
|
}
|
|
// A referenced asset: plan an entry either way so a present:false clip is
|
|
// represented structurally (assetId + present on the segment AND an
|
|
// AssetPlanEntry), not only in a free-form diagnostic. The exporter can
|
|
// distinguish "no association" (no assetId) from "referenced but missing"
|
|
// (assetId present, present:false) without parsing messages.
|
|
const { path, present } = planner.plan(
|
|
meta.id,
|
|
'video',
|
|
`media/${sanitizeFilenamePart(seg.elementId)}.${extension(meta, 'mp4')}`,
|
|
meta.present,
|
|
);
|
|
if (!present) {
|
|
diagnostics.push({
|
|
severity: 'warn',
|
|
code: 'skipped-media',
|
|
sceneId: scene.id,
|
|
actionId: seg.actionId,
|
|
message: `Video media "${meta.id}" for element "${seg.elementId}" is referenced but its bytes are unavailable.`,
|
|
});
|
|
return {
|
|
...seg,
|
|
assetId: meta.id,
|
|
present: false,
|
|
durationSource: 'skipped' as const,
|
|
};
|
|
}
|
|
return { ...seg, assetId: meta.id, present: true, assetRef: path };
|
|
});
|
|
|
|
return { ...scene, base, narration, videos };
|
|
});
|
|
|
|
return { scenes, plan: { entries: planner.entries }, diagnostics };
|
|
}
|