Skip to content

Commit 323149b

Browse files
committed
feat: enhance video preview processing to support explicit S3 locations and improve content type validation
1 parent 176ada7 commit 323149b

2 files changed

Lines changed: 225 additions & 16 deletions

File tree

docs/VIDEO_PREVIEW.md

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
# Video Preview
2+
3+
`processVideoPreview` extracts a frame from a video (from either an explicit S3 location or an HTTP/HTTPS URL) and returns it as an image. The image can be resized, rescaled, and converted to WebP or JPEG format with configurable quality. Both memory cache and S3 cache are supported for the generated preview images.
4+
5+
## Endpoint
6+
7+
- Route: `GET /videos/preview/*` - requests are validated and routed to `processVideoPreview`.
8+
9+
## Security
10+
11+
- `processVideoPreview` relies on upstream validation (e.g. `validation.ProcessImageContextFromPath`) to ensure the incoming `params.Url` or `params.CustomObjectKey` is allowed and properly signed when necessary.
12+
- When `CustomObjectKey` is set, callers must ensure that the key was produced by a server-side signing/validation step; direct client-provided S3 keys are not trusted.
13+
- S3 presigned URLs are generated with 1-hour expiration for ffmpeg to access the video.
14+
15+
## Key concepts
16+
17+
- Frame extraction: uses ffmpeg to extract a single frame at a specified position (first frame, middle frame, or last frame).
18+
- Image processing: extracted frames can be resized, rescaled, and encoded to WebP or JPEG.
19+
- S3 explicit location: when `params.CustomObjectKey` is provided and S3 is configured, the handler reads the video directly from S3 using a presigned URL.
20+
- HTTP proxy: when no explicit S3 location is given, the handler validates and extracts frames from `params.Url`.
21+
- Caching: preview images are cached in both memory (Ristretto) and S3 (if enabled) to optimize performance.
22+
23+
## Environment / configuration
24+
25+
- S3 settings (when using explicit location): `S3_ENABLED`, `S3_ENDPOINT`, `S3_BUCKET`, `S3_PREFIX`, etc. The running binary relies on `s3cache` configuration and presence of a MinIO client.
26+
- Cache settings: `APP_CACHE_TTL` (in-memory cache TTL in seconds), `APP_HTTP_CACHE_TTL` (HTTP Cache-Control max-age).
27+
- Request validation and signing: upstream validation must be configured (routes use `validation.ProcessImageContextFromPath`).
28+
29+
## Path parameters
30+
31+
The path after `/videos/preview/` is parsed to extract transformation parameters and video URL:
32+
33+
Format: `/videos/preview/{params}/{base64-encoded-url-or-location}`
34+
35+
Supported parameters (can be combined):
36+
- `q:{quality}` - JPEG/WebP quality (1-100, default varies)
37+
- `w:{width}` - target width in pixels
38+
- `h:{height}` - target height in pixels
39+
- `s:{scale}` - scale factor (e.g., 0.5 for 50%, 2.0 for 200%)
40+
- `webp` - convert to WebP format (default is JPEG)
41+
- `f:{position}` - frame position: `first`, `middle`, or `last` (default is `first`)
42+
- `loc:{location}` - explicit S3 location (requires signature)
43+
- `i:{interpolation}` - interpolation method for resizing (e.g., `lanczos`, `linear`, `cubic`)
44+
45+
## Request details
46+
47+
### Video sources
48+
49+
1. **S3 location** (when `loc:{location}` parameter is present):
50+
- The handler validates the S3 object exists and is a video
51+
- Generates a presigned URL (1-hour expiration) for ffmpeg to access
52+
- Content-Type is validated from S3 metadata
53+
54+
2. **HTTP/HTTPS URL** (default):
55+
- The handler performs a HEAD request to validate content type
56+
- Must return a valid video MIME type
57+
- URL is passed directly to ffmpeg for frame extraction
58+
59+
### Frame extraction
60+
61+
- Uses ffmpeg to extract a single frame at the specified position
62+
- Supported positions:
63+
- `first` - extracts the first frame (default)
64+
- `middle` - extracts a frame from the middle of the video
65+
- `last` - extracts the last frame
66+
- Frame extraction is performed via the `extractFrameFromPosition` function
67+
68+
### Image transformations
69+
70+
Applied in order:
71+
1. **Resize** (if `w:` or `h:` specified): resizes to exact dimensions
72+
2. **Rescale** (if `s:` specified): scales by percentage
73+
3. **Encode**: converts to WebP or JPEG with specified quality
74+
75+
## Caching behavior
76+
77+
1. **Memory cache check**: checks Ristretto cache first
78+
2. **S3 cache check**: if memory cache misses, checks S3 cache
79+
3. **Generate preview**: if both caches miss:
80+
- Validates video source (S3 or HTTP)
81+
- Extracts frame at specified position
82+
- Applies transformations (resize, rescale)
83+
- Encodes to target format (WebP or JPEG)
84+
- Stores in both memory and S3 caches (if enabled)
85+
86+
## Headers set
87+
88+
- `Content-Type`: `image/webp` or `image/jpeg` depending on output format
89+
- `Cache-Control`: `public, max-age={APP_HTTP_CACHE_TTL}` for client-side caching
90+
91+
## Errors and status codes
92+
93+
- 200 OK — preview image generated and served successfully
94+
- 403 Forbidden — invalid content type or video MIME type not allowed
95+
- 404 Not Found — S3 object not found (when using S3 location)
96+
- 500 Internal Server Error — failures during:
97+
- Content type validation
98+
- Frame extraction
99+
- Image processing (resize, rescale, encode)
100+
- S3 operations (stat, presigned URL generation)
101+
102+
## Examples
103+
104+
### Basic preview (first frame, default quality)
105+
106+
```bash
107+
curl "http://localhost:3000/videos/preview/<base64-encoded-url>"
108+
```
109+
110+
### Preview with specific dimensions and WebP format
111+
112+
```bash
113+
# Extract first frame, resize to 500x300, convert to WebP at quality 80
114+
curl "http://localhost:3000/videos/preview/w:500/h:300/q:80/webp/<base64-encoded-url>"
115+
```
116+
117+
### Preview from middle of video with scaling
118+
119+
```bash
120+
# Extract middle frame, scale to 50%, JPEG quality 90
121+
curl "http://localhost:3000/videos/preview/f:middle/s:0.5/q:90/<base64-encoded-url>"
122+
```
123+
124+
### Preview from S3 location
125+
126+
```bash
127+
# Extract last frame from S3 object, resize to 800x600
128+
curl "http://localhost:3000/videos/preview/loc:<signed-location>/f:last/w:800/h:600/<base64-encoded-url>"
129+
```
130+
131+
### High-quality WebP thumbnail
132+
133+
```bash
134+
# First frame, 300x200, WebP quality 95, Lanczos interpolation
135+
curl "http://localhost:3000/videos/preview/w:300/h:200/q:95/webp/i:lanczos/<base64-encoded-url>"
136+
```
137+
138+
## Performance considerations
139+
140+
- Frame extraction requires ffmpeg and may be CPU-intensive for large videos
141+
- S3 presigned URLs are generated on-demand (1-hour expiration)
142+
- Caching significantly improves performance for repeated preview requests
143+
- Consider pre-generating previews for frequently accessed videos
144+
- Memory and S3 caches work together to minimize redundant processing
145+
146+
## Integration with video upload
147+
148+
When uploading videos using the single or multi-part upload endpoints, you can generate previews by:
149+
150+
1. Upload video to S3 (single upload or multi-part)
151+
2. Use the returned `location` in a preview request with `loc:{location}` parameter
152+
3. Ensure the request is properly signed if required by your security configuration
153+
154+
## MIME type validation
155+
156+
Only video MIME types are accepted:
157+
- video/mp4
158+
- video/quicktime
159+
- video/x-msvideo
160+
- video/x-matroska
161+
- video/webm
162+
- And other standard video MIME types (validated via `validation.IsVideoMime`)

