Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions bin/check_video_deps
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/bin/bash

set -e

# Check if we're in a flox environment
if ! command -v flox &> /dev/null; then
echo "Error: flox not found. This script is optimized for flox environments."
exit 1
fi

# Check if Playwright is installed
if ! command -v playwright &> /dev/null; then
echo "Installing Playwright via flox..."
flox install playwright
# Install Playwright browsers
flox activate -- playwright install
else
echo "Playwright is already installed"
fi

# Check if FFmpeg is installed
if ! command -v ffmpeg &> /dev/null; then
echo "Installing FFmpeg via flox..."
flox install ffmpeg
else
echo "FFmpeg is already installed"
fi

echo "Video dependencies check complete"
2 changes: 1 addition & 1 deletion bin/mprocs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ procs:
shell: 'bin/check_kafka_clickhouse_up && bin/check_temporal_up && python manage.py start_temporal_worker --task-queue billing-task-queue --metrics-port 8008'

temporal-worker-video-export:
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'
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'

dagster:
shell: |-
Expand Down
1 change: 0 additions & 1 deletion frontend/src/lib/constants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,6 @@ export const FEATURE_FLAGS = {
MAX_SESSION_SUMMARIZATION: 'max-session-summarization', // owner: #team-max-ai
CDP_NEW_PRICING: 'cdp-new-pricing', // owner: #team-messaging
IMPROVED_COOKIELESS_MODE: 'improved-cookieless-mode', // owner: @robbie-c #team-web-analytics
REPLAY_EXPORT_SHORT_VIDEO: 'replay-export-short-video', // owner: @veryayskiy #team-replay
REPLAY_EXPORT_FULL_VIDEO: 'replay-export-full-video', // owner: @veryayskiy #team-replay
LLM_ANALYTICS_DATASETS: 'llm-analytics-datasets', // owner: #team-llm-analytics #team-max-ai
AMPLITUDE_BATCH_IMPORT_OPTIONS: 'amplitude-batch-import-options', // owner: #team-ingestion
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,11 @@ import { PlayerFrameCommentOverlay } from 'scenes/session-recordings/player/comm
import { MatchingEventsMatchType } from 'scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic'
import { urls } from 'scenes/urls'

import { ExporterFormat } from '~/types'

import { PlayerFrame } from './PlayerFrame'
import { PlayerFrameOverlay } from './PlayerFrameOverlay'
import { PlayerSidebar } from './PlayerSidebar'
import { SessionRecordingNextConfirmation } from './SessionRecordingNextConfirmation'
import { ClipOverlay } from './controller/ClipRecording'
import { PlayerController } from './controller/PlayerController'
import { PlayerMeta } from './player-meta/PlayerMeta'
import { playerSettingsLogic } from './playerSettingsLogic'
Expand Down Expand Up @@ -101,12 +100,16 @@ export function SessionRecordingPlayer(props: SessionRecordingPlayerProps): JSX.
} = useActions(sessionRecordingPlayerLogic(logicProps))
const { isNotFound, isRecentAndInvalid, isLikelyPastTTL } = useValues(sessionRecordingDataLogic(logicProps))
const { loadSnapshots } = useActions(sessionRecordingDataLogic(logicProps))
const { isFullScreen, explorerMode, isBuffering, isCommenting, quickEmojiIsOpen } = useValues(
sessionRecordingPlayerLogic(logicProps)
)
const { setPlayNextAnimationInterrupted, setIsCommenting, takeScreenshot, setQuickEmojiIsOpen } = useActions(
const { isFullScreen, explorerMode, isBuffering, isCommenting, quickEmojiIsOpen, showingClipParams } = useValues(
sessionRecordingPlayerLogic(logicProps)
)
const {
setPlayNextAnimationInterrupted,
setIsCommenting,
takeScreenshot,
setQuickEmojiIsOpen,
setShowingClipParams,
} = useActions(sessionRecordingPlayerLogic(logicProps))
const speedHotkeys = useMemo(() => createPlaybackSpeedKey(setSpeed), [setSpeed])
const { isVerticallyStacked, sidebarOpen, isCinemaMode } = useValues(playerSettingsLogic)
const { setIsCinemaMode } = useActions(playerSettingsLogic)
Expand Down Expand Up @@ -168,10 +171,10 @@ export function SessionRecordingPlayer(props: SessionRecordingPlayerProps): JSX.
action: () => setQuickEmojiIsOpen(!quickEmojiIsOpen),
},
s: {
action: () => takeScreenshot(ExporterFormat.PNG),
action: () => takeScreenshot(),
},
x: {
action: () => takeScreenshot(ExporterFormat.GIF),
action: () => setShowingClipParams(!showingClipParams),
},
t: {
action: () => setIsCinemaMode(!isCinemaMode),
Expand Down Expand Up @@ -307,6 +310,7 @@ export function SessionRecordingPlayer(props: SessionRecordingPlayerProps): JSX.
<>
<PlayerFrameOverlay />
<PlayerFrameCommentOverlay />
<ClipOverlay />
</>
) : null}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { useActions, useValues } from 'kea'
import { useState } from 'react'

