Skip to content

Commit 5ae03ce

Browse files
authored
chore: clip dialogue window (#37576)
1 parent cce9706 commit 5ae03ce

File tree

7 files changed

+245
-77
lines changed

7 files changed

+245
-77
lines changed

bin/check_video_deps

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
#!/bin/bash
2+
3+
set -e
4+
5+
# Check if we're in a flox environment
6+
if ! command -v flox &> /dev/null; then
7+
echo "Error: flox not found. This script is optimized for flox environments."
8+
exit 1
9+
fi
10+
11+
# Check if Playwright is installed
12+
if ! command -v playwright &> /dev/null; then
13+
echo "Installing Playwright via flox..."
14+
flox install playwright
15+
# Install Playwright browsers
16+
flox activate -- playwright install
17+
else
18+
echo "Playwright is already installed"
19+
fi
20+
21+
# Check if FFmpeg is installed
22+
if ! command -v ffmpeg &> /dev/null; then
23+
echo "Installing FFmpeg via flox..."
24+
flox install ffmpeg
25+
else
26+
echo "FFmpeg is already installed"
27+
fi
28+
29+
echo "Video dependencies check complete"

bin/mprocs.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ procs:
4141
shell: 'bin/check_kafka_clickhouse_up && bin/check_temporal_up && python manage.py start_temporal_worker --task-queue billing-task-queue --metrics-port 8008'
4242

4343
temporal-worker-video-export:
44-
shell: 'bin/check_kafka_clickhouse_up && bin/check_temporal_up && python manage.py start_temporal_worker --task-queue video-export-task-queue --metrics-port 8009'
44+
shell: 'bin/check_video_deps && bin/check_kafka_clickhouse_up && bin/check_temporal_up && python manage.py start_temporal_worker --task-queue video-export-task-queue --metrics-port 8009'
4545

4646
dagster:
4747
shell: |-

frontend/src/lib/constants.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,6 @@ export const FEATURE_FLAGS = {
296296
MAX_SESSION_SUMMARIZATION: 'max-session-summarization', // owner: #team-max-ai
297297
CDP_NEW_PRICING: 'cdp-new-pricing', // owner: #team-messaging
298298
IMPROVED_COOKIELESS_MODE: 'improved-cookieless-mode', // owner: @robbie-c #team-web-analytics
299-
REPLAY_EXPORT_SHORT_VIDEO: 'replay-export-short-video', // owner: @veryayskiy #team-replay
300299
REPLAY_EXPORT_FULL_VIDEO: 'replay-export-full-video', // owner: @veryayskiy #team-replay
301300
LLM_ANALYTICS_DATASETS: 'llm-analytics-datasets', // owner: #team-llm-analytics #team-max-ai
302301
AMPLITUDE_BATCH_IMPORT_OPTIONS: 'amplitude-batch-import-options', // owner: #team-ingestion

frontend/src/scenes/session-recordings/player/SessionRecordingPlayer.tsx

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,11 @@ import { PlayerFrameCommentOverlay } from 'scenes/session-recordings/player/comm
1818
import { MatchingEventsMatchType } from 'scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic'
1919
import { urls } from 'scenes/urls'
2020

21-
import { ExporterFormat } from '~/types'
22-
2321
import { PlayerFrame } from './PlayerFrame'
2422
import { PlayerFrameOverlay } from './PlayerFrameOverlay'
2523
import { PlayerSidebar } from './PlayerSidebar'
2624
import { SessionRecordingNextConfirmation } from './SessionRecordingNextConfirmation'
25+
import { ClipOverlay } from './controller/ClipRecording'
2726
import { PlayerController } from './controller/PlayerController'
2827
import { PlayerMeta } from './player-meta/PlayerMeta'
2928
import { playerSettingsLogic } from './playerSettingsLogic'
@@ -101,12 +100,16 @@ export function SessionRecordingPlayer(props: SessionRecordingPlayerProps): JSX.
101100
} = useActions(sessionRecordingPlayerLogic(logicProps))
102101
const { isNotFound, isRecentAndInvalid, isLikelyPastTTL } = useValues(sessionRecordingDataLogic(logicProps))
103102
const { loadSnapshots } = useActions(sessionRecordingDataLogic(logicProps))
104-
const { isFullScreen, explorerMode, isBuffering, isCommenting, quickEmojiIsOpen } = useValues(
105-
sessionRecordingPlayerLogic(logicProps)
106-
)
107-
const { setPlayNextAnimationInterrupted, setIsCommenting, takeScreenshot, setQuickEmojiIsOpen } = useActions(
103+
const { isFullScreen, explorerMode, isBuffering, isCommenting, quickEmojiIsOpen, showingClipParams } = useValues(
108104
sessionRecordingPlayerLogic(logicProps)
109105
)
106+
const {
107+
setPlayNextAnimationInterrupted,
108+
setIsCommenting,
109+
takeScreenshot,
110+
setQuickEmojiIsOpen,
111+
setShowingClipParams,
112+
} = useActions(sessionRecordingPlayerLogic(logicProps))
110113
const speedHotkeys = useMemo(() => createPlaybackSpeedKey(setSpeed), [setSpeed])
111114
const { isVerticallyStacked, sidebarOpen, isCinemaMode } = useValues(playerSettingsLogic)
112115
const { setIsCinemaMode } = useActions(playerSettingsLogic)
@@ -168,10 +171,10 @@ export function SessionRecordingPlayer(props: SessionRecordingPlayerProps): JSX.
168171
action: () => setQuickEmojiIsOpen(!quickEmojiIsOpen),
169172
},
170173
s: {
171-
action: () => takeScreenshot(ExporterFormat.PNG),
174+
action: () => takeScreenshot(),
172175
},
173176
x: {
174-
action: () => takeScreenshot(ExporterFormat.GIF),
177+
action: () => setShowingClipParams(!showingClipParams),
175178
},
176179
t: {
177180
action: () => setIsCinemaMode(!isCinemaMode),
@@ -307,6 +310,7 @@ export function SessionRecordingPlayer(props: SessionRecordingPlayerProps): JSX.
307310
<>
308311
<PlayerFrameOverlay />
309312
<PlayerFrameCommentOverlay />
313+
<ClipOverlay />
310314
</>
311315
) : null}
312316
</div>
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { useActions, useValues } from 'kea'
2+
import { useState } from 'react'
3+
4+
import { LemonButton, LemonSegmentedButton, LemonTag } from '@posthog/lemon-ui'
5+
6+
import { LemonSegmentedSelect } from 'lib/lemon-ui/LemonSegmentedSelect'
7+
import { IconRecordingClip } from 'lib/lemon-ui/icons'
8+
import { colonDelimitedDuration } from 'lib/utils'
9+
import { sessionRecordingPlayerLogic } from 'scenes/session-recordings/player/sessionRecordingPlayerLogic'
10+
11+
import { KeyboardShortcut } from '~/layout/navigation-3000/components/KeyboardShortcut'
12+
import { ExporterFormat } from '~/types'
13+
14+
interface ClipTimes {
15+
current: string
16+
startClip: string
17+
endClip: string
18+
}
19+
20+
function calculateClipTimes(currentTimeMs: number | null, sessionDurationMs: number, clipDuration: number): ClipTimes {
21+
const startTimeSeconds = (currentTimeMs ?? 0) / 1000
22+
const endTimeSeconds = Math.floor(sessionDurationMs / 1000)
23+
const fixedUnits = endTimeSeconds > 3600 ? 3 : 2
24+
25+
const current = colonDelimitedDuration(startTimeSeconds, fixedUnits)
26+
27+
// Calculate ideal start/end centered around current time
28+
let idealStart = startTimeSeconds - clipDuration / 2
29+
let idealEnd = startTimeSeconds + clipDuration / 2
30+
31+
// Adjust if we hit the beginning boundary
32+
if (idealStart < 0) {
33+
idealStart = 0
34+
idealEnd = Math.min(clipDuration, endTimeSeconds)
35+
}
36+
37+
// Adjust if we hit the end boundary
38+
if (idealEnd > endTimeSeconds) {
39+
idealEnd = endTimeSeconds
40+
idealStart = Math.max(0, endTimeSeconds - clipDuration)
41+
}
42+
43+
const startClip = colonDelimitedDuration(idealStart, fixedUnits)
44+
const endClip = colonDelimitedDuration(idealEnd, fixedUnits)
45+
46+
return { current, startClip, endClip }
47+
}
48+
49+
export function ClipOverlay(): JSX.Element | null {
50+
const { currentPlayerTime, sessionPlayerData, showingClipParams } = useValues(sessionRecordingPlayerLogic)
51+
const { getClip, setShowingClipParams } = useActions(sessionRecordingPlayerLogic)
52+
const [duration, setDuration] = useState(5)
53+
const [format, setFormat] = useState(ExporterFormat.MP4)
54+
55+
const { current, startClip, endClip } = calculateClipTimes(
56+
currentPlayerTime,
57+
sessionPlayerData.durationMs,
58+
duration
59+
)
60+
61+
if (!showingClipParams) {
62+
return null
63+
}
64+
65+
return (
66+
<div className="absolute bottom-4 right-4 z-20 w-64 space-y-3 p-2 bg-primary border border-border rounded shadow-lg">
67+
<div className="space-y-1 text-center">
68+
<div className="text-sm font-medium text-default">
69+
Clipping from {startClip} to {endClip}
70+
</div>
71+
<div className="text-muted">(centered around {current})</div>
72+
</div>
73+
<div className="space-y-1">
74+
<label className="block text-sm font-medium text-default">Format</label>
75+
<LemonSegmentedButton
76+
fullWidth
77+
size="xsmall"
78+
value={format}
79+
onChange={(value) => setFormat(value)}
80+
options={[
81+
{
82+
value: ExporterFormat.MP4,
83+
label: 'MP4',
84+
tooltip: 'Video file - higher quality, better for detailed analysis',
85+
'data-attr': 'replay-screenshot-mp4',
86+
},
87+
{
88+
value: ExporterFormat.GIF,
89+
label: 'GIF',
90+
tooltip: 'Animated GIF - smaller file size, good for sharing',
91+
'data-attr': 'replay-screenshot-gif',
92+
},
93+
]}
94+
/>
95+
</div>
96+
97+
<div className="space-y-1">
98+
<label className="block text-sm font-medium text-default">Duration (seconds)</label>
99+
<LemonSegmentedSelect
100+
fullWidth
101+
size="xsmall"
102+
options={[
103+
{ value: 5, label: '5', 'data-attr': 'replay-clip-duration-5' },
104+
{ value: 10, label: '10', 'data-attr': 'replay-clip-duration-10' },
105+
{ value: 15, label: '15', 'data-attr': 'replay-clip-duration-15' },
106+
]}
107+
value={duration}
108+
onChange={(value) => setDuration(value)}
109+
/>
110+
</div>
111+
112+
<LemonButton
113+
onClick={() => {
114+
getClip(format, duration)
115+
setShowingClipParams(false)
116+
}}
117+
type="primary"
118+
className="mt-3 mx-auto"
119+
disabledReason={
120+
!duration || duration < 5 || duration > 15 ? 'Duration must be between 5 and 15 seconds' : undefined
121+
}
122+
>
123+
Create clip
124+
</LemonButton>
125+
</div>
126+
)
127+
}
128+
129+
export function ClipRecording(): JSX.Element {
130+
const { showingClipParams, currentPlayerTime, sessionPlayerData } = useValues(sessionRecordingPlayerLogic)
131+
const { setPause, setShowingClipParams } = useActions(sessionRecordingPlayerLogic)
132+
133+
const { current } = calculateClipTimes(currentPlayerTime, sessionPlayerData.durationMs, 5)
134+
135+
return (
136+
<LemonButton
137+
size="xsmall"
138+
active={showingClipParams}
139+
onClick={() => {
140+
setPause()
141+
setShowingClipParams(!showingClipParams)
142+
}}
143+
tooltip={
144+
<div className="flex items-center gap-2">
145+
<span>
146+
Create clip around {current} <KeyboardShortcut x />
147+
</span>
148+
<LemonTag type="warning" size="small">
149+
BETA
150+
</LemonTag>
151+
</div>
152+
}
153+
icon={<IconRecordingClip className="text-xl" />}
154+
data-attr="replay-clip"
155+
tooltipPlacement="top"
156+
/>
157+
)
158+
}

frontend/src/scenes/session-recordings/player/controller/PlayerController.tsx

Lines changed: 5 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,8 @@ import { useActions, useValues } from 'kea'
33
import { IconCamera, IconPause, IconPlay, IconRewindPlay, IconVideoCamera } from '@posthog/icons'
44
import { LemonButton, LemonTag } from '@posthog/lemon-ui'
55

6-
import { FEATURE_FLAGS } from 'lib/constants'
76
import { useResizeBreakpoints } from 'lib/hooks/useResizeObserver'
8-
import { IconFullScreen, IconRecordingClip } from 'lib/lemon-ui/icons'
9-
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
7+
import { IconFullScreen } from 'lib/lemon-ui/icons'
108
import { PlayerUpNext } from 'scenes/session-recordings/player/PlayerUpNext'
119
import { CommentOnRecordingButton } from 'scenes/session-recordings/player/commenting/CommentOnRecordingButton'
1210
import {
@@ -15,9 +13,10 @@ import {
1513
} from 'scenes/session-recordings/player/sessionRecordingPlayerLogic'
1614

1715
import { KeyboardShortcut } from '~/layout/navigation-3000/components/KeyboardShortcut'
18-
import { ExporterFormat, SessionPlayerState } from '~/types'
16+
import { SessionPlayerState } from '~/types'
1917

2018
import { playerSettingsLogic } from '../playerSettingsLogic'
19+
import { ClipRecording } from './ClipRecording'
2120
import { SeekSkip, Timestamp } from './PlayerControllerTime'
2221
import { Seekbar } from './Seekbar'
2322

@@ -98,37 +97,13 @@ function CinemaMode(): JSX.Element {
9897
)
9998
}
10099

