Skip to content
1 change: 0 additions & 1 deletion rollup.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import json from '@rollup/plugin-json';
import postcss from 'rollup-plugin-postcss';
import postcssImport from 'postcss-import';
import postcssUrl from 'postcss-url';
import copy from 'rollup-plugin-copy';
import tailwind from 'tailwindcss';
import nodeResolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
Expand Down
15 changes: 15 additions & 0 deletions src/components/atoms/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,31 @@ function Button({
children,
variant = "default",
className,
onClick,
...props
}: React.ComponentPropsWithoutRef<"button"> & {
children: React.ReactNode;
className?: string;
onClick?: () => void;
variant?: "primary" | "danger" | "secondary" | "default";
}) {
const variantClass = getVariantClass(variant);

const handleClick = () => {
onClick?.();
};

const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (e.key === "Enter") {
onClick?.();
}
};

return (
<button
onClick={handleClick}
onKeyDown={handleKeyDown}
tabIndex={props.tabIndex ?? 0}
className={`mx-2 rounded px-5 py-3 max-h-12 ${variantClass} disabled:bg-gray-200 disabled:text-gray-400 disabled:cursor-not-allowed disabled:hover:bg-gray-200 disabled:hover:text-gray-400 disabled:active:bg-gray-200 disabled:active:text-gray-400 disabled:dark:bg-gray-700 disabled:dark:text-gray-400 disabled:dark:hover:bg-gray-700 disabled:dark:hover:text-gray-400 disabled:dark:active:bg-gray-700 disabled:dark:active:text-gray-400 ${className}`}
{...props}
>
Expand Down
1 change: 1 addition & 0 deletions src/components/atoms/IconButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ function IconButton({
className?: string;
icon: string;
text?: string;
onClick?: () => void;
variant?: "primary" | "danger" | "secondary" | "default";
}) {
return (
Expand Down
2 changes: 2 additions & 0 deletions src/components/molecules/ClipSelectionDialog.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const Default = () => {
onClear={action("onClear")}
onRemove={action("onRemove")}
onReorder={action("onReorder")}
onSeekToTime={action("onSeekToTime")}
/>
);
};
Expand Down Expand Up @@ -61,6 +62,7 @@ export const MultipleCuts = () => {
onClear={action("onClear")}
onRemove={action("onRemove")}
onReorder={action("onReorder")}
onSeekToTime={action("onSeekToTime")}
/>
);
};
20 changes: 5 additions & 15 deletions src/components/molecules/ClipSelectionDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,9 @@ import Button from "components/atoms/Button";
import IconButton from "components/atoms/IconButton";

import TimeLink from "components/atoms/TimeLink";
import type { VideoClip } from "types";
import DEFAULT_KEYFRAME_SRC from "../../assets/logo.svg";

export type VideoClip = {
id: string;
/**
* Start time in milliseconds
*/
start: number;
/**
* End time in milliseconds
*/
end: number;
keyframeSrc?: string;
};

interface Props {
clips: VideoClip[];
show: boolean;
Expand All @@ -26,6 +14,7 @@ interface Props {
onReorder: (clips: VideoClip[]) => void;
onCopyStartTime: (id: string) => void;
onCopyEndTime: (id: string) => void;
onSeekToTime: (milliseconds: number) => void;
}

export default function ClipSelectionDialog({
Expand All @@ -37,6 +26,7 @@ export default function ClipSelectionDialog({
onReorder,
onCopyStartTime,
onCopyEndTime,
onSeekToTime,
}: Props) {
if (!show) {
return null;
Expand Down Expand Up @@ -82,8 +72,8 @@ export default function ClipSelectionDialog({
className="mr-2"
/>
<div className="flex-grow">
<TimeLink milliseconds={clip.start} />-{" "}
<TimeLink milliseconds={clip.end} />
<TimeLink onClick={onSeekToTime} milliseconds={clip.start} />-{" "}
<TimeLink onClick={onSeekToTime} milliseconds={clip.end} />
</div>
<div className="flex space-x-1">
<IconButton
Expand Down
29 changes: 26 additions & 3 deletions src/components/pages/VideoSelectionPage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import Button from "components/atoms/Button";
import Heading from "components/atoms/Heading";
import ClipSelectionDialog, {
type VideoClip,
} from "components/molecules/ClipSelectionDialog";
import ClipSelectionDialog, {} from "components/molecules/ClipSelectionDialog";
import EditableTimestampedEventLog from "components/molecules/EditableTimestampedEventLog";
import TimeTable from "components/molecules/TimeTable";
import TimelineControls from "components/molecules/TimelineControls";
Expand All @@ -19,8 +18,10 @@ import type {
ChatMessage,
Section,
TranscriptSegment,
VideoClip,
VideoMetadata,
} from "types";
import findGaps from "utils/findGaps";

interface VideoSelectionPageProps {
content: VideoMetadata;
Expand Down Expand Up @@ -115,6 +116,7 @@ function VideoSelectionPage({ content, onExport }: VideoSelectionPageProps) {
}
onCopyStartTime={handleCopyTime("start")}
onCopyEndTime={handleCopyTime("end")}
onSeekToTime={handleSeekToTime}
/>

<Sidebar
Expand Down Expand Up @@ -165,12 +167,15 @@ function VideoSelectionPage({ content, onExport }: VideoSelectionPageProps) {
<EditableTimestampedEventLog<TranscriptSegment>
log={content.transcript}
onChange={(updatedSegment) => {
// TODO
console.log(updatedSegment);
}}
onAdd={(newSegment) => {
// TODO
console.log(newSegment);
}}
onRemove={(segment) => {
// TODO
console.log(segment);
}}
playheadTime={playheadTime}
Expand All @@ -187,6 +192,24 @@ function VideoSelectionPage({ content, onExport }: VideoSelectionPageProps) {
}
/>

<div className="flex justify-end gap-2">
<Button
onClick={() => {
onExport?.(selectedClips);
}}
variant="primary"
>
Export
</Button>
<Button
onClick={async () => {
setSelectedClips(await findGaps(content.silences, 1000));
}}
>
Clip Silences
</Button>
</div>

<Heading level={2} id="highlights">
Highlights
</Heading>
Expand Down
19 changes: 19 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,22 @@ export interface ChatMessage extends LogEvent {
*/
message: string;
}

/**
* A selection within a video.
*/
export type VideoClip = {
id: string;
/**
* Start time in milliseconds
*/
start: number;
/**
* End time in milliseconds
*/
end: number;
/**
* URL to the keyframe image, if available.
*/
keyframeSrc?: string;
};
101 changes: 101 additions & 0 deletions src/utils/findGaps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { Section } from "types";
import { describe, expect, it } from "vitest";
import findGaps from "./findGaps";

describe("findGaps", () => {
it("should find gaps between sections", async () => {
const sections: Section[] = [
{ timestamp: 0, timestamp_end: 10 },
{ timestamp: 20, timestamp_end: 30 },
{ timestamp: 40, timestamp_end: 50 },
];
const minGapDuration = 5;

const gaps = await findGaps(sections, minGapDuration);

expect(gaps).toEqual([
{ id: "10-20", start: 10, end: 20 },
{ id: "30-40", start: 30, end: 40 },
]);
});

it("should return an empty array if no gaps are found", async () => {
const sections: Section[] = [
{ timestamp: 0, timestamp_end: 10 },
{ timestamp: 11, timestamp_end: 20 },
];
const minGapDuration = 5;

const gaps = await findGaps(sections, minGapDuration);

expect(gaps).toEqual([]);
});

it("should handle sections with undefined timestamp_end", async () => {
const sections: Section[] = [
{ timestamp: 0, timestamp_end: 10 },
{ timestamp: 20 },
{ timestamp: 30, timestamp_end: 40 },
];
const minGapDuration = 5;

const gaps = await findGaps(sections, minGapDuration);

expect(gaps).toEqual([{ id: "10-30", start: 10, end: 30 }]);
});

it("should ignore gaps that are less than the minimum gap duration", async () => {
const sections: Section[] = [
{ timestamp: 0, timestamp_end: 10 },
{ timestamp: 15, timestamp_end: 25 },
];
const minGapDuration = 10;

const gaps = await findGaps(sections, minGapDuration);

expect(gaps).toEqual([]);
});

it("should handle an empty array of sections", async () => {
const sections: Section[] = [];
const minGapDuration = 5;

const gaps = await findGaps(sections, minGapDuration);

expect(gaps).toEqual([]);
});

it("should handle a single section", async () => {
const sections: Section[] = [{ timestamp: 0, timestamp_end: 10 }];
const minGapDuration = 5;

const gaps = await findGaps(sections, minGapDuration);

expect(gaps).toEqual([]);
});

it("should find gaps greater than or equal to the minimum gap duration but ignore gaps less than the minimum gap duration", async () => {
const sections: Section[] = [
{ timestamp: 0, timestamp_end: 10 },
{ timestamp: 15, timestamp_end: 25 },
{ timestamp: 35, timestamp_end: 40 },
];
const minGapDuration = 10;

const gaps = await findGaps(sections, minGapDuration);

expect(gaps).toEqual([{ id: "25-35", start: 25, end: 35 }]);
});

it("should handle timestamps that are floats", async () => {
const sections: Section[] = [
{ timestamp: 0, timestamp_end: 10.5 },
{ timestamp: 20.5, timestamp_end: 30.5 },
];
const minGapDuration = 5;

const gaps = await findGaps(sections, minGapDuration);

expect(gaps).toEqual([{ id: "10.5-20.5", start: 10.5, end: 20.5 }]);
});
});
37 changes: 37 additions & 0 deletions src/utils/findGaps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { Section, VideoClip } from "types";

/**
* Finds gaps between video sections that are greater than or equal to the specified minimum gap duration.
*
* @param {Section[]} sections - The list of video sections.
* @param {number} minGapDuration - The minimum duration of a gap to be considered.
* @returns {Promise<VideoClip[]>} - A promise that resolves to an array of video clips representing the gaps.
*/
export default async function findGaps(
sections: Section[],
minGapDuration: number,
): Promise<VideoClip[]> {
const gaps: VideoClip[] = [];

const validSections = sections.filter(
(section): section is Section & { timestamp_end: number } =>
section.timestamp_end !== undefined,
);

for (let i = 0; i < validSections.length - 1; i++) {
const currentSection = validSections[i];
const nextSection = validSections[i + 1];

const gapDuration = nextSection.timestamp - currentSection.timestamp_end;

if (gapDuration >= minGapDuration) {
gaps.push({
id: `${currentSection.timestamp_end}-${nextSection.timestamp}`,
start: currentSection.timestamp_end,
end: nextSection.timestamp,
});
}
}

return gaps;
}
Loading