import { LemonButton, LemonSegmentedButton, LemonTag } from '@posthog/lemon-ui'

import { LemonSegmentedSelect } from 'lib/lemon-ui/LemonSegmentedSelect'
import { IconRecordingClip } from 'lib/lemon-ui/icons'
import { colonDelimitedDuration } from 'lib/utils'
import { sessionRecordingPlayerLogic } from 'scenes/session-recordings/player/sessionRecordingPlayerLogic'

import { KeyboardShortcut } from '~/layout/navigation-3000/components/KeyboardShortcut'
import { ExporterFormat } from '~/types'

interface ClipTimes {
current: string
startClip: string
endClip: string
}

function calculateClipTimes(currentTimeMs: number | null, sessionDurationMs: number, clipDuration: number): ClipTimes {
const startTimeSeconds = (currentTimeMs ?? 0) / 1000
const endTimeSeconds = Math.floor(sessionDurationMs / 1000)
const fixedUnits = endTimeSeconds > 3600 ? 3 : 2

const current = colonDelimitedDuration(startTimeSeconds, fixedUnits)

// Calculate ideal start/end centered around current time
let idealStart = startTimeSeconds - clipDuration / 2
let idealEnd = startTimeSeconds + clipDuration / 2

// Adjust if we hit the beginning boundary
if (idealStart < 0) {
idealStart = 0
idealEnd = Math.min(clipDuration, endTimeSeconds)
}

// Adjust if we hit the end boundary
if (idealEnd > endTimeSeconds) {
idealEnd = endTimeSeconds
idealStart = Math.max(0, endTimeSeconds - clipDuration)
}

const startClip = colonDelimitedDuration(idealStart, fixedUnits)
const endClip = colonDelimitedDuration(idealEnd, fixedUnits)

return { current, startClip, endClip }
}

export function ClipOverlay(): JSX.Element | null {
const { currentPlayerTime, sessionPlayerData, showingClipParams } = useValues(sessionRecordingPlayerLogic)
const { getClip, setShowingClipParams } = useActions(sessionRecordingPlayerLogic)
const [duration, setDuration] = useState(5)
const [format, setFormat] = useState(ExporterFormat.MP4)

const { current, startClip, endClip } = calculateClipTimes(
currentPlayerTime,
sessionPlayerData.durationMs,
duration
)

if (!showingClipParams) {
return null
}

return (
<div className="absolute bottom-4 right-4 z-20 w-64 space-y-3 p-2 bg-primary border border-border rounded shadow-lg">
<div className="space-y-1 text-center">
<div className="text-sm font-medium text-default">
Clipping from {startClip} to {endClip}
</div>
<div className="text-muted">(centered around {current})</div>
</div>
<div className="space-y-1">
<label className="block text-sm font-medium text-default">Format</label>
<LemonSegmentedButton
fullWidth
size="xsmall"
value={format}
onChange={(value) => setFormat(value)}
options={[
{
value: ExporterFormat.MP4,
label: 'MP4',
tooltip: 'Video file - higher quality, better for detailed analysis',
'data-attr': 'replay-screenshot-mp4',
},
{
value: ExporterFormat.GIF,
label: 'GIF',
tooltip: 'Animated GIF - smaller file size, good for sharing',
'data-attr': 'replay-screenshot-gif',
},
]}
/>
</div>

<div className="space-y-1">
<label className="block text-sm font-medium text-default">Duration (seconds)</label>
<LemonSegmentedSelect
fullWidth
size="xsmall"
options={[
{ value: 5, label: '5', 'data-attr': 'replay-clip-duration-5' },
{ value: 10, label: '10', 'data-attr': 'replay-clip-duration-10' },
{ value: 15, label: '15', 'data-attr': 'replay-clip-duration-15' },
]}
value={duration}
onChange={(value) => setDuration(value)}
/>
</div>

<LemonButton
onClick={() => {
getClip(format, duration)
setShowingClipParams(false)
}}
type="primary"
className="mt-3 mx-auto"
disabledReason={
!duration || duration < 5 || duration > 15 ? 'Duration must be between 5 and 15 seconds' : undefined
}
>
Create clip
</LemonButton>
</div>
)
}

export function ClipRecording(): JSX.Element {
const { showingClipParams, currentPlayerTime, sessionPlayerData } = useValues(sessionRecordingPlayerLogic)
const { setPause, setShowingClipParams } = useActions(sessionRecordingPlayerLogic)

const { current } = calculateClipTimes(currentPlayerTime, sessionPlayerData.durationMs, 5)

return (
<LemonButton
size="xsmall"
active={showingClipParams}
onClick={() => {
setPause()
setShowingClipParams(!showingClipParams)
}}
tooltip={
<div className="flex items-center gap-2">
<span>
Create clip around {current} <KeyboardShortcut x />
</span>
<LemonTag type="warning" size="small">
BETA
</LemonTag>
</div>
}
icon={<IconRecordingClip className="text-xl" />}
data-attr="replay-clip"
tooltipPlacement="top"
/>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@ import { useActions, useValues } from 'kea'
import { IconCamera, IconPause, IconPlay, IconRewindPlay, IconVideoCamera } from '@posthog/icons'
import { LemonButton, LemonTag } from '@posthog/lemon-ui'

import { FEATURE_FLAGS } from 'lib/constants'
import { useResizeBreakpoints } from 'lib/hooks/useResizeObserver'
import { IconFullScreen, IconRecordingClip } from 'lib/lemon-ui/icons'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { IconFullScreen } from 'lib/lemon-ui/icons'
import { PlayerUpNext } from 'scenes/session-recordings/player/PlayerUpNext'
import { CommentOnRecordingButton } from 'scenes/session-recordings/player/commenting/CommentOnRecordingButton'
import {
Expand All @@ -15,9 +13,10 @@ import {
} from 'scenes/session-recordings/player/sessionRecordingPlayerLogic'

import { KeyboardShortcut } from '~/layout/navigation-3000/components/KeyboardShortcut'
import { ExporterFormat, SessionPlayerState } from '~/types'
import { SessionPlayerState } from '~/types'

import { playerSettingsLogic } from '../playerSettingsLogic'
import { ClipRecording } from './ClipRecording'
import { SeekSkip, Timestamp } from './PlayerControllerTime'
import { Seekbar } from './Seekbar'

Expand Down Expand Up @@ -98,37 +97,13 @@ function CinemaMode(): JSX.Element {
)
}

function Clip(): JSX.Element {
const { takeScreenshot } = useActions(sessionRecordingPlayerLogic)

return (
<LemonButton
size="xsmall"
onClick={() => takeScreenshot(ExporterFormat.GIF)}
tooltip={
<div className="flex items-center gap-2">
<span>
Get a GIF from now -2.5s to now +2.5s <KeyboardShortcut x />
</span>
<LemonTag type="warning" size="small">
BETA
</LemonTag>
</div>
}
icon={<IconRecordingClip className="text-xl" />}
data-attr="replay-screenshot-gif"
tooltipPlacement="top"
/>
)
}

function Screenshot(): JSX.Element {
const { takeScreenshot } = useActions(sessionRecordingPlayerLogic)

return (
<LemonButton
size="xsmall"
onClick={() => takeScreenshot(ExporterFormat.PNG)}
onClick={takeScreenshot}
tooltip={
<>
Take a screenshot of this point in the recording <KeyboardShortcut s />
Expand All @@ -144,7 +119,6 @@ function Screenshot(): JSX.Element {
export function PlayerController(): JSX.Element {
const { playlistLogic, logicProps } = useValues(sessionRecordingPlayerLogic)
const { isCinemaMode } = useValues(playerSettingsLogic)
const { featureFlags } = useValues(featureFlagLogic)

const playerMode = logicProps.mode ?? SessionRecordingPlayerMode.Standard

Expand All @@ -168,7 +142,7 @@ export function PlayerController(): JSX.Element {
<>
<CommentOnRecordingButton />
<Screenshot />
{featureFlags[FEATURE_FLAGS.REPLAY_EXPORT_SHORT_VIDEO] && <Clip />}
<ClipRecording />
{playlistLogic ? <PlayerUpNext playlistLogic={playlistLogic} /> : undefined}
</>
)}
Expand Down
Loading
Loading