101-
function Clip(): JSX.Element {
102-
const { takeScreenshot } = useActions(sessionRecordingPlayerLogic)
103-
104-
return (
105-
<LemonButton
106-
size="xsmall"
107-
onClick={() => takeScreenshot(ExporterFormat.GIF)}
108-
tooltip={
109-
<div className="flex items-center gap-2">
110-
<span>
111-
Get a GIF from now -2.5s to now +2.5s <KeyboardShortcut x />
112-
</span>
113-
<LemonTag type="warning" size="small">
114-
BETA
115-
</LemonTag>
116-
</div>
117-
}
118-
icon={<IconRecordingClip className="text-xl" />}
119-
data-attr="replay-screenshot-gif"
120-
tooltipPlacement="top"
121-
/>
122-
)
123-
}
124-
125100
function Screenshot(): JSX.Element {
126101
const { takeScreenshot } = useActions(sessionRecordingPlayerLogic)
127102

128103
return (
129104
<LemonButton
130105
size="xsmall"
131-
onClick={() => takeScreenshot(ExporterFormat.PNG)}
106+
onClick={takeScreenshot}
132107
tooltip={
133108
<>
134109
Take a screenshot of this point in the recording <KeyboardShortcut s />
@@ -144,7 +119,6 @@ function Screenshot(): JSX.Element {
144119
export function PlayerController(): JSX.Element {
145120
const { playlistLogic, logicProps } = useValues(sessionRecordingPlayerLogic)
146121
const { isCinemaMode } = useValues(playerSettingsLogic)
147-
const { featureFlags } = useValues(featureFlagLogic)
148122

149123
const playerMode = logicProps.mode ?? SessionRecordingPlayerMode.Standard
150124

@@ -168,7 +142,7 @@ export function PlayerController(): JSX.Element {
168142
<>
169143
<CommentOnRecordingButton />
170144
<Screenshot />
171-
{featureFlags[FEATURE_FLAGS.REPLAY_EXPORT_SHORT_VIDEO] && <Clip />}
145+
<ClipRecording />
172146
{playlistLogic ? <PlayerUpNext playlistLogic={playlistLogic} /> : undefined}
173147
</>
174148
)}

0 commit comments

Comments
 (0)