Add video thumbnail support
This commit is contained in:
parent
2a0f2e22e0
commit
2f7aae1d6e
|
@ -9,6 +9,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -57,11 +58,12 @@ func (pw *progressReader) Read(p []byte) (int, error) {
|
||||||
|
|
||||||
// Store wraps a database store.
|
// Store wraps a database store.
|
||||||
type Store interface {
|
type Store interface {
|
||||||
GetMediaSet(ctx context.Context, id uuid.UUID) (store.MediaSet, error)
|
GetMediaSet(context.Context, uuid.UUID) (store.MediaSet, error)
|
||||||
GetMediaSetByYoutubeID(ctx context.Context, youtubeID string) (store.MediaSet, error)
|
GetMediaSetByYoutubeID(context.Context, string) (store.MediaSet, error)
|
||||||
CreateMediaSet(ctx context.Context, arg store.CreateMediaSetParams) (store.MediaSet, error)
|
CreateMediaSet(context.Context, store.CreateMediaSetParams) (store.MediaSet, error)
|
||||||
SetAudioUploaded(ctx context.Context, arg store.SetAudioUploadedParams) (store.MediaSet, error)
|
SetAudioUploaded(context.Context, store.SetAudioUploadedParams) (store.MediaSet, error)
|
||||||
SetVideoUploaded(ctx context.Context, arg store.SetVideoUploadedParams) (store.MediaSet, error)
|
SetVideoUploaded(context.Context, store.SetVideoUploadedParams) (store.MediaSet, error)
|
||||||
|
SetVideoThumbnailUploaded(context.Context, store.SetVideoThumbnailUploadedParams) (store.MediaSet, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// S3API provides an API to AWS S3.
|
// S3API provides an API to AWS S3.
|
||||||
|
@ -592,6 +594,10 @@ func sqlInt64(i int64) sql.NullInt64 {
|
||||||
return sql.NullInt64{Int64: i, Valid: true}
|
return sql.NullInt64{Int64: i, Valid: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sqlInt32(i int32) sql.NullInt32 {
|
||||||
|
return sql.NullInt32{Int32: i, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
// ModuloBufReader reads from a reader in block sizes that are exactly modulo
|
// ModuloBufReader reads from a reader in block sizes that are exactly modulo
|
||||||
// modSize, with any remainder buffered until the next read.
|
// modSize, with any remainder buffered until the next read.
|
||||||
type ModuloBufReader struct {
|
type ModuloBufReader struct {
|
||||||
|
@ -628,3 +634,95 @@ func (r *ModuloBufReader) Read(p []byte) (int, error) {
|
||||||
|
|
||||||
return nr - rem, err
|
return nr - rem, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type VideoThumbnail struct {
|
||||||
|
Data []byte
|
||||||
|
Width, Height int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MediaSetService) GetVideoThumbnail(ctx context.Context, id uuid.UUID) (VideoThumbnail, error) {
|
||||||
|
mediaSet, err := s.store.GetMediaSet(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return VideoThumbnail{}, fmt.Errorf("error getting media set: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mediaSet.VideoThumbnailS3UploadedAt.Valid {
|
||||||
|
return s.getThumbnailFromS3(ctx, mediaSet)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.getThumbnailFromYoutube(ctx, mediaSet)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MediaSetService) getThumbnailFromS3(ctx context.Context, mediaSet store.MediaSet) (VideoThumbnail, error) {
|
||||||
|
input := s3.GetObjectInput{
|
||||||
|
Bucket: aws.String(mediaSet.VideoThumbnailS3Bucket.String),
|
||||||
|
Key: aws.String(mediaSet.VideoThumbnailS3Key.String),
|
||||||
|
}
|
||||||
|
output, err := s.s3.GetObject(ctx, &input)
|
||||||
|
if err != nil {
|
||||||
|
return VideoThumbnail{}, fmt.Errorf("error fetching thumbnail from s3: %v", err)
|
||||||
|
}
|
||||||
|
defer output.Body.Close()
|
||||||
|
|
||||||
|
imageData, err := io.ReadAll(output.Body)
|
||||||
|
if err != nil {
|
||||||
|
return VideoThumbnail{}, fmt.Errorf("error reading thumbnail from s3: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return VideoThumbnail{
|
||||||
|
Width: int(mediaSet.VideoThumbnailWidth.Int32),
|
||||||
|
Height: int(mediaSet.VideoThumbnailHeight.Int32),
|
||||||
|
Data: imageData,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MediaSetService) getThumbnailFromYoutube(ctx context.Context, mediaSet store.MediaSet) (VideoThumbnail, error) {
|
||||||
|
video, err := s.youtube.GetVideoContext(ctx, mediaSet.YoutubeID)
|
||||||
|
if err != nil {
|
||||||
|
return VideoThumbnail{}, fmt.Errorf("error fetching video: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(video.Formats) == 0 {
|
||||||
|
return VideoThumbnail{}, errors.New("no format available")
|
||||||
|
}
|
||||||
|
|
||||||
|
thumbnails := video.Thumbnails
|
||||||
|
SortYoutubeThumbnails(thumbnails)
|
||||||
|
thumbnail := thumbnails[0]
|
||||||
|
|
||||||
|
resp, err := http.Get(thumbnail.URL)
|
||||||
|
if err != nil {
|
||||||
|
return VideoThumbnail{}, fmt.Errorf("error fetching thumbnail: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
imageData, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return VideoThumbnail{}, fmt.Errorf("error reading thumbnail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: use mediaSet func to fetch s3Key
|
||||||
|
s3Key := fmt.Sprintf("media_sets/%s/thumbnail.jpg", mediaSet.ID)
|
||||||
|
|
||||||
|
uploader := newMultipartUploader(s.s3)
|
||||||
|
const mimeType = "application/jpeg"
|
||||||
|
|
||||||
|
_, err = uploader.Upload(ctx, bytes.NewReader(imageData), s3Bucket, s3Key, mimeType)
|
||||||
|
if err != nil {
|
||||||
|
return VideoThumbnail{}, fmt.Errorf("error uploading thumbnail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
storeParams := store.SetVideoThumbnailUploadedParams{
|
||||||
|
ID: mediaSet.ID,
|
||||||
|
VideoThumbnailMimeType: sqlString(mimeType),
|
||||||
|
VideoThumbnailS3Bucket: sqlString(s3Bucket),
|
||||||
|
VideoThumbnailS3Key: sqlString(s3Key),
|
||||||
|
VideoThumbnailWidth: sqlInt32(int32(thumbnail.Width)),
|
||||||
|
VideoThumbnailHeight: sqlInt32(int32(thumbnail.Height)),
|
||||||
|
}
|
||||||
|
if _, err := s.store.SetVideoThumbnailUploaded(ctx, storeParams); err != nil {
|
||||||
|
return VideoThumbnail{}, fmt.Errorf("error updating media set: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return VideoThumbnail{Width: int(thumbnail.Width), Height: int(thumbnail.Height), Data: imageData}, nil
|
||||||
|
}
|
||||||
|
|
|
@ -51,3 +51,16 @@ func FilterYoutubeVideo(inFormats youtubev2.FormatList) youtubev2.FormatList {
|
||||||
})
|
})
|
||||||
return formats
|
return formats
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SortYoutubeThumbnails sorts the provided thumbnails ordered in descending preferred order.
|
||||||
|
func SortYoutubeThumbnails(thumbnails youtubev2.Thumbnails) {
|
||||||
|
sort.SliceStable(thumbnails, func(i, j int) bool {
|
||||||
|
// TODO: get rid of these magic 177s.
|
||||||
|
isMinSizeI := thumbnails[i].Width >= 177
|
||||||
|
isMinSizeJ := thumbnails[j].Width >= 177
|
||||||
|
if isMinSizeI && isMinSizeJ {
|
||||||
|
return thumbnails[i].Width < thumbnails[j].Width
|
||||||
|
}
|
||||||
|
return isMinSizeI
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -86,3 +86,31 @@ func TestFilterVideo(t *testing.T) {
|
||||||
assert.Equal(t, formats[2], sortedFormats[1])
|
assert.Equal(t, formats[2], sortedFormats[1])
|
||||||
assert.Equal(t, formats[0], sortedFormats[2])
|
assert.Equal(t, formats[0], sortedFormats[2])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSortThumbnails(t *testing.T) {
|
||||||
|
thumbnails := []youtubev2.Thumbnail{
|
||||||
|
{
|
||||||
|
Width: 250,
|
||||||
|
Height: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Width: 1920,
|
||||||
|
Height: 1080,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Width: 50,
|
||||||
|
Height: 33,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Width: 75,
|
||||||
|
Height: 51,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
media.SortYoutubeThumbnails(thumbnails)
|
||||||
|
|
||||||
|
assert.Equal(t, uint(250), thumbnails[0].Width)
|
||||||
|
assert.Equal(t, uint(1920), thumbnails[1].Width)
|
||||||
|
assert.Equal(t, uint(50), thumbnails[2].Width)
|
||||||
|
assert.Equal(t, uint(75), thumbnails[3].Width)
|
||||||
|
}
|
||||||
|
|
|
@ -211,6 +211,26 @@ func (c *mediaSetServiceController) GetVideo(request *pbmediaset.GetVideoRequest
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *mediaSetServiceController) GetVideoThumbnail(ctx context.Context, request *pbmediaset.GetVideoThumbnailRequest) (*pbmediaset.GetVideoThumbnailResponse, error) {
|
||||||
|
id, err := uuid.Parse(request.GetId())
|
||||||
|
if err != nil {
|
||||||
|
return nil, newResponseError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
thumbnail, err := c.mediaSetService.GetVideoThumbnail(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, newResponseError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
response := pbmediaset.GetVideoThumbnailResponse{
|
||||||
|
Image: thumbnail.Data,
|
||||||
|
Width: int32(thumbnail.Width),
|
||||||
|
Height: int32(thumbnail.Height),
|
||||||
|
}
|
||||||
|
|
||||||
|
return &response, nil
|
||||||
|
}
|
||||||
|
|
||||||
func Start(options Options) error {
|
func Start(options Options) error {
|
||||||
logger, err := buildLogger(options.Environment)
|
logger, err := buildLogger(options.Environment)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -20,3 +20,9 @@ UPDATE media_sets
|
||||||
SET video_s3_bucket = $2, video_s3_key = $3, video_s3_uploaded_at = NOW(), updated_at = NOW()
|
SET video_s3_bucket = $2, video_s3_key = $3, video_s3_uploaded_at = NOW(), updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: SetVideoThumbnailUploaded :one
|
||||||
|
UPDATE media_sets
|
||||||
|
SET video_thumbnail_width = $2, video_thumbnail_height = $3, video_thumbnail_mime_type = $4, video_thumbnail_s3_bucket = $5, video_thumbnail_s3_key = $6, video_thumbnail_s3_uploaded_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *;
|
||||||
|
|
|
@ -181,6 +181,7 @@ function App(): JSX.Element {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<VideoPreview
|
<VideoPreview
|
||||||
|
mediaSet={mediaSet}
|
||||||
video={video}
|
video={video}
|
||||||
position={position}
|
position={position}
|
||||||
duration={millisFromDuration(mediaSet.videoDuration)}
|
duration={millisFromDuration(mediaSet.videoDuration)}
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
|
import { MediaSet, MediaSetServiceClientImpl } from './generated/media_set';
|
||||||
|
import { newRPC } from './App';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
mediaSet: MediaSet;
|
||||||
position: number;
|
position: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
@ -8,6 +11,7 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VideoPreview: React.FC<Props> = ({
|
export const VideoPreview: React.FC<Props> = ({
|
||||||
|
mediaSet,
|
||||||
position,
|
position,
|
||||||
duration,
|
duration,
|
||||||
height,
|
height,
|
||||||
|
@ -17,6 +21,53 @@ export const VideoPreview: React.FC<Props> = ({
|
||||||
|
|
||||||
// effects
|
// effects
|
||||||
|
|
||||||
|
// load thumbnail, to display when the component is loaded for the first
|
||||||
|
// time. This is needed because of browser autoplay limitations.
|
||||||
|
useEffect(() => {
|
||||||
|
(async function () {
|
||||||
|
if (mediaSet == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = videoCanvasRef.current;
|
||||||
|
if (canvas == null) {
|
||||||
|
console.error('no canvas ref available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (ctx == null) {
|
||||||
|
console.error('no 2d context available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set aspect ratio.
|
||||||
|
canvas.width = canvas.height * (canvas.clientWidth / canvas.clientHeight);
|
||||||
|
|
||||||
|
console.log('getting video thumbnail...');
|
||||||
|
const rpc = newRPC();
|
||||||
|
const service = new MediaSetServiceClientImpl(rpc);
|
||||||
|
|
||||||
|
const thumbnail = await service.GetVideoThumbnail({ id: mediaSet.id });
|
||||||
|
|
||||||
|
console.log('got thumbnail', thumbnail);
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(
|
||||||
|
new Blob([thumbnail.image], { type: 'image/jpeg' })
|
||||||
|
);
|
||||||
|
const img = new Image(thumbnail.width, thumbnail.height);
|
||||||
|
|
||||||
|
img.src = url;
|
||||||
|
console.log('img', img);
|
||||||
|
img.onerror = console.error;
|
||||||
|
img.onload = () => {
|
||||||
|
ctx.drawImage(img, 0, 0, 177, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('set src to', url);
|
||||||
|
})();
|
||||||
|
}, [mediaSet]);
|
||||||
|
|
||||||
// render canvas
|
// render canvas
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// TODO: not sure if requestAnimationFrame is recommended here.
|
// TODO: not sure if requestAnimationFrame is recommended here.
|
||||||
|
|
|
@ -56,9 +56,20 @@ message GetVideoProgress {
|
||||||
string url = 2;
|
string url = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message GetVideoThumbnailRequest {
|
||||||
|
string id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetVideoThumbnailResponse {
|
||||||
|
bytes image = 1;
|
||||||
|
int32 width = 2;
|
||||||
|
int32 height = 3;
|
||||||
|
}
|
||||||
|
|
||||||
service MediaSetService {
|
service MediaSetService {
|
||||||
rpc Get(GetRequest) returns (MediaSet) {}
|
rpc Get(GetRequest) returns (MediaSet) {}
|
||||||
rpc GetAudio(GetAudioRequest) returns (stream GetAudioProgress) {}
|
rpc GetAudio(GetAudioRequest) returns (stream GetAudioProgress) {}
|
||||||
rpc GetAudioSegment(GetAudioSegmentRequest) returns (GetAudioSegmentResponse) {}
|
rpc GetAudioSegment(GetAudioSegmentRequest) returns (GetAudioSegmentResponse) {}
|
||||||
rpc GetVideo(GetVideoRequest) returns (stream GetVideoProgress) {}
|
rpc GetVideo(GetVideoRequest) returns (stream GetVideoProgress) {}
|
||||||
|
rpc GetVideoThumbnail(GetVideoThumbnailRequest) returns (GetVideoThumbnailResponse) {}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue