Skip to content
Merged

Clip #72

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
6 changes: 3 additions & 3 deletions .kiro/specs/global-jump-navigation-gui/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,20 +118,20 @@ Tasks for implementing the Global Jump Navigation GUI, including the frontend co

### Backend: Video Clip Export (Nice to Have)

- [ ]* 17. Create clip export endpoint
- [x] 17. Create clip export endpoint
- Add `GET /api/v1/videos/{video_id}/clip` endpoint
- Accept start_ms, end_ms, buffer_ms parameters
- Validate video exists and timestamp range
- _Requirements: 11.4_

- [ ]* 18. Implement ffmpeg clip extraction
- [x] 18. Implement ffmpeg clip extraction
- Build ffmpeg command with stream copy
- Use fragmented MP4 for streaming output
- Stream output directly to response
- Generate descriptive filename
- _Requirements: 11.5, 11.6, 11.7, 11.9_

- [ ]* 19. Add export clip button to GlobalJumpControl
- [x] 19. Add export clip button to GlobalJumpControl
- Show button when lastResult is set
- Implement download handler
- Show loading state during export
Expand Down
21 changes: 1 addition & 20 deletions backend/poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

119 changes: 118 additions & 1 deletion backend/src/api/video_controller.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
from fastapi import APIRouter, Depends, HTTPException, status
import asyncio
import logging
import os

from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session

from ..api.schemas import VideoCreateSchema, VideoResponseSchema, VideoUpdateSchema
Expand All @@ -7,6 +12,8 @@
from ..repositories.video_repository import SqlVideoRepository
from ..services.video_service import VideoService

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/videos", tags=["videos"])


Expand Down Expand Up @@ -143,3 +150,113 @@ async def get_video_location(
detail="No location data available for this video",
)
return location


@router.get("/{video_id}/clip")
async def download_clip(
video_id: str,
start_ms: int = Query(..., ge=0, description="Start timestamp in milliseconds"),
end_ms: int = Query(..., ge=0, description="End timestamp in milliseconds"),
buffer_ms: int = Query(
2000, ge=0, le=10000, description="Buffer time before/after in ms"
),
service: VideoService = Depends(get_video_service),
) -> StreamingResponse:
"""
Export a video clip between the specified timestamps.

Uses ffmpeg with stream copy (-c copy) for fast extraction.

Args:
video_id: ID of the video to extract from
start_ms: Start timestamp in milliseconds
end_ms: End timestamp in milliseconds
buffer_ms: Additional buffer time before start and after end (default 2000ms)

Returns:
StreamingResponse with video/mp4 content type

Raises:
404: Video not found or video file not found on disk
400: Invalid timestamp range (end_ms <= start_ms)
"""
# Validate video exists
video = service.get_video(video_id)
if not video:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Video not found"
)

# Validate video file exists on disk
if not os.path.exists(video.file_path):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Video file not found on disk"
)

# Validate timestamp range
if end_ms <= start_ms:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="end_ms must be greater than start_ms",
)

# Apply buffer (clamp to valid range)
actual_start_ms = max(0, start_ms - buffer_ms)
actual_end_ms = end_ms + buffer_ms

# Convert to seconds for ffmpeg
start_sec = actual_start_ms / 1000
duration_sec = (actual_end_ms - actual_start_ms) / 1000

# Generate filename
start_fmt = f"{int(start_sec // 60)}m{int(start_sec % 60)}s"
end_sec = actual_end_ms / 1000
end_fmt = f"{int(end_sec // 60)}m{int(end_sec % 60)}s"
base_name = os.path.splitext(video.filename)[0]
clip_filename = f"{base_name}_{start_fmt}-{end_fmt}.mp4"

# Build ffmpeg command
# -ss before -i for fast seeking
# -c copy for stream copy (fast, keyframe-aligned)
# -movflags frag_keyframe+empty_moov for streaming output
cmd = [
"ffmpeg",
"-ss",
str(start_sec),
"-i",
video.file_path,
"-t",
str(duration_sec),
"-c",
"copy",
"-movflags",
"frag_keyframe+empty_moov",
"-f",
"mp4",
"pipe:1",
]

async def stream_output():
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)

while True:
chunk = await process.stdout.read(65536) # 64KB chunks
if not chunk:
break
yield chunk

# Wait for process to complete
await process.wait()
if process.returncode != 0:
stderr = await process.stderr.read()
logger.error(f"FFmpeg failed: {stderr.decode()}")

return StreamingResponse(
stream_output(),
media_type="video/mp4",
headers={"Content-Disposition": f'attachment; filename="{clip_filename}"'},
)
100 changes: 100 additions & 0 deletions backend/tests/test_clip_export.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Tests for the video clip export endpoint."""

import os


class TestClipExportValidation:
"""Test clip export parameter validation without full app context."""

def test_timestamp_validation_end_must_be_greater_than_start(self):
"""Verify end_ms > start_ms validation logic."""
start_ms = 5000
end_ms = 5000
assert end_ms <= start_ms, "end_ms must be greater than start_ms"

start_ms = 5000
end_ms = 3000
assert end_ms <= start_ms, "end_ms must be greater than start_ms"

def test_buffer_calculation(self):
"""Test buffer is correctly applied to timestamps."""
start_ms = 5000
end_ms = 10000
buffer_ms = 2000

actual_start_ms = max(0, start_ms - buffer_ms)
actual_end_ms = end_ms + buffer_ms

assert actual_start_ms == 3000
assert actual_end_ms == 12000

def test_buffer_clamps_to_zero(self):
"""Test buffer doesn't go negative."""
start_ms = 1000
buffer_ms = 2000

