diff --git a/pd/internal/cli/record.go b/pd/internal/cli/record.go index 1e82894..90064a2 100644 --- a/pd/internal/cli/record.go +++ b/pd/internal/cli/record.go @@ -18,6 +18,9 @@ func newRecordCommand(stdout, stderr io.Writer) *cobra.Command { idleMinDuration float64 idleNoiseTolerance string stateFile string + thumbnailFile string + thumbnailWidth int + thumbnailHeight int ) cmd := &cobra.Command{ @@ -25,6 +28,13 @@ func newRecordCommand(stdout, stderr io.Writer) *cobra.Command { Short: "Record the desktop to a video file", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if (thumbnailWidth > 0 || thumbnailHeight > 0) && thumbnailFile == "" { + return fmt.Errorf("--thumbnail is required when --thumbnail-width or --thumbnail-height is set") + } + if thumbnailWidth > 0 && thumbnailHeight > 0 { + return fmt.Errorf("--thumbnail-width and --thumbnail-height are mutually exclusive") + } + d, _, err := loadDesktopFromState(stateFile) if err != nil { return err @@ -56,6 +66,24 @@ func newRecordCommand(stdout, stderr io.Writer) *cobra.Command { return err } + if thumbnailFile != "" { + thumbOpts := desktop.ThumbnailOptions{InputFile: handle.File} + if thumbnailWidth > 0 { + thumbOpts.Width = &thumbnailWidth + } + if thumbnailHeight > 0 { + thumbOpts.Height = &thumbnailHeight + } + jpegData, err := d.Thumbnail(thumbOpts) + if err != nil { + return fmt.Errorf("extract thumbnail: %w", err) + } + if err := os.WriteFile(thumbnailFile, jpegData, 0o644); err != nil { + return fmt.Errorf("write thumbnail: %w", err) + } + fmt.Fprintf(stdout, "thumbnail: %s\n", thumbnailFile) + } + fmt.Fprintf(stdout, "saved: %s\n", handle.File) return nil }, @@ -65,6 +93,9 @@ func newRecordCommand(stdout, stderr io.Writer) *cobra.Command { cmd.Flags().Float64Var(&idleSpeedup, "idle-speedup", 0, "Idle segment playback acceleration factor (e.g. 20). Disabled when <= 1.") cmd.Flags().Float64Var(&idleMinDuration, "idle-min-duration", 0, "Minimum idle segment duration in seconds before acceleration") cmd.Flags().StringVar(&idleNoiseTolerance, "idle-noise-tolerance", "", "ffmpeg freezedetect noise tolerance (e.g. -38dB)") + cmd.Flags().StringVar(&thumbnailFile, "thumbnail", "", "write a JPEG thumbnail of the first frame to this path") + cmd.Flags().IntVar(&thumbnailWidth, "thumbnail-width", 0, "resize thumbnail to this width (preserves aspect ratio)") + cmd.Flags().IntVar(&thumbnailHeight, "thumbnail-height", 0, "resize thumbnail to this height (preserves aspect ratio)") addStateFileFlag(cmd, &stateFile) return cmd } diff --git a/pd/internal/desktop/thumbnail.go b/pd/internal/desktop/thumbnail.go new file mode 100644 index 0000000..a3f881a --- /dev/null +++ b/pd/internal/desktop/thumbnail.go @@ -0,0 +1,79 @@ +package desktop + +import ( + "bytes" + "fmt" + "os/exec" + "strings" + + "github.com/coder/portabledesktop/pd/internal/runtime" +) + +// ThumbnailOptions controls how a JPEG thumbnail is extracted from +// an MP4 file. +type ThumbnailOptions struct { + // InputFile is the path to the MP4 file. + InputFile string + // Width sets the output width in pixels. The height is + // calculated automatically to preserve the aspect ratio. + // Mutually exclusive with Height. + Width *int + // Height sets the output height in pixels. The width is + // calculated automatically to preserve the aspect ratio. + // Mutually exclusive with Width. + Height *int +} + +// Thumbnail extracts a single JPEG frame from the given MP4 file +// and returns the raw JPEG bytes. +func (d *Desktop) Thumbnail(opts ThumbnailOptions) ([]byte, error) { + if opts.InputFile == "" { + return nil, fmt.Errorf("input file is required") + } + if opts.Width != nil && opts.Height != nil { + return nil, fmt.Errorf("width and height are mutually exclusive") + } + if opts.Width != nil && *opts.Width <= 0 { + return nil, fmt.Errorf("width must be positive, got %d", *opts.Width) + } + if opts.Height != nil && *opts.Height <= 0 { + return nil, fmt.Errorf("height must be positive, got %d", *opts.Height) + } + + ffmpegBin := runtime.ResolveRuntimeBinary(d.RuntimeDir, "ffmpeg") + + args := []string{ + "-loglevel", "error", + "-i", opts.InputFile, + "-frames:v", "1", + } + + if opts.Width != nil { + args = append(args, "-vf", fmt.Sprintf("scale=%d:-2", *opts.Width)) + } else if opts.Height != nil { + args = append(args, "-vf", fmt.Sprintf("scale=-2:%d", *opts.Height)) + } + + args = append(args, "-f", "image2pipe", "-vcodec", "mjpeg", "-q:v", "2", "pipe:1") + + cmd := exec.Command(ffmpegBin, args...) + cmd.Env = d.Env() + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf( + "ffmpeg thumbnail failed: %w: %s", + err, strings.TrimSpace(stderr.String()), + ) + } + + if stdout.Len() == 0 { + return nil, fmt.Errorf("no frame extracted") + } + + return stdout.Bytes(), nil +} diff --git a/pd/internal/desktop/thumbnail_test.go b/pd/internal/desktop/thumbnail_test.go new file mode 100644 index 0000000..460b971 --- /dev/null +++ b/pd/internal/desktop/thumbnail_test.go @@ -0,0 +1,245 @@ +package desktop + +import ( + "bytes" + "fmt" + "image/jpeg" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func intPtr(v int) *int { return &v } + +func generateTestMP4(t *testing.T, width, height, durationSec int) string { + t.Helper() + out := filepath.Join(t.TempDir(), "test.mp4") + cmd := exec.Command("ffmpeg", + "-f", "lavfi", + "-i", fmt.Sprintf("testsrc2=size=%dx%d:rate=1:duration=%d", width, height, durationSec), + "-c:v", "libx264", + "-pix_fmt", "yuv420p", + "-y", out, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("generate test MP4: %v: %s", err, output) + } + return out +} + +// Validation tests (no ffmpeg needed). + +func TestThumbnail_BothWidthAndHeight(t *testing.T) { + d := &Desktop{RuntimeDir: t.TempDir()} + _, err := d.Thumbnail(ThumbnailOptions{ + InputFile: "dummy.mp4", + Width: intPtr(320), + Height: intPtr(240), + }) + if err == nil { + t.Fatalf("expected error when both Width and Height are set") + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("expected error to contain %q, got: %v", "mutually exclusive", err) + } +} + +func TestThumbnail_ZeroWidth(t *testing.T) { + d := &Desktop{RuntimeDir: t.TempDir()} + _, err := d.Thumbnail(ThumbnailOptions{ + InputFile: "dummy.mp4", + Width: intPtr(0), + }) + if err == nil { + t.Fatalf("expected error for zero Width") + } + if !strings.Contains(err.Error(), "positive") { + t.Fatalf("expected error to contain %q, got: %v", "positive", err) + } +} + +func TestThumbnail_NegativeWidth(t *testing.T) { + d := &Desktop{RuntimeDir: t.TempDir()} + _, err := d.Thumbnail(ThumbnailOptions{ + InputFile: "dummy.mp4", + Width: intPtr(-1), + }) + if err == nil { + t.Fatalf("expected error for negative Width") + } + if !strings.Contains(err.Error(), "positive") { + t.Fatalf("expected error to contain %q, got: %v", "positive", err) + } +} + +func TestThumbnail_ZeroHeight(t *testing.T) { + d := &Desktop{RuntimeDir: t.TempDir()} + _, err := d.Thumbnail(ThumbnailOptions{ + InputFile: "dummy.mp4", + Height: intPtr(0), + }) + if err == nil { + t.Fatalf("expected error for zero Height") + } + if !strings.Contains(err.Error(), "positive") { + t.Fatalf("expected error to contain %q, got: %v", "positive", err) + } +} + +func TestThumbnail_NegativeHeight(t *testing.T) { + d := &Desktop{RuntimeDir: t.TempDir()} + _, err := d.Thumbnail(ThumbnailOptions{ + InputFile: "dummy.mp4", + Height: intPtr(-1), + }) + if err == nil { + t.Fatalf("expected error for negative Height") + } + if !strings.Contains(err.Error(), "positive") { + t.Fatalf("expected error to contain %q, got: %v", "positive", err) + } +} + +func TestThumbnail_EmptyInputFile(t *testing.T) { + d := &Desktop{RuntimeDir: t.TempDir()} + _, err := d.Thumbnail(ThumbnailOptions{ + InputFile: "", + }) + if err == nil { + t.Fatalf("expected error for empty InputFile") + } + if !strings.Contains(err.Error(), "input file") { + t.Fatalf("expected error to contain %q, got: %v", "input file", err) + } +} + +// Integration tests (require ffmpeg). + +func TestThumbnail_NativeResolution(t *testing.T) { + if _, err := exec.LookPath("ffmpeg"); err != nil { + t.Skip("ffmpeg not found on PATH") + } + + mp4 := generateTestMP4(t, 640, 480, 1) + d := &Desktop{RuntimeDir: ""} + data, err := d.Thumbnail(ThumbnailOptions{InputFile: mp4}) + if err != nil { + t.Fatalf("Thumbnail: %v", err) + } + if len(data) < 3 || data[0] != 0xff || data[1] != 0xd8 || data[2] != 0xff { + t.Fatalf("output does not start with JPEG magic bytes") + } + img, err := jpeg.Decode(bytes.NewReader(data)) + if err != nil { + t.Fatalf("decode JPEG: %v", err) + } + bounds := img.Bounds() + w := bounds.Dx() + h := bounds.Dy() + if w != 640 { + t.Fatalf("expected width 640, got %d", w) + } + if h != 480 { + t.Fatalf("expected height 480, got %d", h) + } +} + +func TestThumbnail_ScaleByWidth(t *testing.T) { + if _, err := exec.LookPath("ffmpeg"); err != nil { + t.Skip("ffmpeg not found on PATH") + } + + mp4 := generateTestMP4(t, 640, 480, 1) + d := &Desktop{RuntimeDir: ""} + data, err := d.Thumbnail(ThumbnailOptions{ + InputFile: mp4, + Width: intPtr(320), + }) + if err != nil { + t.Fatalf("Thumbnail: %v", err) + } + img, err := jpeg.Decode(bytes.NewReader(data)) + if err != nil { + t.Fatalf("decode JPEG: %v", err) + } + bounds := img.Bounds() + w := bounds.Dx() + h := bounds.Dy() + if w != 320 { + t.Fatalf("expected width 320, got %d", w) + } + if h != 240 { + t.Fatalf("expected height 240, got %d", h) + } +} + +func TestThumbnail_ScaleByHeight(t *testing.T) { + if _, err := exec.LookPath("ffmpeg"); err != nil { + t.Skip("ffmpeg not found on PATH") + } + + mp4 := generateTestMP4(t, 640, 480, 1) + d := &Desktop{RuntimeDir: ""} + data, err := d.Thumbnail(ThumbnailOptions{ + InputFile: mp4, + Height: intPtr(120), + }) + if err != nil { + t.Fatalf("Thumbnail: %v", err) + } + img, err := jpeg.Decode(bytes.NewReader(data)) + if err != nil { + t.Fatalf("decode JPEG: %v", err) + } + bounds := img.Bounds() + w := bounds.Dx() + h := bounds.Dy() + if h != 120 { + t.Fatalf("expected height 120, got %d", h) + } + if w != 160 { + t.Fatalf("expected width 160, got %d", w) + } +} + +func TestThumbnail_OddSourceDimension(t *testing.T) { + if _, err := exec.LookPath("ffmpeg"); err != nil { + t.Skip("ffmpeg not found on PATH") + } + + mp4 := generateTestMP4(t, 641, 481, 1) + d := &Desktop{RuntimeDir: ""} + data, err := d.Thumbnail(ThumbnailOptions{ + InputFile: mp4, + Width: intPtr(320), + }) + if err != nil { + t.Fatalf("Thumbnail: %v", err) + } + img, err := jpeg.Decode(bytes.NewReader(data)) + if err != nil { + t.Fatalf("decode JPEG: %v", err) + } + bounds := img.Bounds() + w := bounds.Dx() + h := bounds.Dy() + if w != 320 { + t.Fatalf("expected width 320, got %d", w) + } + if h%2 != 0 { + t.Fatalf("expected even height, got %d", h) + } +} + +func TestThumbnail_MissingInputFile(t *testing.T) { + if _, err := exec.LookPath("ffmpeg"); err != nil { + t.Skip("ffmpeg not found on PATH") + } + + d := &Desktop{RuntimeDir: ""} + _, err := d.Thumbnail(ThumbnailOptions{InputFile: "/nonexistent.mp4"}) + if err == nil { + t.Fatalf("expected error for missing input file") + } +}