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
31 changes: 31 additions & 0 deletions pd/internal/cli/record.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,23 @@ func newRecordCommand(stdout, stderr io.Writer) *cobra.Command {
idleMinDuration float64
idleNoiseTolerance string
stateFile string
thumbnailFile string
thumbnailWidth int
thumbnailHeight int
)

cmd := &cobra.Command{
Use: "record [file]",
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
Expand Down Expand Up @@ -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
},
Expand All @@ -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
}
79 changes: 79 additions & 0 deletions pd/internal/desktop/thumbnail.go
Original file line number Diff line number Diff line change
@@ -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
}
245 changes: 245 additions & 0 deletions pd/internal/desktop/thumbnail_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading