diff --git a/.kiro/specs/global-jump-navigation-gui/tasks.md b/.kiro/specs/global-jump-navigation-gui/tasks.md index b9cc8d8..6c3a7da 100644 --- a/.kiro/specs/global-jump-navigation-gui/tasks.md +++ b/.kiro/specs/global-jump-navigation-gui/tasks.md @@ -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 diff --git a/backend/poetry.lock b/backend/poetry.lock index b4f2df7..59b98d7 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -963,25 +963,6 @@ tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] -[[package]] -name = "pytest-asyncio" -version = "0.21.2" -description = "Pytest support for asyncio" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "pytest_asyncio-0.21.2-py3-none-any.whl", hash = "sha256:ab664c88bb7998f711d8039cacd4884da6430886ae8bbd4eded552ed2004f16b"}, - {file = "pytest_asyncio-0.21.2.tar.gz", hash = "sha256:d67738fc232b94b326b9d060750beb16e0074210b98dd8b58a5239fa2a154f45"}, -] - -[package.dependencies] -pytest = ">=7.0.0" - -[package.extras] -docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] -testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] - [[package]] name = "python-dotenv" version = "1.2.1" @@ -1785,4 +1766,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "19511d21333f6ed79a6180189e4917dfed9c00a93a6cf070a391ad146005d586" +content-hash = "24f6914784810beef91619faf5f98ab00e39819ea9fef884f466907607864d29" diff --git a/backend/src/api/video_controller.py b/backend/src/api/video_controller.py index 4d806b5..a935d13 100644 --- a/backend/src/api/video_controller.py +++ b/backend/src/api/video_controller.py @@ -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 @@ -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"]) @@ -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}"'}, + ) diff --git a/backend/tests/test_clip_export.py b/backend/tests/test_clip_export.py new file mode 100644 index 0000000..6d6c97a --- /dev/null +++ b/backend/tests/test_clip_export.py @@ -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 diff --git a/dev/Dockerfile.backend b/dev/Dockerfile.backend index f042d11..16a01d8 100644 --- a/dev/Dockerfile.backend +++ b/dev/Dockerfile.backend @@ -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 diff --git a/frontend/src/components/GlobalJumpControl.test.tsx b/frontend/src/components/GlobalJumpControl.test.tsx index 6aafce7..6a956b1 100644 --- a/frontend/src/components/GlobalJumpControl.test.tsx +++ b/frontend/src/components/GlobalJumpControl.test.tsx @@ -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(); + + expect(screen.queryByRole('button', { name: /export clip/i })).not.toBeInTheDocument(); + }); + + it('shows export button when videoId is provided (player mode)', () => { + render(); + + // 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(); + + // 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(); + + // 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'); + }); + }); }); diff --git a/frontend/src/components/GlobalJumpControl.tsx b/frontend/src/components/GlobalJumpControl.tsx index 0aee53e..48ee0b1 100644 --- a/frontend/src/components/GlobalJumpControl.tsx +++ b/frontend/src/components/GlobalJumpControl.tsx @@ -181,6 +181,13 @@ export default function GlobalJumpControl({ // Error state const [error, setError] = useState(null); + + // Export clip loading state + const [exporting, setExporting] = useState(false); + + // Clip export timestamp inputs (user-editable, in MM:SS format) + const [clipStartTime, setClipStartTime] = useState('0:00'); + const [clipEndTime, setClipEndTime] = useState('0:00'); // Get the configuration for the current artifact type const config = ARTIFACT_CONFIG[artifactType]; @@ -195,6 +202,27 @@ export default function GlobalJumpControl({ return `${minutes}:${secs.toString().padStart(2, '0')}`; }; + /** + * Parse MM:SS or M:SS format to milliseconds. + * Returns null if invalid format. + */ + const parseTime = (timeStr: string): number | null => { + const match = timeStr.match(/^(\d+):(\d{1,2})$/); + if (!match) return null; + const minutes = parseInt(match[1], 10); + const seconds = parseInt(match[2], 10); + if (seconds >= 60) return null; + return (minutes * 60 + seconds) * 1000; + }; + + /** + * Update clip timestamps when a new result is received. + */ + const updateClipTimestamps = (result: GlobalJumpResult) => { + setClipStartTime(formatTime(result.jump_to.start_ms)); + setClipEndTime(formatTime(result.jump_to.end_ms)); + }; + /** * Handle artifact type change - resets type-specific fields. */ @@ -265,6 +293,7 @@ export default function GlobalJumpControl({ const result = data.results[0]; setLastResult(result); setCurrentMatch(`${result.video_filename} @ ${formatTime(result.jump_to.start_ms)}`); + updateClipTimestamps(result); // Handle navigation based on whether video changes if (result.video_id !== videoId) { @@ -296,6 +325,79 @@ export default function GlobalJumpControl({ } }; + /** + * Export a video clip containing the current search result. + * Uses user-editable start/end timestamps in MM:SS format. + * Requirements: 11.1, 11.2, 11.3, 11.8 + */ + const exportClip = async () => { + // Parse user-entered timestamps + const startMs = parseTime(clipStartTime); + const endMs = parseTime(clipEndTime); + + if (startMs === null || endMs === null) { + setError('Invalid timestamp format. Use MM:SS (e.g., 1:30)'); + return; + } + + if (endMs <= startMs) { + setError('End time must be after start time'); + return; + } + + // Use the video ID from the last result if available, otherwise use current videoId + const targetVideoId = lastResult?.video_id || videoId; + + if (!targetVideoId) { + setError('No video selected'); + return; + } + + setExporting(true); + setError(null); + + try { + const buffer_ms = 2000; // 2 second buffer + + const params = new URLSearchParams({ + start_ms: startMs.toString(), + end_ms: endMs.toString(), + buffer_ms: buffer_ms.toString(), + }); + + const response = await fetch( + `${apiUrl}/api/v1/videos/${targetVideoId}/clip?${params}` + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to export clip: ${errorText}`); + } + + // Get filename from Content-Disposition header + const disposition = response.headers.get('Content-Disposition'); + const filenameMatch = disposition?.match(/filename="(.+)"/); + const filename = filenameMatch?.[1] || 'clip.mp4'; + + // Download the blob + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (err) { + const message = err instanceof Error ? err.message : 'Export failed'; + setError(message); + console.error('Export failed:', err); + } finally { + setExporting(false); + } + }; + return (
+ {/* Export Clip section - shown when viewing any video */} + {videoId && ( + <> + + setClipStartTime(e.target.value)} + placeholder="0:00" + title="Start time (MM:SS)" + style={{ + width: '50px', + padding: '6px 8px', + backgroundColor: '#2a2a2a', + color: '#fff', + border: '1px solid #444', + borderRadius: '4px', + fontSize: '12px', + textAlign: 'center', + }} + /> + + setClipEndTime(e.target.value)} + placeholder="0:00" + title="End time (MM:SS)" + style={{ + width: '50px', + padding: '6px 8px', + backgroundColor: '#2a2a2a', + color: '#fff', + border: '1px solid #444', + borderRadius: '4px', + fontSize: '12px', + textAlign: 'center', + }} + /> + + + + )} + {/* Loading indicator */} {loading && ( Loading... diff --git a/frontend/src/components/OCRViewer.tsx b/frontend/src/components/OCRViewer.tsx index d2ea062..3ca248c 100644 --- a/frontend/src/components/OCRViewer.tsx +++ b/frontend/src/components/OCRViewer.tsx @@ -24,6 +24,7 @@ interface RunInfo { created_at: string; artifact_count: number; model_profile: string | null; + language?: string | null; } interface Props { diff --git a/frontend/src/components/TranscriptViewer.tsx b/frontend/src/components/TranscriptViewer.tsx index be3e9dd..0160fc0 100644 --- a/frontend/src/components/TranscriptViewer.tsx +++ b/frontend/src/components/TranscriptViewer.tsx @@ -28,6 +28,7 @@ interface RunInfo { created_at: string; artifact_count: number; model_profile: string | null; + language?: string | null; } interface Props {