ai-chatbot/lib/video-canvas/video-analysis.ts

107 lines
3.1 KiB
TypeScript

export const DEFAULT_VIDEO_ANALYSIS_COST = 2;
export const VIDEO_STORY_NODE_HEIGHT = 400;
export const VIDEO_STORY_NODE_WIDTH = 800;
export const VIDEO_STORY_SOURCE_GAP = 94;
export type VideoAnalysisNodeBounds = {
height: number;
id?: string;
width: number;
x: number;
y: number;
};
type VideoAnalysisTimelineRow = {
duration: string;
endTime: string;
startTime: string;
};
export function normalizeVideoAnalysisTimeline<T extends VideoAnalysisTimelineRow>(
rows: T[],
totalDurationSeconds?: number | null,
) {
if (
rows.length === 0 ||
!Number.isFinite(totalDurationSeconds) ||
Number(totalDurationSeconds) <= 0
) {
return rows;
}
const total = Number(totalDurationSeconds);
const parsedTimeline = rows.map((row) => ({
start: Number.parseFloat(row.startTime),
end: Number.parseFloat(row.endTime),
}));
const timelineIsValid = parsedTimeline.every((item, index) => {
const previous = parsedTimeline[index - 1];
return (
Number.isFinite(item.start) &&
Number.isFinite(item.end) &&
item.start >= 0 &&
item.end > item.start &&
item.end <= total + 0.05 &&
(!previous || item.start >= previous.end - 0.05)
);
});
const coversDuration =
timelineIsValid &&
Math.abs((parsedTimeline[0]?.start || 0) - 0) <= 0.05 &&
Math.abs((parsedTimeline.at(-1)?.end || 0) - total) <= 0.05;
const formatTime = (number: number) => String(Number(number.toFixed(2)));
return rows.map((row, index) => {
const start = coversDuration
? Math.max(0, parsedTimeline[index]!.start)
: (total * index) / rows.length;
const end = coversDuration
? Math.min(total, parsedTimeline[index]!.end)
: (total * (index + 1)) / rows.length;
return {
...row,
startTime: formatTime(start),
endTime: formatTime(end),
duration: formatTime(Math.max(0, end - start)),
};
});
}
function overlapsWithMargin(
candidate: VideoAnalysisNodeBounds,
existing: VideoAnalysisNodeBounds,
margin: number,
) {
return !(
candidate.x + candidate.width + margin <= existing.x ||
existing.x + existing.width + margin <= candidate.x ||
candidate.y + candidate.height + margin <= existing.y ||
existing.y + existing.height + margin <= candidate.y
);
}
export function getVideoStoryPlacement(
source: VideoAnalysisNodeBounds,
existingNodes: VideoAnalysisNodeBounds[],
) {
const baseY = Math.round(source.y + source.height + VIDEO_STORY_SOURCE_GAP);
const horizontalOffsets = [0, 450, -450, 900, -900];
const occupied = existingNodes.filter((node) => node.id !== source.id);
for (let row = 0; row < 12; row += 1) {
for (const offset of horizontalOffsets) {
const candidate = {
height: VIDEO_STORY_NODE_HEIGHT,
width: VIDEO_STORY_NODE_WIDTH,
x: Math.round(source.x + offset),
y: baseY + row * (VIDEO_STORY_NODE_HEIGHT + VIDEO_STORY_SOURCE_GAP),
};
if (!occupied.some((node) => overlapsWithMargin(candidate, node, 24))) {
return { x: candidate.x, y: candidate.y };
}
}
}
return { x: Math.round(source.x), y: baseY };
}