ai-chatbot/lib/video-canvas/smart-storyboard.ts

210 lines
5.8 KiB
TypeScript

import type { VideoCanvasSmartStoryboardConfig } from "@/lib/video-canvas/config";
export type VideoCanvasSmartStoryboardShot = {
description: string;
durationSeconds: number;
id: string;
};
export type VideoCanvasSmartStoryboard = {
enabled: boolean;
shots: VideoCanvasSmartStoryboardShot[];
totalDurationSeconds: number;
};
export type VideoCanvasSmartStoryboardValidation = {
emptyShotIndexes: number[];
status: "exact" | "over" | "under";
totalShotDurationSeconds: number;
valid: boolean;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function clampInteger(value: unknown, fallback: number, min: number, max: number) {
const parsed = Number(value);
return Number.isFinite(parsed)
? Math.max(min, Math.min(max, Math.round(parsed)))
: fallback;
}
function normalizeDescription(value: unknown, maximum: number) {
return String(value || "").replace(/\u00a0/g, " ").trim().slice(0, maximum);
}
function interpolate(template: string, variables: Record<string, string | number>) {
return Object.entries(variables).reduce(
(result, [key, value]) =>
result.replaceAll(`{{${key}}}`, String(value)),
template,
);
}
export function supportsVideoCanvasSmartStoryboard(
config: VideoCanvasSmartStoryboardConfig,
modelDescriptor: string,
) {
if (!config.enabled) return false;
const normalizedModel = modelDescriptor.trim().toLowerCase();
return (
normalizedModel.length > 0 &&
config.modelPatterns.some((pattern) =>
normalizedModel.includes(pattern.trim().toLowerCase()),
)
);
}
export function normalizeVideoCanvasSmartStoryboard(
value: unknown,
options: {
config: VideoCanvasSmartStoryboardConfig;
fallbackPrompt?: string;
fallbackTotalDurationSeconds?: number;
},
): VideoCanvasSmartStoryboard {
const { config } = options;
const incoming = isRecord(value) ? value : {};
const fallbackTotalDurationSeconds = clampInteger(
options.fallbackTotalDurationSeconds,
config.minTotalDurationSeconds,
config.minTotalDurationSeconds,
config.maxTotalDurationSeconds,
);
const totalDurationSeconds = clampInteger(
incoming.totalDurationSeconds,
fallbackTotalDurationSeconds,
config.minTotalDurationSeconds,
config.maxTotalDurationSeconds,
);
const incomingShots = Array.isArray(incoming.shots)
? incoming.shots.slice(0, config.maxShots)
: [];
const shots = incomingShots.flatMap((item, index) => {
const shot = isRecord(item) ? item : {};
const description = normalizeDescription(
shot.description,
config.maxDescriptionLength,
);
const durationSeconds = clampInteger(
shot.durationSeconds,
index === 0 ? totalDurationSeconds : config.defaultShotDurationSeconds,
config.minShotDurationSeconds,
config.maxTotalDurationSeconds,
);
return [
{
description,
durationSeconds,
id:
String(shot.id || "").trim().slice(0, 100) ||
`shot-${index + 1}`,
},
];
});
return {
enabled: incoming.enabled === true,
shots:
shots.length > 0
? shots
: [
{
description: normalizeDescription(
options.fallbackPrompt,
config.maxDescriptionLength,
),
durationSeconds: totalDurationSeconds,
id: "shot-1",
},
],
totalDurationSeconds,
};
}
export function getVideoCanvasSmartStoryboard(
draftValues: Record<string, unknown> | undefined,
options: {
config: VideoCanvasSmartStoryboardConfig;
fallbackPrompt?: string;
fallbackTotalDurationSeconds?: number;
},
) {
const params = isRecord(draftValues?.params) ? draftValues.params : {};
return normalizeVideoCanvasSmartStoryboard(
draftValues?.smartStoryboard ?? params.smartStoryboard,
options,
);
}
export function setVideoCanvasSmartStoryboard(
draftValues: Record<string, unknown> | undefined,
storyboard: VideoCanvasSmartStoryboard,
) {
const params = isRecord(draftValues?.params) ? draftValues.params : {};
return {
...(draftValues || {}),
params: {
...params,
smartStoryboard: storyboard,
},
smartStoryboard: storyboard,
};
}
export function validateVideoCanvasSmartStoryboard(
storyboard: VideoCanvasSmartStoryboard,
): VideoCanvasSmartStoryboardValidation {
const emptyShotIndexes = storyboard.shots.flatMap((shot, index) =>
shot.description.trim() ? [] : [index],
);
const totalShotDurationSeconds = storyboard.shots.reduce(
(total, shot) => total + shot.durationSeconds,
0,
);
const status =
totalShotDurationSeconds === storyboard.totalDurationSeconds
? "exact"
: totalShotDurationSeconds > storyboard.totalDurationSeconds
? "over"
: "under";
return {
emptyShotIndexes,
status,
totalShotDurationSeconds,
valid:
!storyboard.enabled ||
(emptyShotIndexes.length === 0 && status === "exact"),
};
}
export function composeVideoCanvasSmartStoryboardPrompt(
prompt: string,
storyboard: VideoCanvasSmartStoryboard,
config: VideoCanvasSmartStoryboardConfig,
) {
const normalizedPrompt = prompt.trim();
if (!storyboard.enabled) return normalizedPrompt;
const validation = validateVideoCanvasSmartStoryboard(storyboard);
if (!validation.valid) return normalizedPrompt;
const shots = storyboard.shots
.map((shot, index) =>
interpolate(config.shotPromptTemplate, {
description: shot.description,
duration: shot.durationSeconds,
index: index + 1,
}).trim(),
)
.filter(Boolean)
.join("\n");
return interpolate(config.promptTemplate, {
prompt: normalizedPrompt,
shotCount: storyboard.shots.length,
shots,
totalDuration: storyboard.totalDurationSeconds,
}).trim();
}