actual_start_ms = max(0, start_ms - buffer_ms)
assert actual_start_ms == 0

def test_filename_generation(self):
"""Test clip filename is generated correctly."""
filename = "test_video.mp4"
actual_start_ms = 3000 # 3 seconds
actual_end_ms = 12000 # 12 seconds

start_sec = actual_start_ms / 1000
end_sec = actual_end_ms / 1000

start_fmt = f"{int(start_sec // 60)}m{int(start_sec % 60)}s"
end_fmt = f"{int(end_sec // 60)}m{int(end_sec % 60)}s"
base_name = os.path.splitext(filename)[0]
clip_filename = f"{base_name}_{start_fmt}-{end_fmt}.mp4"

assert clip_filename == "test_video_0m3s-0m12s.mp4"

def test_filename_generation_with_minutes(self):
"""Test clip filename with timestamps over 1 minute."""
filename = "my_video.mp4"
actual_start_ms = 65000 # 1:05
actual_end_ms = 125000 # 2:05

start_sec = actual_start_ms / 1000
end_sec = actual_end_ms / 1000

start_fmt = f"{int(start_sec // 60)}m{int(start_sec % 60)}s"
end_fmt = f"{int(end_sec // 60)}m{int(end_sec % 60)}s"
base_name = os.path.splitext(filename)[0]
clip_filename = f"{base_name}_{start_fmt}-{end_fmt}.mp4"

assert clip_filename == "my_video_1m5s-2m5s.mp4"

def test_ffmpeg_command_construction(self):
"""Test ffmpeg command is built correctly."""
file_path = "/path/to/video.mp4"
start_sec = 3.0
duration_sec = 9.0

cmd = [
"ffmpeg",
"-ss",
str(start_sec),
"-i",
file_path,
"-t",
str(duration_sec),
"-c",
"copy",
"-movflags",
"frag_keyframe+empty_moov",
"-f",
"mp4",
"pipe:1",
]

assert cmd[0] == "ffmpeg"
assert cmd[1] == "-ss"
assert cmd[2] == "3.0"
assert cmd[4] == file_path
assert cmd[6] == "9.0"
assert "-c" in cmd
assert "copy" in cmd
3 changes: 2 additions & 1 deletion dev/Dockerfile.backend
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# syntax=docker/dockerfile:1.4
FROM python:3.10-slim

# Install system dependencies
# Install system dependencies including ffmpeg for video clip export
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*

# Install Poetry
Expand Down
60 changes: 60 additions & 0 deletions frontend/src/components/GlobalJumpControl.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -251,4 +251,64 @@ describe('GlobalJumpControl', () => {
expect(screen.getByText('50%')).toBeInTheDocument();
});
});

describe('Export Clip button', () => {
it('does not show export button when no videoId is provided (search page mode)', () => {
render(<GlobalJumpControl />);

expect(screen.queryByRole('button', { name: /export clip/i })).not.toBeInTheDocument();
});

it('shows export button when videoId is provided (player mode)', () => {
render(<GlobalJumpControl videoId="test-video-123" />);

// Export button should be visible when viewing a video
expect(screen.getByRole('button', { name: /export clip/i })).toBeInTheDocument();
});

it('shows timestamp inputs when videoId is provided', () => {
render(<GlobalJumpControl videoId="test-video-123" />);

// Should show start and end time inputs
expect(screen.getByTitle('Start time (MM:SS)')).toBeInTheDocument();
expect(screen.getByTitle('End time (MM:SS)')).toBeInTheDocument();

// Should show set-to-current-time buttons
expect(screen.getAllByTitle(/Set .* to current time/)).toHaveLength(2);
});

it('updates timestamps after successful navigation', async () => {
// Mock successful API response with a result
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
results: [{
video_id: 'test-video-123',
video_filename: 'test.mp4',
file_created_at: '2025-01-01T00:00:00Z',
jump_to: { start_ms: 65000, end_ms: 125000 },
artifact_id: 'artifact-1',
preview: {},
}],
has_more: true,
}),
});

render(<GlobalJumpControl videoId="test-video-123" />);

// Click Next to trigger navigation
const nextButton = screen.getByRole('button', { name: /next/i });
fireEvent.click(nextButton);

// Wait for the match display to update (indicates navigation completed)
await screen.findByText(/test\.mp4 @ 1:05/);

// Check timestamps updated (1:05 and 2:05)
const startInput = screen.getByTitle('Start time (MM:SS)');
const endInput = screen.getByTitle('End time (MM:SS)');

expect(startInput).toHaveValue('1:05');
expect(endInput).toHaveValue('2:05');
});
});
});
Loading