routes/video.go

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ func processVideoPreview(c *fiber.Ctx, logger *zap.Logger, cache *ristretto.Cach
9595
zap.Int("height", params.Height),
9696
zap.Float64("scale", params.Scale),
9797
zap.String("framePosition", params.FramePosition),
98-
zap.String("url", params.Url))
98+
zap.String("url", params.Url),
99+
zap.String("location", params.CustomObjectKey))
99100

100101
cacheKey := cacheKey(params.Url, params)
101102
cacheValue, ok := cache.Get(cacheKey)
@@ -130,27 +131,73 @@ func processVideoPreview(c *fiber.Ctx, logger *zap.Logger, cache *ristretto.Cach
130131
}
131132
}
132133

133-
// First check if it's a video by doing a HEAD request
134-
responseContentType, err := validation.GetContentType(params.Url)
135-
if err != nil {
136-
return c.Status(fiber.StatusInternalServerError).SendString("failed to check video")
137-
}
134+
var videoURL string
135+
var parsedContentType string
138136

139-
if responseContentType == "" {
140-
return c.Status(fiber.StatusForbidden).SendString("no content type received")
141-
}
137+
// If explicit S3 location provided, use it directly (signature already enforced in validation)
138+
if params.CustomObjectKey != "" && s3cache != nil && s3cache.Enabled && s3cache.Client != nil {
139+
// Use S3 location as video source
140+
objKey := objectKeyFromExplicitLocation(s3cache.Prefix, params.CustomObjectKey)
142141

143-
parsedContentType, _, err := mime.ParseMediaType(responseContentType)
144-
if err != nil {
145-
return c.Status(fiber.StatusInternalServerError).SendString("failed to parse content type")
146-
}
142+
// Get object info to validate it's a video
143+
obj, err := s3cache.Client.StatObject(context.Background(), s3cache.Bucket, objKey, minio.StatObjectOptions{})
144+
if err != nil {
145+
logger.Error("failed to stat s3 object", zap.Error(err), zap.String("object", objKey))
146+
return c.Status(fiber.StatusNotFound).SendString("video not found in s3")
147+
}
148+
149+
parsedContentType = obj.ContentType
150+
if parsedContentType == "" {
151+
if ct, ok := obj.Metadata["Content-Type"]; ok && len(ct) > 0 {
152+
parsedContentType = ct[0]
153+
} else {
154+
parsedContentType = "application/octet-stream"
155+
}
156+
}
157+
158+
parsed, _, err := mime.ParseMediaType(parsedContentType)
159+
if err != nil {
160+
return c.Status(fiber.StatusInternalServerError).SendString("failed to parse content type")
161+
}
162+
parsedContentType = parsed
163+
164+
if !validation.IsVideoMime(parsedContentType) {
165+
return c.Status(fiber.StatusForbidden).SendString(fmt.Sprintf("content type '%s' is not a video", parsedContentType))
166+
}
167+
168+
// Generate presigned URL for ffmpeg to access
169+
presignedURL, err := s3cache.Client.PresignedGetObject(context.Background(), s3cache.Bucket, objKey, time.Hour, nil)
170+
if err != nil {
171+
logger.Error("failed to generate presigned url", zap.Error(err))
172+
return c.Status(fiber.StatusInternalServerError).SendString("failed to generate presigned url")
173+
}
174+
videoURL = presignedURL.String()
175+
} else {
176+
// Use HTTP/HTTPS origin
177+
responseContentType, err := validation.GetContentType(params.Url)
178+
if err != nil {
179+
return c.Status(fiber.StatusInternalServerError).SendString("failed to check video")
180+
}
181+
182+
if responseContentType == "" {
183+
return c.Status(fiber.StatusForbidden).SendString("no content type received")
184+
}
185+
186+
parsed, _, err := mime.ParseMediaType(responseContentType)
187+
if err != nil {
188+
return c.Status(fiber.StatusInternalServerError).SendString("failed to parse content type")
189+
}
190+
parsedContentType = parsed
191+
192+
if !validation.IsVideoMime(parsedContentType) {
193+
return c.Status(fiber.StatusForbidden).SendString(fmt.Sprintf("content type '%s' is not allowed", parsedContentType))
194+
}
147195

148-
if !validation.IsVideoMime(parsedContentType) {
149-
return c.Status(fiber.StatusForbidden).SendString(fmt.Sprintf("content type '%s' is not allowed", parsedContentType))
196+
videoURL = params.Url
150197
}
151198

152199
// Extract frame from specified position
153-
frameImage, err := extractFrameFromPosition(params.Url, params.FramePosition)
200+
frameImage, err := extractFrameFromPosition(videoURL, params.FramePosition)
154201
if err != nil {
155202
logger.Error("failed to extract frame", zap.Error(err), zap.String("position", params.FramePosition))
156203
return c.Status(fiber.StatusInternalServerError).SendString("failed to extract video preview")

0 commit comments

Comments
 (0)