107 lines
3.1 KiB
TypeScript
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 };
|
|
}
|