diff --git a/src/components/atoms/Button.tsx b/src/components/atoms/Button.tsx index 183b3ae..4eba18c 100644 --- a/src/components/atoms/Button.tsx +++ b/src/components/atoms/Button.tsx @@ -10,7 +10,7 @@ function Button({ }) { return ( ); } diff --git a/src/components/atoms/TimeSegmentMarker.tsx b/src/components/atoms/TimeSegmentMarker.tsx index 4a7e300..04e047a 100644 --- a/src/components/atoms/TimeSegmentMarker.tsx +++ b/src/components/atoms/TimeSegmentMarker.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { useLens } from "../../context/TimelineContext"; export default function TimeSegmentMarker({ @@ -5,12 +6,15 @@ export default function TimeSegmentMarker({ endMilliseconds, text, className, + onClick, }: { startMilliseconds: number; endMilliseconds: number | undefined; className: string; text: string; + onClick?: () => void; }) { + const [isClickable, setIsClickable] = useState(false); const { timeToRelative } = useLens(); const relativeWidth = @@ -22,10 +26,30 @@ export default function TimeSegmentMarker({ ? `max(${relativeWidth * 100.0}%, 0.125rem)` : "0.125rem"; + const handleHover = (event: React.MouseEvent) => { + // Only make the marker clickable if there is an onClick handler + // and the user is holding down shift + if (onClick && event.shiftKey) { + setIsClickable(true); + return; + } + + setIsClickable(false); + }; + + const handleClick = () => { + if (isClickable && onClick) { + onClick(); + } + }; + return (
{ + // handle events from the video element that change the state + const handlePlay = () => setIsPlaying(true); + const handlePause = () => setIsPlaying(false); + + video?.addEventListener("play", handlePlay); + video?.addEventListener("pause", handlePause); + }, [video]); + const play = () => { if (video) { video.play().then(() => { diff --git a/src/components/molecules/SelectedCutsDialog.stories.tsx b/src/components/molecules/ClipSelectionDialog.stories.tsx similarity index 67% rename from src/components/molecules/SelectedCutsDialog.stories.tsx rename to src/components/molecules/ClipSelectionDialog.stories.tsx index 6bb71e1..76f7455 100644 --- a/src/components/molecules/SelectedCutsDialog.stories.tsx +++ b/src/components/molecules/ClipSelectionDialog.stories.tsx @@ -1,10 +1,10 @@ import { action } from "@storybook/addon-actions"; -import SelectedCutsDialog from "./SelectedCutsDialog"; +import ClipSelectionDialog from "./ClipSelectionDialog"; export default { title: "Molecules/SelectedCutsDialog", - component: SelectedCutsDialog, + component: ClipSelectionDialog, tags: ["molecules"], decorators: [ (story: () => React.ReactNode) => ( @@ -15,15 +15,18 @@ export default { export const Default = () => { return ( - { export const MultipleCuts = () => { return ( - { end: 30, }, ]} - onClose={action("onClose")} + onCopyStartTime={action("onCopyStartTime")} + onCopyEndTime={action("onCopyEndTime")} + onExport={action("onExport")} onClear={action("onClear")} onRemove={action("onRemove")} onReorder={action("onReorder")} diff --git a/src/components/molecules/ClipSelectionDialog.tsx b/src/components/molecules/ClipSelectionDialog.tsx new file mode 100644 index 0000000..200960d --- /dev/null +++ b/src/components/molecules/ClipSelectionDialog.tsx @@ -0,0 +1,126 @@ +import Button from "components/atoms/Button"; +import IconButton from "components/atoms/IconButton"; + +import DEFAULT_KEYFRAME_SRC from "assets/logo.svg"; +import { formatMs } from "utils/duration"; + +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; + onClear: () => void; + onExport: () => void; + onRemove: (id: string) => void; + onReorder: (clips: VideoClip[]) => void; + onCopyStartTime: (id: string) => void; + onCopyEndTime: (id: string) => void; +} + +export default function ClipSelectionDialog({ + clips, + show, + onClear, + onExport, + onRemove, + onReorder, + onCopyStartTime, + onCopyEndTime, +}: Props) { + if (!show) { + return null; + } + + const handleRemove = (id: string) => { + onRemove(id); + }; + + const handleReorder = (index: number, direction: "up" | "down") => { + const newClips = [...clips]; + const [removed] = newClips.splice(index, 1); + newClips.splice(direction === "up" ? index - 1 : index + 1, 0, removed); + onReorder(newClips); + }; + + return ( +
+
+

Selected Clips

+ + + +
+
    + {clips.map((clip, index) => ( +
  • + {`Keyframe +
    + {formatMs(clip.start)} - {formatMs(clip.end)} +
    +
    + handleRemove(clip.id)} + icon="close" + title="Remove" + /> + handleReorder(index, "up")} + disabled={index === 0} + icon="move_up" + title="Move up" + /> + handleReorder(index, "down")} + disabled={index === clips.length - 1} + icon="move_down" + title="Move down" + /> + onCopyStartTime(clip.id)} + icon="line_start_square" + title="Copy start time from playhead" + /> + onCopyEndTime(clip.id)} + icon="line_end_square" + title="Copy end time from playhead" + /> +
    +
  • + ))} +
+
+ ); +} diff --git a/src/components/molecules/SelectedCutsDialog.tsx b/src/components/molecules/SelectedCutsDialog.tsx deleted file mode 100644 index 35711cf..0000000 --- a/src/components/molecules/SelectedCutsDialog.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import Button from "components/atoms/Button"; -import IconButton from "components/atoms/IconButton"; - -import DEFAULT_KEYFRAME_SRC from "assets/logo.svg"; - -type Cut = { - id: string; - start: number; - end: number; - keyframeSrc?: string; -}; - -interface Props { - cuts: Cut[]; - onClose: () => void; - onClear: () => void; - onRemove: (id: string) => void; - onReorder: (cuts: Cut[]) => void; -} - -export default function SelectedCutsDialog({ - cuts, - onClose, - onClear, - onRemove, - onReorder, -}: Props) { - const handleRemove = (id: string) => { - onRemove(id); - }; - - const handleReorder = (index: number, direction: "up" | "down") => { - const newCuts = [...cuts]; - const [removed] = newCuts.splice(index, 1); - newCuts.splice(direction === "up" ? index - 1 : index + 1, 0, removed); - onReorder(newCuts); - }; - - return ( -
-
-

Selected Cuts

- - -
-
    - {cuts.map((cut, index) => ( -
  • - {`Keyframe -
    - Cut {cut.id}: {cut.start}s - {cut.end}s -
    -
    - handleRemove(cut.id)} - icon="close" - title="Remove" - /> - handleReorder(index, "up")} - disabled={index === 0} - icon="arrow_upward" - title="Move up" - /> - handleReorder(index, "down")} - disabled={index === cuts.length - 1} - icon="arrow_downward" - title="Move down" - /> -
    -
  • - ))} -
-
- ); -} diff --git a/src/components/molecules/TimeTable.tsx b/src/components/molecules/TimeTable.tsx index 6ec5474..6c0332c 100644 --- a/src/components/molecules/TimeTable.tsx +++ b/src/components/molecules/TimeTable.tsx @@ -1,3 +1,4 @@ +import Button from "components/atoms/Button"; import type { Section } from "types"; import TimeLink from "../atoms/TimeLink"; @@ -7,35 +8,41 @@ function TimeTable({ includeReasoning = false, includeCategory = false, canEdit = false, + canClip = false, playheadTime, onSeekToTime, + onClip, }: { rows: Section[]; includeEnd?: boolean; includeReasoning?: boolean; includeCategory?: boolean; canEdit?: boolean; + canClip?: boolean; playheadTime?: number; onSeekToTime?: (milliseconds: number) => void; + onClip?: (section: Section) => void; }) { return (
- - {includeEnd && } + {includeEnd && ( + + )} {includeCategory && ( - + )} - + {includeReasoning && ( - + )} - + @@ -71,20 +78,23 @@ function TimeTable({ )} ))} diff --git a/src/components/molecules/TimelineElement.tsx b/src/components/molecules/TimelineElement.tsx index e328923..2190e86 100644 --- a/src/components/molecules/TimelineElement.tsx +++ b/src/components/molecules/TimelineElement.tsx @@ -8,9 +8,11 @@ import { interface TimelineElementProps { content: TimelineItem; + onClick: () => void; } export default function TimelineElement({ content: { startMilliseconds, endMilliseconds, type, text }, + onClick, }: TimelineElementProps) { if (type === "chat") { return ( @@ -36,6 +38,7 @@ export default function TimelineElement({ return ( void; + togglePlay: () => void; } export default forwardRef( @@ -43,6 +44,16 @@ export default forwardRef( video.currentTime = milliseconds / 1000; } }, + + togglePlay: () => { + if (video) { + if (video.paused) { + video.play(); + } else { + video.pause(); + } + } + }, }), [video], ); diff --git a/src/components/organisms/Timeline.tsx b/src/components/organisms/Timeline.tsx index 5fd08e6..682c751 100644 --- a/src/components/organisms/Timeline.tsx +++ b/src/components/organisms/Timeline.tsx @@ -6,7 +6,7 @@ import { import TimelineElement from "components/molecules/TimelineElement"; import { useLens } from "context/TimelineContext"; import { useEffect, useRef, useState } from "react"; -import type { VideoMetadata } from "types"; +import type { Section, VideoMetadata } from "types"; import { createTimeline, generateKey } from "utils/timeline"; export default function Timeline({ @@ -19,10 +19,12 @@ export default function Timeline({ }, playheadTime, onSeekToTime, + onItemSelect, }: { content: VideoMetadata; playheadTime: number; onSeekToTime?: (time: number) => void; + onItemSelect?: (item: Section) => void; }) { const containerRef = useRef(null); const lens = useLens(); @@ -72,34 +74,39 @@ export default function Timeline({ }, [lens]); const timeline = createTimeline([ - ...silences.map((silence) => ({ + ...silences.map((silence, index) => ({ type: "silence" as TimelineElementType, startMilliseconds: silence.timestamp, endMilliseconds: silence.timestamp_end && silence.timestamp_end, text: silence.description || "", + originIndex: index, })), - ...highlights.map((highlight) => ({ + ...highlights.map((highlight, index) => ({ type: "highlight" as TimelineElementType, startMilliseconds: highlight.timestamp, endMilliseconds: highlight.timestamp_end && highlight.timestamp_end, text: highlight.description || "", + originIndex: index, })), - ...attentions.map((attention) => ({ + ...attentions.map((attention, index) => ({ type: "attention" as TimelineElementType, startMilliseconds: attention.timestamp, endMilliseconds: attention.timestamp_end && attention.timestamp_end, text: attention.description || "", + originIndex: index, })), - ...transcription_errors.map((error) => ({ + ...transcription_errors.map((error, index) => ({ type: "error" as TimelineElementType, startMilliseconds: error.timestamp, endMilliseconds: error.timestamp_end && error.timestamp_end, text: error.description || "", + originIndex: index, })), - ...chat_history.map((chat) => ({ + ...chat_history.map((chat, index) => ({ type: "chat" as TimelineElementType, startMilliseconds: chat.timestamp, text: chat.username, + originIndex: index, })), ]); const elements = lens.getVisibleElements(timeline); @@ -148,6 +155,13 @@ export default function Timeline({ return; } + /** + * If the user is holding down the shift key, don't seek to the time. + */ + if (event.shiftKey) { + return; + } + const containerPixelWidth = containerRef.current.clientWidth; const offsetXInContainer = event.pageX - containerRef.current.offsetLeft; @@ -162,16 +176,47 @@ export default function Timeline({ return (
{elements.map((content) => ( - + { + if (onItemSelect) { + let source: Section[]; + + switch (content.type) { + case "silence": + source = silences; + break; + case "highlight": + source = highlights; + break; + case "attention": + source = attentions; + break; + case "error": + source = transcription_errors; + break; + case "chat": + source = chat_history; + break; + default: + throw new Error("Invalid type"); + } + + onItemSelect(source[content.originIndex] as Section); + } + }} + /> ))} {}} startMilliseconds={playheadTime} endMilliseconds={undefined} text="" diff --git a/src/components/pages/VideoSelectionPage.stories.tsx b/src/components/pages/VideoSelectionPage.stories.tsx index 732ed50..9bc1321 100644 --- a/src/components/pages/VideoSelectionPage.stories.tsx +++ b/src/components/pages/VideoSelectionPage.stories.tsx @@ -1,3 +1,4 @@ +import { action } from "@storybook/addon-actions"; import VideoSelectionPage from "./VideoSelectionPage"; export default { @@ -11,15 +12,21 @@ export const Default = { content: { title: "Test Video", video_url: "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8", - - length: 1000 * 60 * 60, + // 10 minutes, 34 seconds, in milliseconds + length: 634000, highlights: [ { timestamp: 0, - timestamp_end: 1000 * 60 * 60, + timestamp_end: 10000, description: "This is a test highlight", reasoning: "This is the reasoning for the highlight", }, + { + timestamp: 20000, + timestamp_end: 30000, + description: "This is another test highlight", + reasoning: "This is the reasoning for the highlight", + }, ], attentions: [], transcription_errors: [], @@ -27,5 +34,6 @@ export const Default = { chat_history: [], transcript: [], }, + onExport: action("Export"), }, }; diff --git a/src/components/pages/VideoSelectionPage.tsx b/src/components/pages/VideoSelectionPage.tsx index a7e594e..d0c276b 100644 --- a/src/components/pages/VideoSelectionPage.tsx +++ b/src/components/pages/VideoSelectionPage.tsx @@ -1,4 +1,7 @@ import Heading from "components/atoms/Heading"; +import ClipSelectionDialog, { + type VideoClip, +} from "components/molecules/ClipSelectionDialog"; import EditableTimestampedEventLog from "components/molecules/EditableTimestampedEventLog"; import TimeTable from "components/molecules/TimeTable"; import TimelineControls from "components/molecules/TimelineControls"; @@ -12,32 +15,108 @@ import Timeline from "components/organisms/Timeline"; import { TimelineProvider } from "context/TimelineContext"; import useKeyboardShortcuts from "hooks/useKeyboardShortcuts"; import { useRef, useState } from "react"; -import type { ChatMessage, TranscriptSegment, VideoMetadata } from "types"; +import type { + ChatMessage, + Section, + TranscriptSegment, + VideoMetadata, +} from "types"; interface VideoSelectionPageProps { content: VideoMetadata; + onExport?: (clips: VideoClip[]) => void; } -function VideoSelectionPage({ content }: VideoSelectionPageProps) { +function VideoSelectionPage({ content, onExport }: VideoSelectionPageProps) { const [playheadTime, setPlayheadTime] = useState(0); const [followPlayback, setFollowPlayback] = useState(true); const videoPlayerRef = useRef(null); + const [selectedClips, setSelectedClips] = useState([]); function handleSeekToTime(milliseconds: number) { videoPlayerRef.current?.seekTo(milliseconds); } + function appendSectionToClips(section: Section) { + const clip: VideoClip = { + id: section.timestamp.toString(), + start: section.timestamp, + end: section.timestamp_end || section.timestamp + 10000, + }; + + setSelectedClips((prevClips) => { + const prevClipsWithoutNew = prevClips.filter( + (prevClip) => prevClip.id !== clip.id, + ); + + return [...prevClipsWithoutNew, clip]; + }); + } + + function handleCopyTime(startOrEnd: "start" | "end") { + return (id: string) => { + setSelectedClips((clips) => + clips.map((clip) => { + if (clip.id === id) { + return { ...clip, [startOrEnd]: playheadTime }; + } + return clip; + }), + ); + }; + } + useKeyboardShortcuts({ "1": () => { if (videoPlayerRef.current) { videoPlayerRef.current.seekTo(0); } }, + " ": () => { + if (videoPlayerRef.current) { + videoPlayerRef.current.togglePlay(); + } + }, + ArrowLeft: () => { + if (videoPlayerRef.current) { + videoPlayerRef.current.seekTo(playheadTime - 250); + } + }, + ArrowRight: () => { + if (videoPlayerRef.current) { + videoPlayerRef.current.seekTo(playheadTime + 250); + } + }, + "Shift+ArrowLeft": () => { + if (videoPlayerRef.current) { + videoPlayerRef.current.seekTo(playheadTime - 1000); + } + }, + "Shift+ArrowRight": () => { + if (videoPlayerRef.current) { + videoPlayerRef.current.seekTo(playheadTime + 1000); + } + }, }); return (
+ 0} + clips={selectedClips} + onExport={() => { + onExport?.(selectedClips); + }} + onClear={() => setSelectedClips([])} + onReorder={(newClips) => setSelectedClips(newClips)} + onRemove={(id) => + setSelectedClips(selectedClips.filter((clip) => clip.id !== id)) + } + onCopyStartTime={handleCopyTime("start")} + onCopyEndTime={handleCopyTime("end")} + /> + } transcript={ @@ -117,6 +197,8 @@ function VideoSelectionPage({ content }: VideoSelectionPageProps) { includeEnd includeReasoning canEdit + canClip + onClip={appendSectionToClips} /> diff --git a/src/hooks/useKeyboardShortcuts.tsx b/src/hooks/useKeyboardShortcuts.tsx index ef6a6a4..f93b615 100644 --- a/src/hooks/useKeyboardShortcuts.tsx +++ b/src/hooks/useKeyboardShortcuts.tsx @@ -7,7 +7,14 @@ interface KeyActionMap { const useKeyboardShortcuts = (keyActionMap: KeyActionMap) => { useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { - const key = `${event.ctrlKey ? "Ctrl+" : ""}${event.key}`; + const keyParts = [ + event.ctrlKey ? "Ctrl" : "", + event.shiftKey ? "Shift" : "", + event.altKey ? "Alt" : "", + event.metaKey ? "Meta" : "", + event.key, + ].filter((part) => part !== ""); + const key = `${keyParts.join("+")}`; if (keyActionMap[key]) { event.preventDefault(); keyActionMap[key](event); diff --git a/src/utils/timeline.ts b/src/utils/timeline.ts index 82316fb..93089cc 100644 --- a/src/utils/timeline.ts +++ b/src/utils/timeline.ts @@ -25,6 +25,7 @@ export interface TimelineItem { startMilliseconds: number; endMilliseconds?: number; text: string; + originIndex: number; } /** diff --git a/tailwind.config.mjs b/tailwind.config.mjs index 9f2b2f4..b870489 100644 --- a/tailwind.config.mjs +++ b/tailwind.config.mjs @@ -10,6 +10,16 @@ export default { fontFamily: { body: ["Nunito"], }, + + animation: { + "highlight-new": "highlight-new 1s ease-in-out", + }, + keyframes: { + "highlight-new": { + "0%, 100%": { backgroundColor: "transparent" }, + "25%": { backgroundColor: "rgba(255, 255, 0, 0.8)" }, + }, + }, }, }, plugins: [tailwindcssAnimate], diff --git a/tsconfig.json b/tsconfig.json index a6ccf80..73d65ba 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,8 +22,7 @@ "types": [ "vite/client", "vitest/globals", - "@testing-library/jest-dom", - "vite-plugin-svgr/client" + "@testing-library/jest-dom" ], }, "include": [ @@ -32,4 +31,4 @@ "exclude": [ "node_modules" ] -} +} \ No newline at end of file
+ {includeEnd ? "Start Time" : "Time"} End TimeEnd TimeCategoryCategoryDescriptionDescriptionReasoningReasoningActionsActions
{canEdit && ( - + + )} + + {canClip && ( + )} - +