Compare commits

..

1 Commits

Author SHA1 Message Date
Michael Evans 374137256e Proof of concept for input field with routing to video page
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2022-01-02 01:32:04 -06:00
79 changed files with 8896 additions and 9424 deletions

View File

@ -1,16 +1,17 @@
---
kind: pipeline
type: kubernetes
type: docker
name: default
steps:
- name: backend-go1.19
image: golang:1.19
- name: backend
image: golang:1.17
commands:
- cd backend/
- go install honnef.co/go/tools/cmd/staticcheck@latest
- go build ./...
- go vet ./...
# - go run honnef.co/go/tools/cmd/staticcheck@latest ./...
- staticcheck ./...
- go test -bench=. -benchmem -cover ./...
- name: frontend
@ -19,4 +20,4 @@ steps:
- cd frontend
- yarn
- yarn build
- yarn test
- yarn test

View File

@ -11,7 +11,7 @@ ENV REACT_APP_API_URL=$API_URL
RUN yarn install
RUN yarn build
FROM golang:1.18beta1-alpine3.14 as go-builder
FROM golang:1.17.3-alpine3.14 as go-builder
ENV GOPATH ""
RUN go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
@ -30,6 +30,6 @@ COPY --from=go-builder /app/clipper /bin/clipper
COPY --from=go-builder /root/go/bin/migrate /bin/migrate
COPY --from=node-builder /app/build /app/assets
ENV CLIPPER_ASSETS_HTTP_ROOT "/app/assets"
ENV ASSETS_HTTP_ROOT "/app/assets"
ENTRYPOINT ["/bin/clipper"]

View File

@ -1,39 +1,30 @@
CLIPPER_ENV=development # or production
ENV=development # or production
CLIPPER_BIND_ADDR=localhost:8888
# Required if serving grpc-web, assets, etc from a different hostname.
# Multiple domains can be separated with commas.
#
# Example: http://localhost:3000
CLIPPER_CORS_ALLOWED_ORIGINS=
BIND_ADDR=localhost:8888
# PostgreSQL connection string.
CLIPPER_DATABASE_URL=
DATABASE_URL=
# Optional. If set, files in this location will be served over HTTP at /.
# Mostly useful for deployment.
CLIPPER_ASSETS_HTTP_ROOT=
ASSETS_HTTP_ROOT=
# Set the store type - either s3 or filesystem. Defaults to filesystem. The S3
# store is recommended for production usage.
#
# NOTE: Enabling the file system store will disable serving assets over HTTP.
CLIPPER_FILE_STORE=filesystem
FILE_STORE=filesystem
# The base URL used for serving file store assets.
# Example: http://localhost:8888
CLIPPER_FILE_STORE_HTTP_BASE_URL=
FILE_STORE_HTTP_BASE_URL=
# The root directory for the file system store.
CLIPPER_FILE_STORE_HTTP_ROOT=data/
FILE_STORE_HTTP_ROOT=data/
# AWS credentials, required for the S3 store.
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_REGION=
CLIPPER_S3_BUCKET=
# The number of concurrent FFMPEG processes that will be permitted.
# Defaults to runtime.NumCPU():
CLIPPER_FFMPEG_WORKER_POOL_SIZE=
S3_BUCKET=

View File

@ -19,9 +19,8 @@ import (
)
const (
defaultTimeout = 600 * time.Second
defaultURLExpiry = time.Hour
maximumWorkerQueueSize = 32
defaultTimeout = 600 * time.Second
defaultURLExpiry = time.Hour
)
func main() {
@ -55,17 +54,12 @@ func main() {
log.Fatal(err)
}
// Create a worker pool
wp := media.NewWorkerPool(config.FFmpegWorkerPoolSize, maximumWorkerQueueSize, logger.Sugar().Named("FFmpegWorkerPool"))
wp.Run()
log.Fatal(server.Start(server.Options{
Config: config,
Timeout: defaultTimeout,
Store: store,
YoutubeClient: &youtubeClient,
FileStore: fileStore,
WorkerPool: wp,
Logger: logger,
}))
}

View File

@ -3,11 +3,7 @@ package config
import (
"errors"
"fmt"
"net/url"
"os"
"runtime"
"strconv"
"strings"
)
type Environment int
@ -20,12 +16,10 @@ const (
type FileStore int
const (
FileSystemStore FileStore = iota
FileSystemStore = iota
S3Store
)
const DefaultBindAddr = "localhost:8888"
type Config struct {
Environment Environment
BindAddr string
@ -34,73 +28,58 @@ type Config struct {
DatabaseURL string
FileStore FileStore
FileStoreHTTPRoot string
FileStoreHTTPBaseURL *url.URL
FileStoreHTTPBaseURL string
AWSAccessKeyID string
AWSSecretAccessKey string
AWSRegion string
S3Bucket string
AssetsHTTPRoot string
FFmpegWorkerPoolSize int
CORSAllowedOrigins []string
}
const Prefix = "CLIPPER_"
func envPrefix(k string) string { return Prefix + k }
func getenvPrefix(k string) string { return os.Getenv(Prefix + k) }
func NewFromEnv() (Config, error) {
envVarName := envPrefix("ENV")
envString := os.Getenv(envVarName)
envString := os.Getenv("ENV")
var env Environment
switch envString {
case "production":
env = EnvProduction
case "development", "":
case "development":
env = EnvDevelopment
case "":
return Config{}, errors.New("ENV not set")
default:
return Config{}, fmt.Errorf("invalid %s value: %s", envVarName, envString)
return Config{}, fmt.Errorf("invalid ENV value: %s", envString)
}
bindAddr := getenvPrefix("BIND_ADDR")
bindAddr := os.Getenv("BIND_ADDR")
if bindAddr == "" {
bindAddr = DefaultBindAddr
bindAddr = "localhost:8888"
}
tlsCertFileName := envPrefix("TLS_CERT_FILE")
tlsKeyFileName := envPrefix("TLS_KEY_FILE")
tlsCertFile := os.Getenv(tlsCertFileName)
tlsKeyFile := os.Getenv(tlsKeyFileName)
tlsCertFile := os.Getenv("TLS_CERT_FILE")
tlsKeyFile := os.Getenv("TLS_KEY_FILE")
if (tlsCertFile == "" && tlsKeyFile != "") || (tlsCertFile != "" && tlsKeyFile == "") {
return Config{}, fmt.Errorf("both %s and %s must be set", tlsCertFileName, tlsKeyFileName)
return Config{}, errors.New("both TLS_CERT_FILE and TLS_KEY_FILE must be set")
}
databaseURLName := envPrefix("DATABASE_URL")
databaseURL := os.Getenv(databaseURLName)
databaseURL := os.Getenv("DATABASE_URL")
if databaseURL == "" {
return Config{}, fmt.Errorf("%s not set", databaseURLName)
return Config{}, errors.New("DATABASE_URL not set")
}
fileStoreName := envPrefix("FILE_STORE")
fileStoreString := os.Getenv(fileStoreName)
fileStoreString := os.Getenv("FILE_STORE")
var fileStore FileStore
switch getenvPrefix("FILE_STORE") {
switch os.Getenv("FILE_STORE") {
case "s3":
fileStore = S3Store
case "filesystem", "":
fileStore = FileSystemStore
default:
return Config{}, fmt.Errorf("invalid %s value: %s", fileStoreName, fileStoreString)
return Config{}, fmt.Errorf("invalid FILE_STORE value: %s", fileStoreString)
}
fileStoreHTTPBaseURLName := envPrefix("FILE_STORE_HTTP_BASE_URL")
fileStoreHTTPBaseURLString := os.Getenv(fileStoreHTTPBaseURLName)
if !strings.HasSuffix(fileStoreHTTPBaseURLString, "/") {
fileStoreHTTPBaseURLString += "/"
}
fileStoreHTTPBaseURL, err := url.Parse(fileStoreHTTPBaseURLString)
if err != nil {
return Config{}, fmt.Errorf("invalid %s: %v", fileStoreHTTPBaseURLName, fileStoreHTTPBaseURLString)
fileStoreHTTPBaseURL := os.Getenv("FILE_STORE_HTTP_BASE_URL")
if fileStoreHTTPBaseURL == "" {
fileStoreHTTPBaseURL = "/"
}
var awsAccessKeyID, awsSecretAccessKey, awsRegion, s3Bucket, fileStoreHTTPRoot string
@ -120,33 +99,17 @@ func NewFromEnv() (Config, error) {
return Config{}, errors.New("AWS_REGION not set")
}
s3Bucket = getenvPrefix("S3_BUCKET")
s3Bucket = os.Getenv("S3_BUCKET")
if s3Bucket == "" {
return Config{}, errors.New("S3_BUCKET not set")
}
} else {
if fileStoreHTTPRoot = getenvPrefix("FILE_STORE_HTTP_ROOT"); fileStoreHTTPRoot == "" {
if fileStoreHTTPRoot = os.Getenv("FILE_STORE_HTTP_ROOT"); fileStoreHTTPRoot == "" {
return Config{}, errors.New("FILE_STORE_HTTP_ROOT not set")
}
}
assetsHTTPRoot := getenvPrefix("ASSETS_HTTP_ROOT")
ffmpegWorkerPoolSize := runtime.NumCPU()
ffmpegWorkerPoolSizeName := envPrefix("FFMPEG_WORKER_POOL_SIZE")
if s := os.Getenv(ffmpegWorkerPoolSizeName); s != "" {
if n, err := strconv.Atoi(s); err != nil {
return Config{}, fmt.Errorf("invalid %s value: %s", ffmpegWorkerPoolSizeName, s)
} else {
ffmpegWorkerPoolSize = n
}
}
var corsAllowedOrigins []string
corsAllowedOriginsName := envPrefix("CORS_ALLOWED_ORIGINS")
if s := os.Getenv(corsAllowedOriginsName); s != "" {
corsAllowedOrigins = strings.Split(s, ",")
}
assetsHTTPRoot := os.Getenv("ASSETS_HTTP_ROOT")
return Config{
Environment: env,
@ -162,7 +125,5 @@ func NewFromEnv() (Config, error) {
AssetsHTTPRoot: assetsHTTPRoot,
FileStoreHTTPRoot: fileStoreHTTPRoot,
FileStoreHTTPBaseURL: fileStoreHTTPBaseURL,
FFmpegWorkerPoolSize: ffmpegWorkerPoolSize,
CORSAllowedOrigins: corsAllowedOrigins,
}, nil
}

View File

@ -1,294 +0,0 @@
package config_test
import (
"net/url"
"os"
"runtime"
"strings"
"testing"
"git.netflux.io/rob/clipper/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type env map[string]string
// clearenv clears prefixed keys from the environment. Currently it does not
// clear AWS_* environment variables.
func clearenv() {
for _, kv := range os.Environ() {
split := strings.SplitN(kv, "=", 2)
k := split[0]
if !strings.HasPrefix(k, config.Prefix) {
continue
}
os.Unsetenv(k)
}
}
// setupenv sets up a valid environment, including AWS_* configuration
// variables.
func setupenv() {
e := env{
"CLIPPER_ENV": "development",
"CLIPPER_DATABASE_URL": "postgresql://localhost:5432/db",
"CLIPPER_FILE_STORE_HTTP_ROOT": "/data",
"CLIPPER_S3_BUCKET": "bucket",
"AWS_ACCESS_KEY_ID": "key",
"AWS_SECRET_ACCESS_KEY": "secret",
"AWS_REGION": "eu-west-1",
}
for k, v := range e {
os.Setenv(k, v)
}
}
func mustParseURL(t *testing.T, u string) *url.URL {
pu, err := url.Parse(u)
require.NoError(t, err)
return pu
}
func TestNewFromEnv(t *testing.T) {
t.Run("ENV", func(t *testing.T) {
defer clearenv()
setupenv()
os.Setenv("CLIPPER_ENV", "foo")
c, err := config.NewFromEnv()
assert.EqualError(t, err, "invalid CLIPPER_ENV value: foo")
os.Setenv("CLIPPER_ENV", "")
c, err = config.NewFromEnv()
require.NoError(t, err)
assert.Equal(t, config.EnvDevelopment, c.Environment)
os.Setenv("CLIPPER_ENV", "development")
c, err = config.NewFromEnv()
require.NoError(t, err)
assert.Equal(t, config.EnvDevelopment, c.Environment)
os.Setenv("CLIPPER_ENV", "production")
c, err = config.NewFromEnv()
require.NoError(t, err)
assert.Equal(t, config.EnvProduction, c.Environment)
})
t.Run("BIND_ADDR", func(t *testing.T) {
defer clearenv()
setupenv()
os.Setenv("CLIPPER_BIND_ADDR", "")
c, err := config.NewFromEnv()
require.NoError(t, err)
assert.Equal(t, config.DefaultBindAddr, c.BindAddr)
os.Setenv("CLIPPER_BIND_ADDR", "example.com:1234")
c, err = config.NewFromEnv()
require.NoError(t, err)
assert.Equal(t, "example.com:1234", c.BindAddr)
})
t.Run("TLS_CERT_FILE and TLS_KEY_FILE", func(t *testing.T) {
defer clearenv()
setupenv()
c, err := config.NewFromEnv()
require.NoError(t, err)
assert.Equal(t, "", c.TLSCertFile)
assert.Equal(t, "", c.TLSKeyFile)
const expErr = "both CLIPPER_TLS_CERT_FILE and CLIPPER_TLS_KEY_FILE must be set"
os.Setenv("CLIPPER_TLS_CERT_FILE", "foo")
os.Setenv("CLIPPER_TLS_KEY_FILE", "")
c, err = config.NewFromEnv()
assert.EqualError(t, err, expErr)
os.Setenv("CLIPPER_TLS_CERT_FILE", "")
os.Setenv("CLIPPER_TLS_KEY_FILE", "bar")
c, err = config.NewFromEnv()
assert.EqualError(t, err, expErr)
os.Setenv("CLIPPER_TLS_CERT_FILE", "foo")
os.Setenv("CLIPPER_TLS_KEY_FILE", "bar")
c, err = config.NewFromEnv()
require.NoError(t, err)
assert.Equal(t, "foo", c.TLSCertFile)
assert.Equal(t, "bar", c.TLSKeyFile)
})
t.Run("DATABASE_URL", func(t *testing.T) {
defer clearenv()
setupenv()
os.Unsetenv("CLIPPER_DATABASE_URL")
_, err := config.NewFromEnv()
assert.EqualError(t, err, "CLIPPER_DATABASE_URL not set")
os.Setenv("CLIPPER_DATABASE_URL", "foo")
c, err := config.NewFromEnv()
require.NoError(t, err)
assert.Equal(t, "foo", c.DatabaseURL)
})
t.Run("FILE_STORE", func(t *testing.T) {
defer clearenv()
setupenv()
os.Unsetenv("CLIPPER_FILE_STORE")
c, err := config.NewFromEnv()
require.NoError(t, err)
assert.Equal(t, config.FileSystemStore, c.FileStore)
os.Setenv("CLIPPER_FILE_STORE", "foo")
c, err = config.NewFromEnv()
assert.EqualError(t, err, "invalid CLIPPER_FILE_STORE value: foo")
os.Setenv("CLIPPER_FILE_STORE", "filesystem")
c, err = config.NewFromEnv()
require.NoError(t, err)
assert.Equal(t, config.FileSystemStore, c.FileStore)
os.Setenv("CLIPPER_FILE_STORE", "s3")
c, err = config.NewFromEnv()
require.NoError(t, err)
assert.Equal(t, config.S3Store, c.FileStore)
})
t.Run("FILE_STORE_HTTP_ROOT", func(t *testing.T) {
defer clearenv()
setupenv()
os.Unsetenv("CLIPPER_FILE_STORE_HTTP_ROOT")
_, err := config.NewFromEnv()
require.EqualError(t, err, "FILE_STORE_HTTP_ROOT not set")
os.Setenv("CLIPPER_FILE_STORE_HTTP_ROOT", "/foo")
c, err := config.NewFromEnv()
require.NoError(t, err)
assert.Equal(t, "/foo", c.FileStoreHTTPRoot)
})
t.Run("FILE_STORE_HTTP_BASE_URL", func(t *testing.T) {
defer clearenv()
setupenv()
os.Setenv("CLIPPER_FILE_STORE_HTTP_BASE_URL", "%%")
_, err := config.NewFromEnv()
require.EqualError(t, err, "invalid CLIPPER_FILE_STORE_HTTP_BASE_URL: %%/")
os.Unsetenv("CLIPPER_FILE_STORE_HTTP_BASE_URL")
c, err := config.NewFromEnv()
require.NoError(t, err)
assert.Equal(t, mustParseURL(t, "/"), c.FileStoreHTTPBaseURL)
os.Setenv("CLIPPER_FILE_STORE_HTTP_BASE_URL", "/foo")
c, err = config.NewFromEnv()
require.NoError(t, err)
assert.Equal(t, mustParseURL(t, "/foo/"), c.FileStoreHTTPBaseURL)
os.Setenv("CLIPPER_FILE_STORE_HTTP_BASE_URL", "/foo/")
c, err = config.NewFromEnv()
require.NoError(t, err)
assert.Equal(t, mustParseURL(t, "/foo/"), c.FileStoreHTTPBaseURL)
os.Setenv("CLIPPER_FILE_STORE_HTTP_BASE_URL", "https://www.example.com/foo")
c, err = config.NewFromEnv()
require.NoError(t, err)
assert.Equal(t, mustParseURL(t, "https://www.example.com/foo/"), c.FileStoreHTTPBaseURL)
})
t.Run("ASSETS_HTTP_ROOT", func(t *testing.T) {
defer clearenv()
setupenv()
os.Unsetenv("CLIPPER_ASSETS_HTTP_ROOT")
c, err := config.NewFromEnv()
require.NoError(t, err)
assert.Equal(t, "", c.AssetsHTTPRoot)
os.Setenv("CLIPPER_ASSETS_HTTP_ROOT", "/bar")
c, err = config.NewFromEnv()
require.NoError(t, err)
assert.Equal(t, "/bar", c.AssetsHTTPRoot)
})
t.Run("FFMPEG_WORKER_POOL_SIZE", func(t *testing.T) {
defer clearenv()
setupenv()
os.Setenv("CLIPPER_FFMPEG_WORKER_POOL_SIZE", "nope")
c, err := config.NewFromEnv()
assert.EqualError(t, err, "invalid CLIPPER_FFMPEG_WORKER_POOL_SIZE value: nope")
os.Unsetenv("CLIPPER_FFMPEG_WORKER_POOL_SIZE")
c, err = config.NewFromEnv()
require.NoError(t, err)
assert.Equal(t, runtime.NumCPU(), c.FFmpegWorkerPoolSize)
os.Setenv("CLIPPER_FFMPEG_WORKER_POOL_SIZE", "10")
c, err = config.NewFromEnv()
require.NoError(t, err)
assert.Equal(t, 10, c.FFmpegWorkerPoolSize)
})
t.Run("AWS configuration", func(t *testing.T) {
defer clearenv()
setupenv()
os.Setenv("CLIPPER_FILE_STORE", "s3")
os.Unsetenv("AWS_ACCESS_KEY_ID")
_, err := config.NewFromEnv()
assert.EqualError(t, err, "AWS_ACCESS_KEY_ID not set")
os.Setenv("AWS_ACCESS_KEY_ID", "key")
os.Unsetenv("AWS_SECRET_ACCESS_KEY")
_, err = config.NewFromEnv()
assert.EqualError(t, err, "AWS_SECRET_ACCESS_KEY not set")
os.Setenv("AWS_ACCESS_KEY_ID", "key")
os.Setenv("AWS_SECRET_ACCESS_KEY", "secret")
os.Unsetenv("AWS_REGION")
_, err = config.NewFromEnv()
assert.EqualError(t, err, "AWS_REGION not set")
os.Setenv("AWS_ACCESS_KEY_ID", "key")
os.Setenv("AWS_SECRET_ACCESS_KEY", "secret")
os.Setenv("AWS_REGION", "eu-west-1")
os.Unsetenv("CLIPPER_S3_BUCKET")
_, err = config.NewFromEnv()
assert.EqualError(t, err, "S3_BUCKET not set")
os.Setenv("AWS_ACCESS_KEY_ID", "key")
os.Setenv("AWS_SECRET_ACCESS_KEY", "secret")
os.Setenv("AWS_REGION", "eu-west-1")
os.Setenv("CLIPPER_S3_BUCKET", "bucket")
c, err := config.NewFromEnv()
require.NoError(t, err)
assert.Equal(t, "key", c.AWSAccessKeyID)
assert.Equal(t, "secret", c.AWSSecretAccessKey)
assert.Equal(t, "eu-west-1", c.AWSRegion)
assert.Equal(t, "bucket", c.S3Bucket)
})
t.Run("CORS_ALLOWED_ORIGINS", func(t *testing.T) {
defer clearenv()
setupenv()
os.Setenv("CLIPPER_CORS_ALLOWED_ORIGINS", "")
c, err := config.NewFromEnv()
require.NoError(t, err)
assert.Nil(t, c.CORSAllowedOrigins)
os.Setenv("CLIPPER_CORS_ALLOWED_ORIGINS", "*")
c, err = config.NewFromEnv()
require.NoError(t, err)
assert.Equal(t, []string{"*"}, c.CORSAllowedOrigins)
os.Setenv("CLIPPER_CORS_ALLOWED_ORIGINS", "https://www1.example.com,https://www2.example.com")
c, err = config.NewFromEnv()
require.NoError(t, err)
assert.Equal(t, []string{"https://www1.example.com", "https://www2.example.com"}, c.CORSAllowedOrigins)
})
}

View File

@ -4,20 +4,12 @@ import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
)
// NewFileSystemStoreHTTPMiddleware returns an HTTP middleware which strips the
// base URL path prefix from incoming paths, suitable for passing to an
// appropriately-configured FileSystemStore.
func NewFileSystemStoreHTTPMiddleware(baseURL *url.URL, next http.Handler) http.Handler {
return http.StripPrefix(baseURL.Path, next)
}
// FileSystemStore is a file store that stores files on the local filesystem.
// It is currently intended for usage in a development environment.
type FileSystemStore struct {
@ -29,12 +21,15 @@ type FileSystemStore struct {
// which is the storage location on the local file system for stored objects,
// and a baseURL which is a URL which should be configured to serve the stored
// files over HTTP.
func NewFileSystemStore(rootPath string, baseURL *url.URL) (*FileSystemStore, error) {
url := *baseURL
func NewFileSystemStore(rootPath string, baseURL string) (*FileSystemStore, error) {
url, err := url.Parse(baseURL)
if err != nil {
return nil, fmt.Errorf("error parsing URL: %v", err)
}
if !strings.HasSuffix(url.Path, "/") {
url.Path += "/"
}
return &FileSystemStore{rootPath: rootPath, baseURL: &url}, nil
return &FileSystemStore{rootPath: rootPath, baseURL: url}, nil
}
// GetObject retrieves an object from the local filesystem.

View File

@ -3,7 +3,6 @@ package filestore_test
import (
"context"
"io/ioutil"
"net/url"
"os"
"path"
"strings"
@ -15,9 +14,7 @@ import (
)
func TestFileStoreGetObject(t *testing.T) {
baseURL, err := url.Parse("/")
require.NoError(t, err)
store, err := filestore.NewFileSystemStore("testdata/", baseURL)
store, err := filestore.NewFileSystemStore("testdata/", "/")
require.NoError(t, err)
reader, err := store.GetObject(context.Background(), "file.txt")
require.NoError(t, err)
@ -63,9 +60,7 @@ func TestFileStoreGetObjectWithRange(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
baseURL, err := url.Parse("/")
require.NoError(t, err)
store, err := filestore.NewFileSystemStore("testdata/", baseURL)
store, err := filestore.NewFileSystemStore("testdata/", "/")
require.NoError(t, err)
reader, err := store.GetObjectWithRange(context.Background(), "file.txt", tc.start, tc.end)
@ -118,9 +113,7 @@ func TestFileStoreGetURL(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
baseURL, err := url.Parse(tc.baseURL)
require.NoError(t, err)
store, err := filestore.NewFileSystemStore("testdata/", baseURL)
store, err := filestore.NewFileSystemStore("testdata/", tc.baseURL)
require.NoError(t, err)
url, err := store.GetURL(context.Background(), tc.key)
@ -156,9 +149,7 @@ func TestFileStorePutObject(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
baseURL, err := url.Parse("/")
require.NoError(t, err)
store, err := filestore.NewFileSystemStore(rootPath, baseURL)
store, err := filestore.NewFileSystemStore(rootPath, "/")
require.NoError(t, err)
n, err := store.PutObject(context.Background(), tc.key, strings.NewReader(tc.content), "text/plain")

View File

@ -1,36 +0,0 @@
// Code generated by mockery v2.9.4. DO NOT EDIT.
package mocks
import (
context "context"
media "git.netflux.io/rob/clipper/media"
mock "github.com/stretchr/testify/mock"
)
// AudioSegmentStream is an autogenerated mock type for the AudioSegmentStream type
type AudioSegmentStream struct {
mock.Mock
}
// Next provides a mock function with given fields: ctx
func (_m *AudioSegmentStream) Next(ctx context.Context) (media.AudioSegmentProgress, error) {
ret := _m.Called(ctx)
var r0 media.AudioSegmentProgress
if rf, ok := ret.Get(0).(func(context.Context) media.AudioSegmentProgress); ok {
r0 = rf(ctx)
} else {
r0 = ret.Get(0).(media.AudioSegmentProgress)
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context) error); ok {
r1 = rf(ctx)
} else {
r1 = ret.Error(1)
}
return r0, r1
}

View File

@ -1,153 +0,0 @@
// Code generated by mockery v2.9.4. DO NOT EDIT.
package mocks
import (
context "context"
media "git.netflux.io/rob/clipper/media"
mock "github.com/stretchr/testify/mock"
uuid "github.com/google/uuid"
)
// MediaSetService is an autogenerated mock type for the MediaSetService type
type MediaSetService struct {
mock.Mock
}
// Get provides a mock function with given fields: _a0, _a1
func (_m *MediaSetService) Get(_a0 context.Context, _a1 string) (*media.MediaSet, error) {
ret := _m.Called(_a0, _a1)
var r0 *media.MediaSet
if rf, ok := ret.Get(0).(func(context.Context, string) *media.MediaSet); ok {
r0 = rf(_a0, _a1)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*media.MediaSet)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(_a0, _a1)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetAudioSegment provides a mock function with given fields: _a0, _a1, _a2, _a3, _a4
func (_m *MediaSetService) GetAudioSegment(_a0 context.Context, _a1 uuid.UUID, _a2 int64, _a3 int64, _a4 media.AudioFormat) (media.AudioSegmentStream, error) {
ret := _m.Called(_a0, _a1, _a2, _a3, _a4)
var r0 media.AudioSegmentStream
if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID, int64, int64, media.AudioFormat) media.AudioSegmentStream); ok {
r0 = rf(_a0, _a1, _a2, _a3, _a4)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(media.AudioSegmentStream)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, uuid.UUID, int64, int64, media.AudioFormat) error); ok {
r1 = rf(_a0, _a1, _a2, _a3, _a4)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetPeaks provides a mock function with given fields: _a0, _a1, _a2
func (_m *MediaSetService) GetPeaks(_a0 context.Context, _a1 uuid.UUID, _a2 int) (media.GetPeaksProgressReader, error) {
ret := _m.Called(_a0, _a1, _a2)
var r0 media.GetPeaksProgressReader
if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID, int) media.GetPeaksProgressReader); ok {
r0 = rf(_a0, _a1, _a2)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(media.GetPeaksProgressReader)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, uuid.UUID, int) error); ok {
r1 = rf(_a0, _a1, _a2)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetPeaksForSegment provides a mock function with given fields: _a0, _a1, _a2, _a3, _a4
func (_m *MediaSetService) GetPeaksForSegment(_a0 context.Context, _a1 uuid.UUID, _a2 int64, _a3 int64, _a4 int) ([]int16, error) {
ret := _m.Called(_a0, _a1, _a2, _a3, _a4)
var r0 []int16
if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID, int64, int64, int) []int16); ok {
r0 = rf(_a0, _a1, _a2, _a3, _a4)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]int16)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, uuid.UUID, int64, int64, int) error); ok {
r1 = rf(_a0, _a1, _a2, _a3, _a4)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetVideo provides a mock function with given fields: _a0, _a1
func (_m *MediaSetService) GetVideo(_a0 context.Context, _a1 uuid.UUID) (media.GetVideoProgressReader, error) {
ret := _m.Called(_a0, _a1)
var r0 media.GetVideoProgressReader
if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) media.GetVideoProgressReader); ok {
r0 = rf(_a0, _a1)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(media.GetVideoProgressReader)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, uuid.UUID) error); ok {
r1 = rf(_a0, _a1)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetVideoThumbnail provides a mock function with given fields: _a0, _a1
func (_m *MediaSetService) GetVideoThumbnail(_a0 context.Context, _a1 uuid.UUID) (media.VideoThumbnail, error) {
ret := _m.Called(_a0, _a1)
var r0 media.VideoThumbnail
if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) media.VideoThumbnail); ok {
r0 = rf(_a0, _a1)
} else {
r0 = ret.Get(0).(media.VideoThumbnail)
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, uuid.UUID) error); ok {
r1 = rf(_a0, _a1)
} else {
r1 = ret.Error(1)
}
return r0, r1
}

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.27.1
// protoc v3.19.1
// protoc v3.17.3
// source: media_set.proto
package media_set
@ -74,9 +74,6 @@ type MediaSet struct {
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
YoutubeId string `protobuf:"bytes,2,opt,name=youtube_id,json=youtubeId,proto3" json:"youtube_id,omitempty"`
Title string `protobuf:"bytes,12,opt,name=title,proto3" json:"title,omitempty"`
Description string `protobuf:"bytes,13,opt,name=description,proto3" json:"description,omitempty"`
Author string `protobuf:"bytes,14,opt,name=author,proto3" json:"author,omitempty"`
AudioChannels int32 `protobuf:"varint,3,opt,name=audio_channels,json=audioChannels,proto3" json:"audio_channels,omitempty"`
AudioApproxFrames int64 `protobuf:"varint,4,opt,name=audio_approx_frames,json=audioApproxFrames,proto3" json:"audio_approx_frames,omitempty"`
AudioFrames int64 `protobuf:"varint,5,opt,name=audio_frames,json=audioFrames,proto3" json:"audio_frames,omitempty"`
@ -134,27 +131,6 @@ func (x *MediaSet) GetYoutubeId() string {
return ""
}
func (x *MediaSet) GetTitle() string {
if x != nil {
return x.Title
}
return ""
}
func (x *MediaSet) GetDescription() string {
if x != nil {
return x.Description
}
return ""
}
func (x *MediaSet) GetAuthor() string {
if x != nil {
return x.Author
}
return ""
}
func (x *MediaSet) GetAudioChannels() int32 {
if x != nil {
return x.AudioChannels
@ -328,7 +304,6 @@ type GetPeaksProgress struct {
Peaks []int32 `protobuf:"varint,1,rep,packed,name=peaks,proto3" json:"peaks,omitempty"`
PercentComplete float32 `protobuf:"fixed32,2,opt,name=percent_complete,json=percentComplete,proto3" json:"percent_complete,omitempty"`
Url string `protobuf:"bytes,3,opt,name=url,proto3" json:"url,omitempty"`
AudioFrames int64 `protobuf:"varint,4,opt,name=audio_frames,json=audioFrames,proto3" json:"audio_frames,omitempty"`
}
func (x *GetPeaksProgress) Reset() {
@ -384,13 +359,6 @@ func (x *GetPeaksProgress) GetUrl() string {
return ""
}
func (x *GetPeaksProgress) GetAudioFrames() int64 {
if x != nil {
return x.AudioFrames
}
return 0
}
type GetPeaksForSegmentRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
@ -585,6 +553,8 @@ type GetAudioSegmentProgress struct {
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
MimeType string `protobuf:"bytes,1,opt,name=mime_type,json=mimeType,proto3" json:"mime_type,omitempty"`
Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"`
PercentComplete float32 `protobuf:"fixed32,3,opt,name=percent_complete,json=percentComplete,proto3" json:"percent_complete,omitempty"`
AudioData []byte `protobuf:"bytes,4,opt,name=audio_data,json=audioData,proto3" json:"audio_data,omitempty"`
}
@ -621,6 +591,20 @@ func (*GetAudioSegmentProgress) Descriptor() ([]byte, []int) {
return file_media_set_proto_rawDescGZIP(), []int{7}
}
func (x *GetAudioSegmentProgress) GetMimeType() string {
if x != nil {
return x.MimeType
}
return ""
}
func (x *GetAudioSegmentProgress) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
func (x *GetAudioSegmentProgress) GetPercentComplete() float32 {
if x != nil {
return x.PercentComplete
@ -853,16 +837,11 @@ var file_media_set_proto_rawDesc = []byte{
0x0a, 0x0f, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74,
0x6f, 0x12, 0x09, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x1a, 0x1e, 0x67, 0x6f,
0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x64, 0x75,
0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x9d, 0x04, 0x0a,
0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xcd, 0x03, 0x0a,
0x08, 0x4d, 0x65, 0x64, 0x69, 0x61, 0x53, 0x65, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18,
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x79, 0x6f, 0x75,
0x74, 0x75, 0x62, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x79,
0x6f, 0x75, 0x74, 0x75, 0x62, 0x65, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x69, 0x74, 0x6c,
0x65, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x12, 0x20,
0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x0d, 0x20,
0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e,
0x12, 0x16, 0x0a, 0x06, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09,
0x52, 0x06, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x75, 0x64, 0x69,
0x6f, 0x75, 0x74, 0x75, 0x62, 0x65, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x75, 0x64, 0x69,
0x6f, 0x5f, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05,
0x52, 0x0d, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x12,
0x2e, 0x0a, 0x13, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x5f, 0x61, 0x70, 0x70, 0x72, 0x6f, 0x78, 0x5f,
@ -894,95 +873,96 @@ var file_media_set_proto_rawDesc = []byte{
0x50, 0x65, 0x61, 0x6b, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02,
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x19, 0x0a, 0x08,
0x6e, 0x75, 0x6d, 0x5f, 0x62, 0x69, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07,
0x6e, 0x75, 0x6d, 0x42, 0x69, 0x6e, 0x73, 0x22, 0x88, 0x01, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x50,
0x65, 0x61, 0x6b, 0x73, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x12, 0x14, 0x0a, 0x05,
0x70, 0x65, 0x61, 0x6b, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x05, 0x52, 0x05, 0x70, 0x65, 0x61,
0x6b, 0x73, 0x12, 0x29, 0x0a, 0x10, 0x70, 0x65, 0x72, 0x63, 0x65, 0x6e, 0x74, 0x5f, 0x63, 0x6f,
0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x02, 0x52, 0x0f, 0x70, 0x65,
0x72, 0x63, 0x65, 0x6e, 0x74, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x10, 0x0a,
0x03, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12,
0x21, 0x0a, 0x0c, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x5f, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x73, 0x18,
0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x46, 0x72, 0x61, 0x6d,
0x65, 0x73, 0x22, 0x84, 0x01, 0x0a, 0x19, 0x47, 0x65, 0x74, 0x50, 0x65, 0x61, 0x6b, 0x73, 0x46,
0x6f, 0x72, 0x53, 0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64,
0x12, 0x19, 0x0a, 0x08, 0x6e, 0x75, 0x6d, 0x5f, 0x62, 0x69, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x01,
0x28, 0x05, 0x52, 0x07, 0x6e, 0x75, 0x6d, 0x42, 0x69, 0x6e, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x73,
0x74, 0x61, 0x72, 0x74, 0x5f, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03,
0x52, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, 0x46, 0x72, 0x61, 0x6d, 0x65, 0x12, 0x1b, 0x0a, 0x09,
0x65, 0x6e, 0x64, 0x5f, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52,
0x08, 0x65, 0x6e, 0x64, 0x46, 0x72, 0x61, 0x6d, 0x65, 0x22, 0x32, 0x0a, 0x1a, 0x47, 0x65, 0x74,
0x50, 0x65, 0x61, 0x6b, 0x73, 0x46, 0x6f, 0x72, 0x53, 0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x52,
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x70, 0x65, 0x61, 0x6b, 0x73,
0x18, 0x01, 0x20, 0x03, 0x28, 0x05, 0x52, 0x05, 0x70, 0x65, 0x61, 0x6b, 0x73, 0x22, 0x96, 0x01,
0x0a, 0x16, 0x47, 0x65, 0x74, 0x41, 0x75, 0x64, 0x69, 0x6f, 0x53, 0x65, 0x67, 0x6d, 0x65, 0x6e,
0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01,
0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x74, 0x61, 0x72,
0x74, 0x5f, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x73,
0x74, 0x61, 0x72, 0x74, 0x46, 0x72, 0x61, 0x6d, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x65, 0x6e, 0x64,
0x5f, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x65, 0x6e,
0x64, 0x46, 0x72, 0x61, 0x6d, 0x65, 0x12, 0x2e, 0x0a, 0x06, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74,
0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73,
0x65, 0x74, 0x2e, 0x41, 0x75, 0x64, 0x69, 0x6f, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x52, 0x06,
0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x22, 0x63, 0x0a, 0x17, 0x47, 0x65, 0x74, 0x41, 0x75, 0x64,
0x69, 0x6f, 0x53, 0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73,
0x6e, 0x75, 0x6d, 0x42, 0x69, 0x6e, 0x73, 0x22, 0x65, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x50, 0x65,
0x61, 0x6b, 0x73, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x70,
0x65, 0x61, 0x6b, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x05, 0x52, 0x05, 0x70, 0x65, 0x61, 0x6b,
0x73, 0x12, 0x29, 0x0a, 0x10, 0x70, 0x65, 0x72, 0x63, 0x65, 0x6e, 0x74, 0x5f, 0x63, 0x6f, 0x6d,
0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x02, 0x52, 0x0f, 0x70, 0x65, 0x72,
0x63, 0x65, 0x6e, 0x74, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x1d, 0x0a, 0x0a,
0x61, 0x75, 0x64, 0x69, 0x6f, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c,
0x52, 0x09, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x44, 0x61, 0x74, 0x61, 0x22, 0x21, 0x0a, 0x0f, 0x47,
0x65, 0x74, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e,
0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0x4f,
0x0a, 0x10, 0x47, 0x65, 0x74, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65,
0x73, 0x73, 0x12, 0x29, 0x0a, 0x10, 0x70, 0x65, 0x72, 0x63, 0x65, 0x6e, 0x74, 0x5f, 0x63, 0x6f,
0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x02, 0x52, 0x0f, 0x70, 0x65,
0x72, 0x63, 0x65, 0x6e, 0x74, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x10, 0x0a,
0x03, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x22,
0x2a, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x54, 0x68, 0x75, 0x6d, 0x62,
0x6e, 0x61, 0x69, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69,
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0x5f, 0x0a, 0x19, 0x47,
0x65, 0x74, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x54, 0x68, 0x75, 0x6d, 0x62, 0x6e, 0x61, 0x69, 0x6c,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6d, 0x61, 0x67,
0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x14,
0x0a, 0x05, 0x77, 0x69, 0x64, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x77,
0x69, 0x64, 0x74, 0x68, 0x12, 0x16, 0x0a, 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x18, 0x03,
0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x2a, 0x1f, 0x0a, 0x0b,
0x41, 0x75, 0x64, 0x69, 0x6f, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x12, 0x07, 0x0a, 0x03, 0x57,
0x41, 0x56, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x4d, 0x50, 0x33, 0x10, 0x01, 0x32, 0xfd, 0x03,
0x0a, 0x0f, 0x4d, 0x65, 0x64, 0x69, 0x61, 0x53, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63,
0x65, 0x12, 0x33, 0x0a, 0x03, 0x47, 0x65, 0x74, 0x12, 0x15, 0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61,
0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
0x13, 0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x4d, 0x65, 0x64, 0x69,
0x61, 0x53, 0x65, 0x74, 0x22, 0x00, 0x12, 0x47, 0x0a, 0x08, 0x47, 0x65, 0x74, 0x50, 0x65, 0x61,
0x6b, 0x73, 0x12, 0x1a, 0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47,
0x65, 0x74, 0x50, 0x65, 0x61, 0x6b, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b,
0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x50, 0x65,
0x61, 0x6b, 0x73, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x22, 0x00, 0x30, 0x01, 0x12,
0x63, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x50, 0x65, 0x61, 0x6b, 0x73, 0x46, 0x6f, 0x72, 0x53, 0x65,
0x67, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x24, 0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65,
0x74, 0x2e, 0x47, 0x65, 0x74, 0x50, 0x65, 0x61, 0x6b, 0x73, 0x46, 0x6f, 0x72, 0x53, 0x65, 0x67,
0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x25, 0x2e, 0x6d, 0x65,
0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x50, 0x65, 0x61, 0x6b, 0x73,
0x46, 0x6f, 0x72, 0x53, 0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x22, 0x00, 0x12, 0x5c, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x41, 0x75, 0x64, 0x69, 0x6f,
0x53, 0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x21, 0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f,
0x73, 0x65, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x75, 0x64, 0x69, 0x6f, 0x53, 0x65, 0x67, 0x6d,
0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x6d, 0x65, 0x64,
0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x75, 0x64, 0x69, 0x6f, 0x53,
0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x22, 0x00,
0x30, 0x01, 0x12, 0x47, 0x0a, 0x08, 0x47, 0x65, 0x74, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x12, 0x1a,
0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x02, 0x52, 0x0f, 0x70, 0x65, 0x72,
0x63, 0x65, 0x6e, 0x74, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x10, 0x0a, 0x03,
0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x22, 0x84,
0x01, 0x0a, 0x19, 0x47, 0x65, 0x74, 0x50, 0x65, 0x61, 0x6b, 0x73, 0x46, 0x6f, 0x72, 0x53, 0x65,
0x67, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02,
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x19, 0x0a, 0x08,
0x6e, 0x75, 0x6d, 0x5f, 0x62, 0x69, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07,
0x6e, 0x75, 0x6d, 0x42, 0x69, 0x6e, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x74, 0x61, 0x72, 0x74,
0x5f, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x73, 0x74,
0x61, 0x72, 0x74, 0x46, 0x72, 0x61, 0x6d, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x65, 0x6e, 0x64, 0x5f,
0x66, 0x72, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x65, 0x6e, 0x64,
0x46, 0x72, 0x61, 0x6d, 0x65, 0x22, 0x32, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x50, 0x65, 0x61, 0x6b,
0x73, 0x46, 0x6f, 0x72, 0x53, 0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x70, 0x65, 0x61, 0x6b, 0x73, 0x18, 0x01, 0x20, 0x03,
0x28, 0x05, 0x52, 0x05, 0x70, 0x65, 0x61, 0x6b, 0x73, 0x22, 0x96, 0x01, 0x0a, 0x16, 0x47, 0x65,
0x74, 0x41, 0x75, 0x64, 0x69, 0x6f, 0x53, 0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
0x52, 0x02, 0x69, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x74, 0x61, 0x72, 0x74, 0x5f, 0x66, 0x72,
0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74,
0x46, 0x72, 0x61, 0x6d, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x65, 0x6e, 0x64, 0x5f, 0x66, 0x72, 0x61,
0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x65, 0x6e, 0x64, 0x46, 0x72, 0x61,
0x6d, 0x65, 0x12, 0x2e, 0x0a, 0x06, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01,
0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x41,
0x75, 0x64, 0x69, 0x6f, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x52, 0x06, 0x66, 0x6f, 0x72, 0x6d,
0x61, 0x74, 0x22, 0x9a, 0x01, 0x0a, 0x17, 0x47, 0x65, 0x74, 0x41, 0x75, 0x64, 0x69, 0x6f, 0x53,
0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x12, 0x1b,
0x0a, 0x09, 0x6d, 0x69, 0x6d, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28,
0x09, 0x52, 0x08, 0x6d, 0x69, 0x6d, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d,
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65,
0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x29, 0x0a, 0x10, 0x70, 0x65, 0x72, 0x63, 0x65, 0x6e, 0x74,
0x5f, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x02, 0x52,
0x0f, 0x70, 0x65, 0x72, 0x63, 0x65, 0x6e, 0x74, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65,
0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x18, 0x04,
0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x44, 0x61, 0x74, 0x61, 0x22,
0x21, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02,
0x69, 0x64, 0x22, 0x4f, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x50, 0x72,
0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x12, 0x29, 0x0a, 0x10, 0x70, 0x65, 0x72, 0x63, 0x65, 0x6e,
0x74, 0x5f, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x02,
0x52, 0x0f, 0x70, 0x65, 0x72, 0x63, 0x65, 0x6e, 0x74, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74,
0x65, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03,
0x75, 0x72, 0x6c, 0x22, 0x2a, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x54,
0x68, 0x75, 0x6d, 0x62, 0x6e, 0x61, 0x69, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22,
0x5f, 0x0a, 0x19, 0x47, 0x65, 0x74, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x54, 0x68, 0x75, 0x6d, 0x62,
0x6e, 0x61, 0x69, 0x6c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05,
0x69, 0x6d, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x69, 0x6d, 0x61,
0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x77, 0x69, 0x64, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28,
0x05, 0x52, 0x05, 0x77, 0x69, 0x64, 0x74, 0x68, 0x12, 0x16, 0x0a, 0x06, 0x68, 0x65, 0x69, 0x67,
0x68, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74,
0x2a, 0x1f, 0x0a, 0x0b, 0x41, 0x75, 0x64, 0x69, 0x6f, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x12,
0x07, 0x0a, 0x03, 0x57, 0x41, 0x56, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x4d, 0x50, 0x33, 0x10,
0x01, 0x32, 0xfd, 0x03, 0x0a, 0x0f, 0x4d, 0x65, 0x64, 0x69, 0x61, 0x53, 0x65, 0x74, 0x53, 0x65,
0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x33, 0x0a, 0x03, 0x47, 0x65, 0x74, 0x12, 0x15, 0x2e, 0x6d,
0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x1a, 0x13, 0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e,
0x4d, 0x65, 0x64, 0x69, 0x61, 0x53, 0x65, 0x74, 0x22, 0x00, 0x12, 0x47, 0x0a, 0x08, 0x47, 0x65,
0x74, 0x50, 0x65, 0x61, 0x6b, 0x73, 0x12, 0x1a, 0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73,
0x65, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x50, 0x65, 0x61, 0x6b, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47,
0x65, 0x74, 0x50, 0x65, 0x61, 0x6b, 0x73, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x22,
0x00, 0x30, 0x01, 0x12, 0x63, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x50, 0x65, 0x61, 0x6b, 0x73, 0x46,
0x6f, 0x72, 0x53, 0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x24, 0x2e, 0x6d, 0x65, 0x64, 0x69,
0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x50, 0x65, 0x61, 0x6b, 0x73, 0x46, 0x6f,
0x72, 0x53, 0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
0x25, 0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x50,
0x65, 0x61, 0x6b, 0x73, 0x46, 0x6f, 0x72, 0x53, 0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65,
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x5c, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x41,
0x75, 0x64, 0x69, 0x6f, 0x53, 0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x21, 0x2e, 0x6d, 0x65,
0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x75, 0x64, 0x69, 0x6f,
0x53, 0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22,
0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x75,
0x64, 0x69, 0x6f, 0x53, 0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65,
0x73, 0x73, 0x22, 0x00, 0x30, 0x01, 0x12, 0x47, 0x0a, 0x08, 0x47, 0x65, 0x74, 0x56, 0x69, 0x64,
0x65, 0x6f, 0x12, 0x1a, 0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47,
0x65, 0x74, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b,
0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x56, 0x69,
0x64, 0x65, 0x6f, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x6d, 0x65, 0x64,
0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x50,
0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x22, 0x00, 0x30, 0x01, 0x12, 0x60, 0x0a, 0x11, 0x47,
0x65, 0x74, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x54, 0x68, 0x75, 0x6d, 0x62, 0x6e, 0x61, 0x69, 0x6c,
0x12, 0x23, 0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47, 0x65, 0x74,
0x56, 0x69, 0x64, 0x65, 0x6f, 0x54, 0x68, 0x75, 0x6d, 0x62, 0x6e, 0x61, 0x69, 0x6c, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65,
0x74, 0x2e, 0x47, 0x65, 0x74, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x54, 0x68, 0x75, 0x6d, 0x62, 0x6e,
0x61, 0x69, 0x6c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x0e, 0x5a,
0x0c, 0x70, 0x62, 0x2f, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x62, 0x06, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x33,
0x64, 0x65, 0x6f, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x22, 0x00, 0x30, 0x01, 0x12,
0x60, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x54, 0x68, 0x75, 0x6d, 0x62,
0x6e, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74,
0x2e, 0x47, 0x65, 0x74, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x54, 0x68, 0x75, 0x6d, 0x62, 0x6e, 0x61,
0x69, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x6d, 0x65, 0x64, 0x69,
0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x54, 0x68,
0x75, 0x6d, 0x62, 0x6e, 0x61, 0x69, 0x6c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22,
0x00, 0x42, 0x0e, 0x5a, 0x0c, 0x70, 0x62, 0x2f, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65,
0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (

View File

@ -36,7 +36,4 @@ type MediaSet struct {
VideoContentLength int64
AudioEncodedS3Key sql.NullString
AudioEncodedS3UploadedAt sql.NullTime
Title string
Description string
Author string
}

View File

@ -11,16 +11,13 @@ import (
)
const createMediaSet = `-- name: CreateMediaSet :one
INSERT INTO media_sets (youtube_id, title, description, author, audio_youtube_itag, audio_channels, audio_frames_approx, audio_sample_rate, audio_content_length, audio_encoded_mime_type, video_youtube_itag, video_content_length, video_mime_type, video_duration_nanos, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, NOW(), NOW())
RETURNING id, youtube_id, audio_youtube_itag, audio_channels, audio_frames_approx, audio_frames, audio_sample_rate, audio_raw_s3_key, audio_raw_s3_uploaded_at, audio_encoded_mime_type, video_youtube_itag, video_s3_key, video_s3_uploaded_at, video_mime_type, video_duration_nanos, video_thumbnail_s3_key, video_thumbnail_s3_uploaded_at, video_thumbnail_mime_type, video_thumbnail_width, video_thumbnail_height, created_at, updated_at, audio_content_length, video_content_length, audio_encoded_s3_key, audio_encoded_s3_uploaded_at, title, description, author
INSERT INTO media_sets (youtube_id, audio_youtube_itag, audio_channels, audio_frames_approx, audio_sample_rate, audio_content_length, audio_encoded_mime_type, video_youtube_itag, video_content_length, video_mime_type, video_duration_nanos, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), NOW())
RETURNING id, youtube_id, audio_youtube_itag, audio_channels, audio_frames_approx, audio_frames, audio_sample_rate, audio_raw_s3_key, audio_raw_s3_uploaded_at, audio_encoded_mime_type, video_youtube_itag, video_s3_key, video_s3_uploaded_at, video_mime_type, video_duration_nanos, video_thumbnail_s3_key, video_thumbnail_s3_uploaded_at, video_thumbnail_mime_type, video_thumbnail_width, video_thumbnail_height, created_at, updated_at, audio_content_length, video_content_length, audio_encoded_s3_key, audio_encoded_s3_uploaded_at
`
type CreateMediaSetParams struct {
YoutubeID string
Title string
Description string
Author string
AudioYoutubeItag int32
AudioChannels int32
AudioFramesApprox int64
@ -36,9 +33,6 @@ type CreateMediaSetParams struct {
func (q *Queries) CreateMediaSet(ctx context.Context, arg CreateMediaSetParams) (MediaSet, error) {
row := q.db.QueryRow(ctx, createMediaSet,
arg.YoutubeID,
arg.Title,
arg.Description,
arg.Author,
arg.AudioYoutubeItag,
arg.AudioChannels,
arg.AudioFramesApprox,
@ -78,15 +72,12 @@ func (q *Queries) CreateMediaSet(ctx context.Context, arg CreateMediaSetParams)
&i.VideoContentLength,
&i.AudioEncodedS3Key,
&i.AudioEncodedS3UploadedAt,
&i.Title,
&i.Description,
&i.Author,
)
return i, err
}
const getMediaSet = `-- name: GetMediaSet :one
SELECT id, youtube_id, audio_youtube_itag, audio_channels, audio_frames_approx, audio_frames, audio_sample_rate, audio_raw_s3_key, audio_raw_s3_uploaded_at, audio_encoded_mime_type, video_youtube_itag, video_s3_key, video_s3_uploaded_at, video_mime_type, video_duration_nanos, video_thumbnail_s3_key, video_thumbnail_s3_uploaded_at, video_thumbnail_mime_type, video_thumbnail_width, video_thumbnail_height, created_at, updated_at, audio_content_length, video_content_length, audio_encoded_s3_key, audio_encoded_s3_uploaded_at, title, description, author FROM media_sets WHERE id = $1
SELECT id, youtube_id, audio_youtube_itag, audio_channels, audio_frames_approx, audio_frames, audio_sample_rate, audio_raw_s3_key, audio_raw_s3_uploaded_at, audio_encoded_mime_type, video_youtube_itag, video_s3_key, video_s3_uploaded_at, video_mime_type, video_duration_nanos, video_thumbnail_s3_key, video_thumbnail_s3_uploaded_at, video_thumbnail_mime_type, video_thumbnail_width, video_thumbnail_height, created_at, updated_at, audio_content_length, video_content_length, audio_encoded_s3_key, audio_encoded_s3_uploaded_at FROM media_sets WHERE id = $1
`
func (q *Queries) GetMediaSet(ctx context.Context, id uuid.UUID) (MediaSet, error) {
@ -119,15 +110,12 @@ func (q *Queries) GetMediaSet(ctx context.Context, id uuid.UUID) (MediaSet, erro
&i.VideoContentLength,
&i.AudioEncodedS3Key,
&i.AudioEncodedS3UploadedAt,
&i.Title,
&i.Description,
&i.Author,
)
return i, err
}
const getMediaSetByYoutubeID = `-- name: GetMediaSetByYoutubeID :one
SELECT id, youtube_id, audio_youtube_itag, audio_channels, audio_frames_approx, audio_frames, audio_sample_rate, audio_raw_s3_key, audio_raw_s3_uploaded_at, audio_encoded_mime_type, video_youtube_itag, video_s3_key, video_s3_uploaded_at, video_mime_type, video_duration_nanos, video_thumbnail_s3_key, video_thumbnail_s3_uploaded_at, video_thumbnail_mime_type, video_thumbnail_width, video_thumbnail_height, created_at, updated_at, audio_content_length, video_content_length, audio_encoded_s3_key, audio_encoded_s3_uploaded_at, title, description, author FROM media_sets WHERE youtube_id = $1
SELECT id, youtube_id, audio_youtube_itag, audio_channels, audio_frames_approx, audio_frames, audio_sample_rate, audio_raw_s3_key, audio_raw_s3_uploaded_at, audio_encoded_mime_type, video_youtube_itag, video_s3_key, video_s3_uploaded_at, video_mime_type, video_duration_nanos, video_thumbnail_s3_key, video_thumbnail_s3_uploaded_at, video_thumbnail_mime_type, video_thumbnail_width, video_thumbnail_height, created_at, updated_at, audio_content_length, video_content_length, audio_encoded_s3_key, audio_encoded_s3_uploaded_at FROM media_sets WHERE youtube_id = $1
`
func (q *Queries) GetMediaSetByYoutubeID(ctx context.Context, youtubeID string) (MediaSet, error) {
@ -160,9 +148,6 @@ func (q *Queries) GetMediaSetByYoutubeID(ctx context.Context, youtubeID string)
&i.VideoContentLength,
&i.AudioEncodedS3Key,
&i.AudioEncodedS3UploadedAt,
&i.Title,
&i.Description,
&i.Author,
)
return i, err
}
@ -171,7 +156,7 @@ const setEncodedAudioUploaded = `-- name: SetEncodedAudioUploaded :one
UPDATE media_sets
SET audio_encoded_s3_key = $2, audio_encoded_s3_uploaded_at = NOW(), updated_at = NOW()
WHERE id = $1
RETURNING id, youtube_id, audio_youtube_itag, audio_channels, audio_frames_approx, audio_frames, audio_sample_rate, audio_raw_s3_key, audio_raw_s3_uploaded_at, audio_encoded_mime_type, video_youtube_itag, video_s3_key, video_s3_uploaded_at, video_mime_type, video_duration_nanos, video_thumbnail_s3_key, video_thumbnail_s3_uploaded_at, video_thumbnail_mime_type, video_thumbnail_width, video_thumbnail_height, created_at, updated_at, audio_content_length, video_content_length, audio_encoded_s3_key, audio_encoded_s3_uploaded_at, title, description, author
RETURNING id, youtube_id, audio_youtube_itag, audio_channels, audio_frames_approx, audio_frames, audio_sample_rate, audio_raw_s3_key, audio_raw_s3_uploaded_at, audio_encoded_mime_type, video_youtube_itag, video_s3_key, video_s3_uploaded_at, video_mime_type, video_duration_nanos, video_thumbnail_s3_key, video_thumbnail_s3_uploaded_at, video_thumbnail_mime_type, video_thumbnail_width, video_thumbnail_height, created_at, updated_at, audio_content_length, video_content_length, audio_encoded_s3_key, audio_encoded_s3_uploaded_at
`
type SetEncodedAudioUploadedParams struct {
@ -209,9 +194,6 @@ func (q *Queries) SetEncodedAudioUploaded(ctx context.Context, arg SetEncodedAud
&i.VideoContentLength,
&i.AudioEncodedS3Key,
&i.AudioEncodedS3UploadedAt,
&i.Title,
&i.Description,
&i.Author,
)
return i, err
}
@ -220,7 +202,7 @@ const setRawAudioUploaded = `-- name: SetRawAudioUploaded :one
UPDATE media_sets
SET audio_raw_s3_key = $2, audio_frames = $3, audio_raw_s3_uploaded_at = NOW(), updated_at = NOW()
WHERE id = $1
RETURNING id, youtube_id, audio_youtube_itag, audio_channels, audio_frames_approx, audio_frames, audio_sample_rate, audio_raw_s3_key, audio_raw_s3_uploaded_at, audio_encoded_mime_type, video_youtube_itag, video_s3_key, video_s3_uploaded_at, video_mime_type, video_duration_nanos, video_thumbnail_s3_key, video_thumbnail_s3_uploaded_at, video_thumbnail_mime_type, video_thumbnail_width, video_thumbnail_height, created_at, updated_at, audio_content_length, video_content_length, audio_encoded_s3_key, audio_encoded_s3_uploaded_at, title, description, author
RETURNING id, youtube_id, audio_youtube_itag, audio_channels, audio_frames_approx, audio_frames, audio_sample_rate, audio_raw_s3_key, audio_raw_s3_uploaded_at, audio_encoded_mime_type, video_youtube_itag, video_s3_key, video_s3_uploaded_at, video_mime_type, video_duration_nanos, video_thumbnail_s3_key, video_thumbnail_s3_uploaded_at, video_thumbnail_mime_type, video_thumbnail_width, video_thumbnail_height, created_at, updated_at, audio_content_length, video_content_length, audio_encoded_s3_key, audio_encoded_s3_uploaded_at
`
type SetRawAudioUploadedParams struct {
@ -259,9 +241,6 @@ func (q *Queries) SetRawAudioUploaded(ctx context.Context, arg SetRawAudioUpload
&i.VideoContentLength,
&i.AudioEncodedS3Key,
&i.AudioEncodedS3UploadedAt,
&i.Title,
&i.Description,
&i.Author,
)
return i, err
}
@ -270,7 +249,7 @@ const setVideoThumbnailUploaded = `-- name: SetVideoThumbnailUploaded :one
UPDATE media_sets
SET video_thumbnail_width = $2, video_thumbnail_height = $3, video_thumbnail_mime_type = $4, video_thumbnail_s3_key = $5, video_thumbnail_s3_uploaded_at = NOW(), updated_at = NOW()
WHERE id = $1
RETURNING id, youtube_id, audio_youtube_itag, audio_channels, audio_frames_approx, audio_frames, audio_sample_rate, audio_raw_s3_key, audio_raw_s3_uploaded_at, audio_encoded_mime_type, video_youtube_itag, video_s3_key, video_s3_uploaded_at, video_mime_type, video_duration_nanos, video_thumbnail_s3_key, video_thumbnail_s3_uploaded_at, video_thumbnail_mime_type, video_thumbnail_width, video_thumbnail_height, created_at, updated_at, audio_content_length, video_content_length, audio_encoded_s3_key, audio_encoded_s3_uploaded_at, title, description, author
RETURNING id, youtube_id, audio_youtube_itag, audio_channels, audio_frames_approx, audio_frames, audio_sample_rate, audio_raw_s3_key, audio_raw_s3_uploaded_at, audio_encoded_mime_type, video_youtube_itag, video_s3_key, video_s3_uploaded_at, video_mime_type, video_duration_nanos, video_thumbnail_s3_key, video_thumbnail_s3_uploaded_at, video_thumbnail_mime_type, video_thumbnail_width, video_thumbnail_height, created_at, updated_at, audio_content_length, video_content_length, audio_encoded_s3_key, audio_encoded_s3_uploaded_at
`
type SetVideoThumbnailUploadedParams struct {
@ -317,9 +296,6 @@ func (q *Queries) SetVideoThumbnailUploaded(ctx context.Context, arg SetVideoThu
&i.VideoContentLength,
&i.AudioEncodedS3Key,
&i.AudioEncodedS3UploadedAt,
&i.Title,
&i.Description,
&i.Author,
)
return i, err
}
@ -328,7 +304,7 @@ const setVideoUploaded = `-- name: SetVideoUploaded :one
UPDATE media_sets
SET video_s3_key = $2, video_s3_uploaded_at = NOW(), updated_at = NOW()
WHERE id = $1
RETURNING id, youtube_id, audio_youtube_itag, audio_channels, audio_frames_approx, audio_frames, audio_sample_rate, audio_raw_s3_key, audio_raw_s3_uploaded_at, audio_encoded_mime_type, video_youtube_itag, video_s3_key, video_s3_uploaded_at, video_mime_type, video_duration_nanos, video_thumbnail_s3_key, video_thumbnail_s3_uploaded_at, video_thumbnail_mime_type, video_thumbnail_width, video_thumbnail_height, created_at, updated_at, audio_content_length, video_content_length, audio_encoded_s3_key, audio_encoded_s3_uploaded_at, title, description, author
RETURNING id, youtube_id, audio_youtube_itag, audio_channels, audio_frames_approx, audio_frames, audio_sample_rate, audio_raw_s3_key, audio_raw_s3_uploaded_at, audio_encoded_mime_type, video_youtube_itag, video_s3_key, video_s3_uploaded_at, video_mime_type, video_duration_nanos, video_thumbnail_s3_key, video_thumbnail_s3_uploaded_at, video_thumbnail_mime_type, video_thumbnail_width, video_thumbnail_height, created_at, updated_at, audio_content_length, video_content_length, audio_encoded_s3_key, audio_encoded_s3_uploaded_at
`
type SetVideoUploadedParams struct {
@ -366,9 +342,6 @@ func (q *Queries) SetVideoUploaded(ctx context.Context, arg SetVideoUploadedPara
&i.VideoContentLength,
&i.AudioEncodedS3Key,
&i.AudioEncodedS3UploadedAt,
&i.Title,
&i.Description,
&i.Author,
)
return i, err
}

View File

@ -1,69 +1,61 @@
module git.netflux.io/rob/clipper
go 1.19
go 1.17
require (
github.com/aws/aws-sdk-go-v2 v1.13.0
github.com/aws/aws-sdk-go-v2/config v1.13.1
github.com/aws/aws-sdk-go-v2/credentials v1.8.0
github.com/aws/aws-sdk-go-v2/service/s3 v1.24.1
github.com/aws/smithy-go v1.10.0
github.com/aws/aws-sdk-go-v2 v1.11.1
github.com/aws/aws-sdk-go-v2/config v1.10.2
github.com/aws/aws-sdk-go-v2/credentials v1.6.2
github.com/aws/aws-sdk-go-v2/service/s3 v1.19.1
github.com/aws/smithy-go v1.9.0
github.com/google/uuid v1.3.0
github.com/gorilla/handlers v1.5.1
github.com/gorilla/mux v1.8.0
github.com/gorilla/schema v1.2.0
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0
github.com/improbable-eng/grpc-web v0.15.0
github.com/jackc/pgconn v1.11.0
github.com/jackc/pgx/v4 v4.15.0
github.com/kkdai/youtube/v2 v2.7.10
github.com/jackc/pgconn v1.10.1
github.com/jackc/pgx/v4 v4.14.0
github.com/kkdai/youtube/v2 v2.7.4
github.com/stretchr/testify v1.7.0
go.uber.org/zap v1.21.0
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
google.golang.org/grpc v1.44.0
go.uber.org/zap v1.19.1
google.golang.org/grpc v1.42.0
google.golang.org/protobuf v1.27.1
)
require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.2.0 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.10.0 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.2.0 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.7.0 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.7.0 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.11.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.9.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.14.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.0.0 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.8.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.0.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.5.0 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.5.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.9.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.6.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.10.1 // indirect
github.com/bitly/go-simplejson v0.5.0 // indirect
github.com/cenkalti/backoff/v4 v4.1.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f // indirect
github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91 // indirect
github.com/dop251/goja v0.0.0-20220124171016-cfb079cdc7b4 // indirect
github.com/felixge/httpsnoop v1.0.2 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
github.com/jackc/pgio v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgproto3/v2 v2.2.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
github.com/jackc/pgtype v1.10.0 // indirect
github.com/jackc/puddle v1.2.1 // indirect
github.com/klauspost/compress v1.14.2 // indirect
github.com/jackc/pgtype v1.9.0 // indirect
github.com/jackc/puddle v1.2.0 // indirect
github.com/klauspost/compress v1.13.6 // indirect
github.com/lib/pq v1.10.4 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rs/cors v1.8.2 // indirect
github.com/stretchr/objx v0.3.0 // indirect
github.com/rs/cors v1.8.0 // indirect
github.com/stretchr/objx v0.2.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.7.0 // indirect
golang.org/x/crypto v0.0.0-20220210151621-f4118a5b28e2 // indirect
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect
golang.org/x/sys v0.0.0-20220209214540-3681064d5158 // indirect
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 // indirect
golang.org/x/net v0.0.0-20210913180222-943fd674d43e // indirect
golang.org/x/sys v0.0.0-20210910150752-751e447fb3d0 // indirect
golang.org/x/text v0.3.7 // indirect
google.golang.org/genproto v0.0.0-20220208230804-65c12eb4c068 // indirect
google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
nhooyr.io/websocket v1.8.7 // indirect
)

File diff suppressed because it is too large Load Diff

View File

@ -7,19 +7,19 @@ import (
"fmt"
"io"
"math"
"os/exec"
"strconv"
"sync"
"git.netflux.io/rob/clipper/config"
"git.netflux.io/rob/clipper/generated/store"
"go.uber.org/zap"
"golang.org/x/sync/errgroup"
)
type GetPeaksProgress struct {
PercentComplete float32
Peaks []int16
URL string
AudioFrames int64
}
type GetPeaksProgressReader interface {
@ -29,25 +29,21 @@ type GetPeaksProgressReader interface {
// audioGetter manages getting and processing audio from Youtube.
type audioGetter struct {
store Store
youtube YoutubeClient
fileStore FileStore
commandFunc CommandFunc
workerPool *WorkerPool
config config.Config
logger *zap.SugaredLogger
store Store
youtube YoutubeClient
fileStore FileStore
config config.Config
logger *zap.SugaredLogger
}
// newAudioGetter returns a new audioGetter.
func newAudioGetter(store Store, youtube YoutubeClient, fileStore FileStore, commandFunc CommandFunc, workerPool *WorkerPool, config config.Config, logger *zap.SugaredLogger) *audioGetter {
func newAudioGetter(store Store, youtube YoutubeClient, fileStore FileStore, config config.Config, logger *zap.SugaredLogger) *audioGetter {
return &audioGetter{
store: store,
youtube: youtube,
fileStore: fileStore,
commandFunc: commandFunc,
workerPool: workerPool,
config: config,
logger: logger,
store: store,
youtube: youtube,
fileStore: fileStore,
config: config,
logger: logger,
}
}
@ -64,7 +60,7 @@ func (g *audioGetter) GetAudio(ctx context.Context, mediaSet store.MediaSet, num
format := video.Formats.FindByItag(int(mediaSet.AudioYoutubeItag))
if format == nil {
return nil, fmt.Errorf("error finding itag: %d", mediaSet.AudioYoutubeItag)
return nil, fmt.Errorf("error finding itag: %v", err)
}
stream, _, err := g.youtube.GetStreamContext(ctx, video, format)
@ -81,13 +77,7 @@ func (g *audioGetter) GetAudio(ctx context.Context, mediaSet store.MediaSet, num
audioGetter: g,
getPeaksProgressReader: audioProgressReader,
}
go func() {
if err := g.workerPool.WaitForTask(ctx, func() error { return s.getAudio(ctx, stream, mediaSet) }); err != nil {
// the progress reader is closed inside the worker in the non-error case.
s.CloseWithError(err)
}
}()
go s.getAudio(ctx, stream, mediaSet)
return s, nil
}
@ -98,109 +88,96 @@ type audioGetterState struct {
*getPeaksProgressReader
}
func (s *audioGetterState) getAudio(ctx context.Context, r io.ReadCloser, mediaSet store.MediaSet) error {
func (s *audioGetterState) getAudio(ctx context.Context, r io.ReadCloser, mediaSet store.MediaSet) {
streamWithProgress := newLogProgressReader(r, "audio", mediaSet.AudioContentLength, s.logger)
pr, pw := io.Pipe()
teeReader := io.TeeReader(streamWithProgress, pw)
var stdErr bytes.Buffer
cmd := s.commandFunc(ctx, "ffmpeg", "-hide_banner", "-loglevel", "error", "-i", "-", "-f", rawAudioFormat, "-ar", strconv.Itoa(rawAudioSampleRate), "-acodec", rawAudioCodec, "-")
cmd := exec.CommandContext(ctx, "ffmpeg", "-hide_banner", "-loglevel", "error", "-i", "-", "-f", rawAudioFormat, "-ar", strconv.Itoa(rawAudioSampleRate), "-acodec", rawAudioCodec, "-")
cmd.Stdin = teeReader
cmd.Stderr = &stdErr
// ffmpegWriter accepts encoded audio and pipes it to FFmpeg.
ffmpegWriter, err := cmd.StdinPipe()
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("error getting stdin: %v", err)
s.CloseWithError(fmt.Errorf("error getting stdout: %v", err))
return
}
uploadReader, uploadWriter := io.Pipe()
mw := io.MultiWriter(uploadWriter, ffmpegWriter)
// ffmpegReader delivers raw audio output from FFmpeg, and also writes it
// back to the progress reader.
var ffmpegReader io.Reader
if stdoutPipe, err := cmd.StdoutPipe(); err == nil {
ffmpegReader = io.TeeReader(stdoutPipe, s)
} else {
return fmt.Errorf("error getting stdout: %v", err)
if err = cmd.Start(); err != nil {
s.CloseWithError(fmt.Errorf("error starting command: %v, output: %s", err, stdErr.String()))
return
}
var presignedAudioURL string
g, ctx := errgroup.WithContext(ctx)
var wg sync.WaitGroup
wg.Add(2)
// Upload the encoded audio.
g.Go(func() error {
// TODO: fix error shadowing in these two goroutines.
go func() {
defer wg.Done()
// TODO: use mediaSet func to fetch key
key := fmt.Sprintf("media_sets/%s/audio.opus", mediaSet.ID)
_, encErr := s.fileStore.PutObject(ctx, key, uploadReader, "audio/opus")
_, encErr := s.fileStore.PutObject(ctx, key, pr, "audio/opus")
if encErr != nil {
return fmt.Errorf("error uploading encoded audio: %v", encErr)
s.CloseWithError(fmt.Errorf("error uploading encoded audio: %v", encErr))
return
}
presignedAudioURL, encErr = s.fileStore.GetURL(ctx, key)
if encErr != nil {
return fmt.Errorf("error generating presigned URL: %v", encErr)
presignedAudioURL, err = s.fileStore.GetURL(ctx, key)
if err != nil {
s.CloseWithError(fmt.Errorf("error generating presigned URL: %v", err))
}
if _, encErr = s.store.SetEncodedAudioUploaded(ctx, store.SetEncodedAudioUploadedParams{
if _, err = s.store.SetEncodedAudioUploaded(ctx, store.SetEncodedAudioUploadedParams{
ID: mediaSet.ID,
AudioEncodedS3Key: sqlString(key),
}); encErr != nil {
return fmt.Errorf("error setting encoded audio uploaded: %v", encErr)
}); err != nil {
s.CloseWithError(fmt.Errorf("error setting encoded audio uploaded: %v", err))
}
return nil
})
}()
// Upload the raw audio.
g.Go(func() error {
go func() {
defer wg.Done()
// TODO: use mediaSet func to fetch key
key := fmt.Sprintf("media_sets/%s/audio.raw", mediaSet.ID)
bytesUploaded, rawErr := s.fileStore.PutObject(ctx, key, ffmpegReader, rawAudioMimeType)
teeReader := io.TeeReader(stdout, s)
bytesUploaded, rawErr := s.fileStore.PutObject(ctx, key, teeReader, rawAudioMimeType)
if rawErr != nil {
return fmt.Errorf("error uploading raw audio: %v", rawErr)
s.CloseWithError(fmt.Errorf("error uploading raw audio: %v", rawErr))
return
}
if _, rawErr = s.store.SetRawAudioUploaded(ctx, store.SetRawAudioUploadedParams{
if _, err = s.store.SetRawAudioUploaded(ctx, store.SetRawAudioUploadedParams{
ID: mediaSet.ID,
AudioRawS3Key: sqlString(key),
AudioFrames: sqlInt64(bytesUploaded / SizeOfInt16 / int64(mediaSet.AudioChannels)),
}); rawErr != nil {
return fmt.Errorf("error setting raw audio uploaded: %v", rawErr)
}); err != nil {
s.CloseWithError(fmt.Errorf("error setting raw audio uploaded: %v", err))
}
}()
return nil
})
g.Go(func() error {
defer func() { _ = r.Close() }()
defer func() { _ = uploadWriter.Close() }()
defer func() { _ = ffmpegWriter.Close() }()
if _, err := io.Copy(mw, streamWithProgress); err != nil {
return fmt.Errorf("error copying: %v", err)
}
return nil
})
if err := cmd.Start(); err != nil {
return fmt.Errorf("error starting command: %v, output: %s", err, stdErr.String())
if err = cmd.Wait(); err != nil {
// TODO: cancel other goroutines (e.g. video fetch) if an error occurs here.
s.CloseWithError(fmt.Errorf("error waiting for command: %v, output: %s", err, stdErr.String()))
return
}
if err := g.Wait(); err != nil {
return fmt.Errorf("error uploading: %v", err)
}
// Close the pipe sending encoded audio to be uploaded, this ensures the
// uploader reading from the pipe will receive io.EOF and complete
// successfully.
pw.Close()
if err := cmd.Wait(); err != nil {
return fmt.Errorf("error waiting for command: %v, output: %s", err, stdErr.String())
}
// Wait for the uploaders to complete.
wg.Wait()
// Finally, close the progress reader so that the subsequent call to Next()
// returns the presigned URL and io.EOF.
s.Close(presignedAudioURL)
return nil
}
// getPeaksProgressReader accepts a byte stream containing little endian
@ -252,12 +229,7 @@ func (w *getPeaksProgressReader) Next() (GetPeaksProgress, error) {
select {
case progress, ok := <-w.progress:
if !ok {
return GetPeaksProgress{
Peaks: w.currPeaks,
PercentComplete: w.percentComplete(),
URL: w.url,
AudioFrames: w.framesProcessed,
}, io.EOF
return GetPeaksProgress{Peaks: w.currPeaks, PercentComplete: w.percentComplete(), URL: w.url}, io.EOF
}
return progress, nil
case err := <-w.errorChan:

View File

@ -1,12 +1,10 @@
package media_test
import (
"bytes"
"context"
"database/sql"
"errors"
"io"
"strings"
"testing"
"time"
@ -15,186 +13,16 @@ import (
"git.netflux.io/rob/clipper/generated/store"
"git.netflux.io/rob/clipper/media"
"github.com/google/uuid"
"github.com/kkdai/youtube/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
func TestGetAudioFromYoutube(t *testing.T) {
const (
videoID = "abcdef12"
inFixturePath = "testdata/tone-44100-stereo-int16-30000ms.raw"
inFixtureLen = int64(5_292_000)
inFixtureFrames = inFixtureLen / 4 // stereo-int16
)
ctx := context.Background()
wp := media.NewTestWorkerPool()
mediaSetID := uuid.New()
mediaSet := store.MediaSet{
ID: mediaSetID,
YoutubeID: videoID,
AudioYoutubeItag: 123,
AudioChannels: 2,
AudioFramesApprox: inFixtureFrames,
AudioContentLength: 22,
}
video := &youtube.Video{
ID: videoID,
Formats: []youtube.Format{{ItagNo: 123, FPS: 0, AudioChannels: 2}},
}
t.Run("NOK,ErrorFetchingMediaSet", func(t *testing.T) {
var mockStore mocks.Store
mockStore.On("GetMediaSet", mock.Anything, mediaSetID).Return(store.MediaSet{}, errors.New("db went boom"))
service := media.NewMediaSetService(&mockStore, nil, nil, nil, wp, config.Config{}, zap.NewNop().Sugar())
_, err := service.GetPeaks(ctx, mediaSetID, 10)
assert.EqualError(t, err, "error getting media set: db went boom")
})
t.Run("NOK,ErrorFetchingStream", func(t *testing.T) {
var mockStore mocks.Store
mockStore.On("GetMediaSet", mock.Anything, mediaSetID).Return(mediaSet, nil)
var youtubeClient mocks.YoutubeClient
youtubeClient.On("GetVideoContext", mock.Anything, mediaSet.YoutubeID).Return(video, nil)
youtubeClient.On("GetStreamContext", mock.Anything, video, &video.Formats[0]).Return(nil, int64(0), errors.New("uh oh"))
service := media.NewMediaSetService(&mockStore, &youtubeClient, nil, nil, wp, config.Config{}, zap.NewNop().Sugar())
_, err := service.GetPeaks(ctx, mediaSetID, 10)
assert.EqualError(t, err, "error fetching stream: uh oh")
})
t.Run("NOK,ErrorBuildingProgressReader", func(t *testing.T) {
invalidMediaSet := mediaSet
invalidMediaSet.AudioChannels = 0
var mockStore mocks.Store
mockStore.On("GetMediaSet", mock.Anything, mediaSetID).Return(invalidMediaSet, nil)
var youtubeClient mocks.YoutubeClient
youtubeClient.On("GetVideoContext", mock.Anything, mediaSet.YoutubeID).Return(video, nil)
youtubeClient.On("GetStreamContext", mock.Anything, video, &video.Formats[0]).Return(nil, int64(0), nil)
service := media.NewMediaSetService(&mockStore, &youtubeClient, nil, nil, wp, config.Config{}, zap.NewNop().Sugar())
_, err := service.GetPeaks(ctx, mediaSetID, 10)
assert.EqualError(t, err, "error building progress reader: error creating audio progress reader (framesExpected = 1323000, channels = 0, numBins = 10)")
})
t.Run("NOK,UploadError", func(t *testing.T) {
var mockStore mocks.Store
mockStore.On("GetMediaSet", mock.Anything, mediaSetID).Return(mediaSet, nil)
mockStore.On("SetEncodedAudioUploaded", mock.Anything, mock.Anything).Return(mediaSet, nil)
var youtubeClient mocks.YoutubeClient
youtubeClient.On("GetVideoContext", mock.Anything, mediaSet.YoutubeID).Return(video, nil)
youtubeClient.On("GetStreamContext", mock.Anything, video, &video.Formats[0]).Return(io.NopCloser(bytes.NewReader(nil)), int64(0), nil)
var fileStore mocks.FileStore
fileStore.On("PutObject", mock.Anything, mock.Anything, mock.Anything, "audio/raw").Return(int64(0), errors.New("network error"))
fileStore.On("PutObject", mock.Anything, mock.Anything, mock.Anything, "audio/opus").Return(int64(0), nil)
fileStore.On("GetURL", mock.Anything, mock.Anything).Return("", nil)
cmd := helperCommand(t, "", inFixturePath, "", 0)
service := media.NewMediaSetService(&mockStore, &youtubeClient, &fileStore, cmd, wp, config.Config{}, zap.NewNop().Sugar())
stream, err := service.GetPeaks(ctx, mediaSetID, 10)
assert.NoError(t, err)
_, err = stream.Next()
assert.EqualError(t, err, "error waiting for progress: error uploading: error uploading raw audio: network error")
})
t.Run("NOK,FFmpegError", func(t *testing.T) {
var mockStore mocks.Store
mockStore.On("GetMediaSet", mock.Anything, mediaSetID).Return(mediaSet, nil)
mockStore.On("SetEncodedAudioUploaded", mock.Anything, mock.Anything).Return(mediaSet, nil)
mockStore.On("SetRawAudioUploaded", mock.Anything, mock.Anything).Return(mediaSet, nil)
var youtubeClient mocks.YoutubeClient
youtubeClient.On("GetVideoContext", mock.Anything, mediaSet.YoutubeID).Return(video, nil)
youtubeClient.On("GetStreamContext", mock.Anything, video, &video.Formats[0]).Return(io.NopCloser(strings.NewReader("some audio")), int64(0), nil)
var fileStore mocks.FileStore
fileStore.On("PutObject", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Run(func(args mock.Arguments) {
_, err := io.Copy(io.Discard, args[2].(io.Reader))
require.NoError(t, err)
}).
Return(int64(0), nil)
fileStore.On("GetURL", mock.Anything, mock.Anything).Return("", nil)
cmd := helperCommand(t, "", inFixturePath, "oh no", 101)
service := media.NewMediaSetService(&mockStore, &youtubeClient, &fileStore, cmd, wp, config.Config{}, zap.NewNop().Sugar())
stream, err := service.GetPeaks(ctx, mediaSetID, 10)
assert.NoError(t, err)
_, err = stream.Next()
assert.EqualError(t, err, "error waiting for progress: error waiting for command: exit status 101, output: oh no")
})
t.Run("OK", func(t *testing.T) {
// Mock Store
var mockStore mocks.Store
mockStore.On("GetMediaSet", mock.Anything, mediaSetID).Return(mediaSet, nil)
mockStore.On("SetRawAudioUploaded", mock.Anything, mock.MatchedBy(func(p store.SetRawAudioUploadedParams) bool {
return p.ID == mediaSetID && p.AudioFrames.Int64 == inFixtureFrames
})).Return(mediaSet, nil)
mockStore.On("SetEncodedAudioUploaded", mock.Anything, mock.MatchedBy(func(p store.SetEncodedAudioUploadedParams) bool {
return p.ID == mediaSetID
})).Return(mediaSet, nil)
defer mockStore.AssertExpectations(t)
// Mock YoutubeClient
encodedContent := "this is an opus stream"
reader := io.NopCloser(strings.NewReader(encodedContent))
var youtubeClient mocks.YoutubeClient
youtubeClient.On("GetVideoContext", mock.Anything, mediaSet.YoutubeID).Return(video, nil)
youtubeClient.On("GetStreamContext", mock.Anything, video, &video.Formats[0]).Return(reader, int64(len(encodedContent)), nil)
defer youtubeClient.AssertExpectations(t)
// Mock FileStore
// It is necessary to consume the readers passed into the mocks to avoid IO
// errors. Since we're doing that we can also assert the content that is
// passed to them is as expected.
url := "https://www.example.com/foo"
var fileStore mocks.FileStore
fileStore.On("PutObject", mock.Anything, "media_sets/"+mediaSetID.String()+"/audio.opus", mock.Anything, "audio/opus").
Run(func(args mock.Arguments) {
readContent, err := io.ReadAll(args[2].(io.Reader))
require.NoError(t, err)
assert.Equal(t, encodedContent, string(readContent))
}).
Return(int64(len(encodedContent)), nil)
fileStore.On("PutObject", mock.Anything, "media_sets/"+mediaSetID.String()+"/audio.raw", mock.Anything, "audio/raw").
Run(func(args mock.Arguments) {
n, err := io.Copy(io.Discard, args[2].(io.Reader))
require.NoError(t, err)
assert.Equal(t, inFixtureLen, n)
}).
Return(inFixtureLen, nil)
fileStore.On("GetURL", mock.Anything, "media_sets/"+mediaSetID.String()+"/audio.opus").Return(url, nil)
defer fileStore.AssertExpectations(t)
numBins := 10
cmd := helperCommand(t, "ffmpeg -hide_banner -loglevel error -i - -f s16le -ar 48000 -acodec pcm_s16le -", inFixturePath, "", 0)
service := media.NewMediaSetService(&mockStore, &youtubeClient, &fileStore, cmd, wp, config.Config{}, zap.NewNop().Sugar())
stream, err := service.GetPeaks(ctx, mediaSetID, numBins)
require.NoError(t, err)
assertConsumeStream(t, numBins, url, 1_323_000, stream)
})
}
func TestGetPeaksFromFileStore(t *testing.T) {
const (
inFixturePath = "testdata/tone-44100-stereo-int16-30000ms.raw"
inFixtureLen = 5_292_000
)
const inFixturePath = "testdata/tone-44100-stereo-int16-30000ms.raw"
ctx := context.Background()
wp := media.NewTestWorkerPool()
logger := zap.NewNop().Sugar()
mediaSetID := uuid.New()
mediaSet := store.MediaSet{
@ -210,7 +38,7 @@ func TestGetPeaksFromFileStore(t *testing.T) {
t.Run("NOK,ErrorFetchingMediaSet", func(t *testing.T) {
var mockStore mocks.Store
mockStore.On("GetMediaSet", mock.Anything, mediaSetID).Return(store.MediaSet{}, errors.New("db went boom"))
service := media.NewMediaSetService(&mockStore, nil, nil, nil, wp, config.Config{}, logger)
service := media.NewMediaSetService(&mockStore, nil, nil, nil, config.Config{}, logger)
_, err := service.GetPeaks(ctx, mediaSetID, 10)
assert.EqualError(t, err, "error getting media set: db went boom")
})
@ -223,7 +51,7 @@ func TestGetPeaksFromFileStore(t *testing.T) {
var fileStore mocks.FileStore
fileStore.On("GetObject", mock.Anything, "raw audio key").Return(nil, errors.New("boom"))
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, wp, config.Config{}, logger)
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, config.Config{}, logger)
_, err := service.GetPeaks(ctx, mediaSetID, 10)
require.EqualError(t, err, "error getting object from file store: boom")
})
@ -234,12 +62,12 @@ func TestGetPeaksFromFileStore(t *testing.T) {
defer mockStore.AssertExpectations(t)
var fileStore mocks.FileStore
reader := fixtureReader(t, inFixturePath, inFixtureLen)
reader := fixtureReader(t, inFixturePath, 5_292_000)
fileStore.On("GetObject", mock.Anything, "raw audio key").Return(reader, nil)
fileStore.On("GetURL", mock.Anything, "encoded audio key").Return("", errors.New("network error"))
defer fileStore.AssertExpectations(t)
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, wp, config.Config{}, logger)
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, config.Config{}, logger)
stream, err := service.GetPeaks(ctx, mediaSetID, 10)
require.NoError(t, err)
@ -261,58 +89,48 @@ func TestGetPeaksFromFileStore(t *testing.T) {
defer mockStore.AssertExpectations(t)
var fileStore mocks.FileStore
url := "https://www.example.com/foo"
reader := fixtureReader(t, inFixturePath, inFixtureLen)
reader := fixtureReader(t, inFixturePath, 5_292_000)
fileStore.On("GetObject", mock.Anything, "raw audio key").Return(reader, nil)
fileStore.On("GetURL", mock.Anything, "encoded audio key").Return(url, nil)
fileStore.On("GetURL", mock.Anything, "encoded audio key").Return("https://www.example.com/foo", nil)
defer fileStore.AssertExpectations(t)
numBins := 10
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, wp, config.Config{}, logger)
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, config.Config{}, logger)
stream, err := service.GetPeaks(ctx, mediaSetID, numBins)
require.NoError(t, err)
assertConsumeStream(t, numBins, url, 1_323_000, stream)
lastPeaks := make([]int16, 2) // stereo
var (
count int
lastPercentComplete float32
lastURL string
)
for {
progress, err := stream.Next()
if err != io.EOF {
require.NoError(t, err)
}
assert.Len(t, progress.Peaks, 2)
assert.GreaterOrEqual(t, progress.PercentComplete, lastPercentComplete)
lastPercentComplete = progress.PercentComplete
lastURL = progress.URL
if err == io.EOF {
break
}
// the fixture is a tone gradually increasing in amplitude:
assert.Greater(t, progress.Peaks[0], lastPeaks[0])
assert.Greater(t, progress.Peaks[1], lastPeaks[1])
lastPeaks = progress.Peaks
count++
}
assert.Equal(t, float32(100), lastPercentComplete)
assert.Equal(t, []int16{32_767, 32_766}, lastPeaks)
assert.Equal(t, numBins, count)
assert.Equal(t, "https://www.example.com/foo", lastURL)
})
}
// assertConsumeStream asserts that the stream produced by both the
// from-youtube and from-filestore flows is identical.
func assertConsumeStream(t *testing.T, expBins int, expURL string, expAudioFrames int64, stream media.GetPeaksProgressReader) {
lastPeaks := make([]int16, 2) // stereo
var (
count int
lastPercentComplete float32
lastURL string
lastAudioFrames int64
)
for {
progress, err := stream.Next()
if err != io.EOF {
require.NoError(t, err)
}
assert.Len(t, progress.Peaks, 2)
assert.GreaterOrEqual(t, progress.PercentComplete, lastPercentComplete)
lastPercentComplete = progress.PercentComplete
lastURL = progress.URL
lastAudioFrames = progress.AudioFrames
if err == io.EOF {
break
}
// the fixture is a tone gradually increasing in amplitude:
assert.Greater(t, progress.Peaks[0], lastPeaks[0])
assert.Greater(t, progress.Peaks[1], lastPeaks[1])
lastPeaks = progress.Peaks
count++
}
assert.Equal(t, float32(100), lastPercentComplete)
assert.Equal(t, []int16{32_767, 32_766}, lastPeaks)
assert.Equal(t, expBins, count)
assert.Equal(t, expURL, lastURL)
assert.Equal(t, expAudioFrames, lastAudioFrames)
}

View File

@ -1,7 +1,5 @@
package media
//go:generate mockery --recursive --name AudioSegmentStream --output ../generated/mocks
import (
"bytes"
"context"
@ -44,21 +42,14 @@ type AudioSegmentProgress struct {
Data []byte
}
// AudioSegmentStream implements stream of AudioSegmentProgress structs. The
// Next() method must be called until it returns io.EOF to avoid resource
// leakage.
type AudioSegmentStream interface {
Next(ctx context.Context) (AudioSegmentProgress, error)
}
// audioSegmentStream implements AudioSegmentStream.
type audioSegmentStream struct {
// AudioSegmentStream is a stream of AudioSegmentProgress structs.
type AudioSegmentStream struct {
progressChan chan AudioSegmentProgress
errorChan chan error
}
// send publishes a new partial segment and progress update to the strean.
func (s *audioSegmentStream) send(p []byte, percentComplete float32) {
func (s *AudioSegmentStream) send(p []byte, percentComplete float32) {
s.progressChan <- AudioSegmentProgress{
Data: p,
PercentComplete: percentComplete,
@ -66,12 +57,12 @@ func (s *audioSegmentStream) send(p []byte, percentComplete float32) {
}
// close signals the successful end of the stream of data.
func (s *audioSegmentStream) close() {
func (s *AudioSegmentStream) close() {
close(s.progressChan)
}
// closeWithError signals the unsuccessful end of a stream of data.
func (s *audioSegmentStream) closeWithError(err error) {
func (s *AudioSegmentStream) closeWithError(err error) {
s.errorChan <- err
}
@ -79,25 +70,23 @@ func (s *audioSegmentStream) closeWithError(err error) {
type audioSegmentGetter struct {
mu sync.Mutex
commandFunc CommandFunc
workerPool *WorkerPool
rawAudio io.ReadCloser
channels int32
outFormat AudioFormat
stream *audioSegmentStream
stream *AudioSegmentStream
bytesRead, bytesExpected int64
}
// newAudioSegmentGetter returns a new audioSegmentGetter. The io.ReadCloser
// will be consumed and closed by the getAudioSegment() function.
func newAudioSegmentGetter(commandFunc CommandFunc, workerPool *WorkerPool, rawAudio io.ReadCloser, channels int32, bytesExpected int64, outFormat AudioFormat) *audioSegmentGetter {
func newAudioSegmentGetter(commandFunc CommandFunc, rawAudio io.ReadCloser, channels int32, bytesExpected int64, outFormat AudioFormat) *audioSegmentGetter {
return &audioSegmentGetter{
commandFunc: commandFunc,
workerPool: workerPool,
rawAudio: rawAudio,
channels: channels,
bytesExpected: bytesExpected,
outFormat: outFormat,
stream: &audioSegmentStream{
stream: &AudioSegmentStream{
progressChan: make(chan AudioSegmentProgress),
errorChan: make(chan error, 1),
},
@ -131,7 +120,7 @@ func (s *audioSegmentGetter) percentComplete() float32 {
}
// Next implements AudioSegmentStream.
func (s *audioSegmentStream) Next(ctx context.Context) (AudioSegmentProgress, error) {
func (s *AudioSegmentStream) Next(ctx context.Context) (AudioSegmentProgress, error) {
select {
case progress, ok := <-s.progressChan:
if !ok {
@ -148,26 +137,19 @@ func (s *audioSegmentStream) Next(ctx context.Context) (AudioSegmentProgress, er
func (s *audioSegmentGetter) getAudioSegment(ctx context.Context) {
defer s.rawAudio.Close()
err := s.workerPool.WaitForTask(ctx, func() error {
var stdErr bytes.Buffer
cmd := s.commandFunc(ctx, "ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "s16le", "-ac", itoa(int(s.channels)), "-ar", itoa(rawAudioSampleRate), "-i", "-", "-f", s.outFormat.String(), "-")
cmd.Stderr = &stdErr
cmd.Stdin = s
cmd.Stdout = s
var stdErr bytes.Buffer
cmd := s.commandFunc(ctx, "ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "s16le", "-ac", itoa(int(s.channels)), "-ar", itoa(rawAudioSampleRate), "-i", "-", "-f", s.outFormat.String(), "-")
cmd.Stderr = &stdErr
cmd.Stdin = s
cmd.Stdout = s
if err := cmd.Start(); err != nil {
return fmt.Errorf("error starting command: %v, output: %s", err, stdErr.String())
}
if err := cmd.Start(); err != nil {
s.stream.closeWithError(fmt.Errorf("error starting command: %v, output: %s", err, stdErr.String()))
return
}
if err := cmd.Wait(); err != nil {
return fmt.Errorf("error waiting for ffmpeg: %v, output: %s", err, stdErr.String())
}
return nil
})
if err != nil {
s.stream.closeWithError(err)
if err := cmd.Wait(); err != nil {
s.stream.closeWithError(fmt.Errorf("error waiting for ffmpeg: %v, output: %s", err, stdErr.String()))
return
}

View File

@ -4,7 +4,12 @@ import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strconv"
"strings"
"testing"
"git.netflux.io/rob/clipper/config"
@ -19,15 +24,87 @@ import (
"go.uber.org/zap"
)
func fixtureReader(t *testing.T, fixturePath string, limit int64) io.ReadCloser {
fptr, err := os.Open(fixturePath)
require.NoError(t, err)
// limitReader to make the mock work realistically, not intended for assertions:
return struct {
io.Reader
io.Closer
}{
Reader: io.LimitReader(fptr, limit),
Closer: fptr,
}
}
func helperCommand(t *testing.T, wantCommand, stdoutFile, stderrString string, forceExitCode int) media.CommandFunc {
return func(ctx context.Context, name string, args ...string) *exec.Cmd {
cs := []string{"-test.run=TestHelperProcess", "--", name}
cs = append(cs, args...)
cmd := exec.CommandContext(ctx, os.Args[0], cs...)
cmd.Env = []string{
"GO_WANT_HELPER_PROCESS=1",
"GO_WANT_COMMAND=" + wantCommand,
"GO_STDOUT_FILE=" + stdoutFile,
"GO_STDERR_STRING=" + stderrString,
"GO_FORCE_EXIT_CODE=" + strconv.Itoa(forceExitCode),
}
return cmd
}
}
func TestHelperProcess(t *testing.T) {
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
return
}
defer func() {
// Stop the helper process writing to stdout after the test has finished.
// This prevents it from writing the "PASS" string which is unwanted in
// this context.
if !t.Failed() {
os.Stdout, _ = os.Open(os.DevNull)
}
}()
if exitCode := os.Getenv("GO_FORCE_EXIT_CODE"); exitCode != "0" {
c, _ := strconv.Atoi(exitCode)
os.Stderr.WriteString(os.Getenv("GO_STDERR_STRING"))
os.Exit(c)
}
if wantCommand := os.Getenv("GO_WANT_COMMAND"); wantCommand != "" {
gotCmd := strings.Split(strings.Join(os.Args, " "), " -- ")[1]
if wantCommand != gotCmd {
fmt.Printf("GO_WANT_COMMAND assertion failed:\nwant = %v\ngot = %v", wantCommand, gotCmd)
return
}
}
// Copy stdin to /dev/null. This is required to avoid broken pipe errors in
// the tests:
_, err := io.Copy(io.Discard, os.Stdin)
require.NoError(t, err)
// If an output file is provided, then copy that to stdout:
if fname := os.Getenv("GO_STDOUT_FILE"); fname != "" {
fptr, err := os.Open(fname)
require.NoError(t, err)
_, err = io.Copy(os.Stdout, fptr)
require.NoError(t, err)
}
}
func TestGetSegment(t *testing.T) {
const inFixturePath = "testdata/tone-44100-stereo-int16-30000ms.raw"
mediaSetID := uuid.MustParse("4c440241-cca9-436f-adb0-be074588cf2b")
wp := media.NewTestWorkerPool()
const inFixturePath = "testdata/tone-44100-stereo-int16-30000ms.raw"
t.Run("invalid range", func(t *testing.T) {
var mockStore mocks.Store
var fileStore mocks.FileStore
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, wp, config.Config{}, zap.NewNop().Sugar())
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, config.Config{}, zap.NewNop().Sugar())
stream, err := service.GetAudioSegment(context.Background(), mediaSetID, 1, 0, media.AudioFormatMP3)
require.Nil(t, stream)
@ -38,7 +115,7 @@ func TestGetSegment(t *testing.T) {
var mockStore mocks.Store
mockStore.On("GetMediaSet", mock.Anything, mediaSetID).Return(store.MediaSet{}, pgx.ErrNoRows)
var fileStore mocks.FileStore
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, wp, config.Config{}, zap.NewNop().Sugar())
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, config.Config{}, zap.NewNop().Sugar())
stream, err := service.GetAudioSegment(context.Background(), mediaSetID, 0, 1, media.AudioFormatMP3)
require.Nil(t, stream)
@ -55,7 +132,7 @@ func TestGetSegment(t *testing.T) {
fileStore.On("GetObjectWithRange", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(nil, errors.New("network error"))
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, wp, config.Config{}, zap.NewNop().Sugar())
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, config.Config{}, zap.NewNop().Sugar())
stream, err := service.GetAudioSegment(context.Background(), mediaSetID, 0, 1, media.AudioFormatMP3)
require.Nil(t, stream)
@ -73,7 +150,7 @@ func TestGetSegment(t *testing.T) {
Return(fixtureReader(t, inFixturePath, 1), nil)
cmd := helperCommand(t, "", "", "something bad happened", 2)
service := media.NewMediaSetService(&mockStore, nil, &fileStore, cmd, wp, config.Config{}, zap.NewNop().Sugar())
service := media.NewMediaSetService(&mockStore, nil, &fileStore, cmd, config.Config{}, zap.NewNop().Sugar())
stream, err := service.GetAudioSegment(context.Background(), mediaSetID, 0, 1, media.AudioFormatMP3)
require.NoError(t, err)
@ -159,7 +236,7 @@ func TestGetSegment(t *testing.T) {
defer fileStore.AssertExpectations(t)
cmd := helperCommand(t, tc.wantCommand, tc.outFixturePath, "", 0)
service := media.NewMediaSetService(&mockStore, nil, &fileStore, cmd, wp, config.Config{}, zap.NewNop().Sugar())
service := media.NewMediaSetService(&mockStore, nil, &fileStore, cmd, config.Config{}, zap.NewNop().Sugar())
stream, err := service.GetAudioSegment(ctx, mediaSetID, tc.inStartFrame, tc.inEndFrame, tc.audioFormat)
require.NoError(t, err)

View File

@ -30,7 +30,7 @@ type videoGetter struct {
type videoGetterState struct {
*videoGetter
r io.ReadCloser
r io.Reader
count, exp int64
mediaSetID uuid.UUID
key, contentType string
@ -45,14 +45,12 @@ func newVideoGetter(store Store, fileStore FileStore, logger *zap.SugaredLogger)
// GetVideo gets video from Youtube and uploads it to a filestore using the
// specified key and content type. The returned reader must have its Next()
// method called until err == io.EOF, otherwise a deadlock or other resource
// method called until error = io.EOF, otherwise a deadlock or other resource
// leakage is likely.
//
// GetVideo will consume and close r.
func (g *videoGetter) GetVideo(ctx context.Context, r io.ReadCloser, exp int64, mediaSetID uuid.UUID, key, contentType string) (GetVideoProgressReader, error) {
func (g *videoGetter) GetVideo(ctx context.Context, r io.Reader, exp int64, mediaSetID uuid.UUID, key, contentType string) (GetVideoProgressReader, error) {
s := &videoGetterState{
videoGetter: g,
r: r,
r: newLogProgressReader(r, "video", exp, g.logger),
exp: exp,
mediaSetID: mediaSetID,
key: key,
@ -77,8 +75,7 @@ func (s *videoGetterState) Write(p []byte) (int, error) {
}
func (s *videoGetterState) getVideo(ctx context.Context) {
logReader := newLogProgressReader(s.r, "video", s.exp, s.logger)
teeReader := io.TeeReader(logReader, s)
teeReader := io.TeeReader(s.r, s)
_, err := s.fileStore.PutObject(ctx, s.key, teeReader, s.contentType)
if err != nil {
@ -86,15 +83,9 @@ func (s *videoGetterState) getVideo(ctx context.Context) {
return
}
if err = s.r.Close(); err != nil {
s.errorChan <- fmt.Errorf("error closing video stream: %v", err)
return
}
s.url, err = s.fileStore.GetURL(ctx, s.key)
if err != nil {
s.errorChan <- fmt.Errorf("error getting object URL: %v", err)
return
}
storeParams := store.SetVideoUploadedParams{
@ -104,7 +95,6 @@ func (s *videoGetterState) getVideo(ctx context.Context) {
_, err = s.store.SetVideoUploaded(ctx, storeParams)
if err != nil {
s.errorChan <- fmt.Errorf("error saving to store: %v", err)
return
}
close(s.progressChan)
@ -125,10 +115,10 @@ func (s *videoGetterState) Next() (GetVideoProgress, error) {
}
}
type videoGetterFromFileStore string
type videoGetterDownloaded string
// Next() implements GetVideoProgressReader.
func (s *videoGetterFromFileStore) Next() (GetVideoProgress, error) {
func (s *videoGetterDownloaded) Next() (GetVideoProgress, error) {
return GetVideoProgress{
PercentComplete: 100,
URL: string(*s),

View File

@ -1,276 +0,0 @@
package media_test
import (
"bytes"
"context"
"database/sql"
"errors"
"io"
"strings"
"testing"
"time"
"git.netflux.io/rob/clipper/config"
"git.netflux.io/rob/clipper/generated/mocks"
"git.netflux.io/rob/clipper/generated/store"
"git.netflux.io/rob/clipper/media"
"github.com/google/uuid"
"github.com/kkdai/youtube/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
func TestGetVideoFromYoutube(t *testing.T) {
ctx := context.Background()
wp := media.NewTestWorkerPool()
logger := zap.NewNop().Sugar()
const (
itag = 234
videoID = "video001"
videoMimeType = "video/mp4"
videoContentLength = int64(100_000)
)
mediaSetID := uuid.New()
mediaSet := store.MediaSet{
ID: mediaSetID,
YoutubeID: videoID,
VideoYoutubeItag: int32(itag),
}
video := &youtube.Video{
ID: videoID,
Formats: []youtube.Format{{ItagNo: itag, FPS: 30, AudioChannels: 0, MimeType: videoMimeType, ContentLength: videoContentLength}},
}
t.Run("NOK,ErrorFetchingVideo", func(t *testing.T) {
var mockStore mocks.Store
mockStore.On("GetMediaSet", ctx, mediaSetID).Return(mediaSet, nil)
var youtubeClient mocks.YoutubeClient
youtubeClient.On("GetVideoContext", ctx, videoID).Return(nil, errors.New("nope"))
service := media.NewMediaSetService(&mockStore, &youtubeClient, nil, nil, wp, config.Config{}, logger)
_, err := service.GetVideo(ctx, mediaSetID)
assert.EqualError(t, err, "error fetching video: nope")
})
t.Run("NOK,ErrorFetchingStream", func(t *testing.T) {
var mockStore mocks.Store
mockStore.On("GetMediaSet", ctx, mediaSetID).Return(mediaSet, nil)
var youtubeClient mocks.YoutubeClient
youtubeClient.On("GetVideoContext", ctx, videoID).Return(video, nil)
youtubeClient.On("GetStreamContext", ctx, video, &video.Formats[0]).Return(nil, int64(0), errors.New("network failure"))
service := media.NewMediaSetService(&mockStore, &youtubeClient, nil, nil, wp, config.Config{}, logger)
_, err := service.GetVideo(ctx, mediaSetID)
assert.EqualError(t, err, "error fetching stream: network failure")
})
t.Run("NOK,ErrorPuttingObjectInFileStore", func(t *testing.T) {
var mockStore mocks.Store
mockStore.On("GetMediaSet", ctx, mediaSetID).Return(mediaSet, nil)
encodedContent := "a video stream"
reader := io.NopCloser(strings.NewReader(encodedContent))
var youtubeClient mocks.YoutubeClient
youtubeClient.On("GetVideoContext", ctx, videoID).Return(video, nil)
youtubeClient.On("GetStreamContext", ctx, video, &video.Formats[0]).Return(reader, int64(len(encodedContent)), nil)
var fileStore mocks.FileStore
fileStore.On("PutObject", ctx, mock.Anything, mock.Anything, videoMimeType).Return(int64(0), errors.New("error storing object"))
service := media.NewMediaSetService(&mockStore, &youtubeClient, &fileStore, nil, wp, config.Config{}, logger)
stream, err := service.GetVideo(ctx, mediaSetID)
require.NoError(t, err)
_, err = stream.Next()
assert.EqualError(t, err, "error waiting for progress: error uploading to file store: error storing object")
})
t.Run("NOK,ErrorClosingStream", func(t *testing.T) {
var mockStore mocks.Store
mockStore.On("GetMediaSet", ctx, mediaSetID).Return(mediaSet, nil)
encodedContent := "a video stream"
reader := errorCloser{Reader: strings.NewReader(encodedContent)}
var youtubeClient mocks.YoutubeClient
youtubeClient.On("GetVideoContext", ctx, videoID).Return(video, nil)
youtubeClient.On("GetStreamContext", ctx, video, &video.Formats[0]).Return(reader, int64(len(encodedContent)), nil)
var fileStore mocks.FileStore
fileStore.On("PutObject", ctx, mock.Anything, mock.Anything, videoMimeType).Return(int64(len(encodedContent)), nil)
service := media.NewMediaSetService(&mockStore, &youtubeClient, &fileStore, nil, wp, config.Config{}, logger)
stream, err := service.GetVideo(ctx, mediaSetID)
require.NoError(t, err)
_, err = stream.Next()
assert.EqualError(t, err, "error waiting for progress: error closing video stream: close error")
})
t.Run("NOK,ErrorGettingObjectURL", func(t *testing.T) {
var mockStore mocks.Store
mockStore.On("GetMediaSet", ctx, mediaSetID).Return(mediaSet, nil)
encodedContent := "a video stream"
reader := io.NopCloser(strings.NewReader(encodedContent))
var youtubeClient mocks.YoutubeClient
youtubeClient.On("GetVideoContext", ctx, videoID).Return(video, nil)
youtubeClient.On("GetStreamContext", ctx, video, &video.Formats[0]).Return(reader, int64(len(encodedContent)), nil)
var fileStore mocks.FileStore
fileStore.On("PutObject", ctx, mock.Anything, mock.Anything, videoMimeType).Return(int64(len(encodedContent)), nil)
fileStore.On("GetURL", ctx, mock.Anything).Return("", errors.New("URL error"))
service := media.NewMediaSetService(&mockStore, &youtubeClient, &fileStore, nil, wp, config.Config{}, logger)
stream, err := service.GetVideo(ctx, mediaSetID)
require.NoError(t, err)
_, err = stream.Next()
assert.EqualError(t, err, "error waiting for progress: error getting object URL: URL error")
})
t.Run("NOK,ErrorUpdatingStore", func(t *testing.T) {
var mockStore mocks.Store
mockStore.On("GetMediaSet", ctx, mediaSetID).Return(mediaSet, nil)
mockStore.On("SetVideoUploaded", ctx, mock.Anything).Return(mediaSet, errors.New("boom"))
encodedContent := "a video stream"
reader := io.NopCloser(strings.NewReader(encodedContent))
var youtubeClient mocks.YoutubeClient
youtubeClient.On("GetVideoContext", ctx, videoID).Return(video, nil)
youtubeClient.On("GetStreamContext", ctx, video, &video.Formats[0]).Return(reader, int64(len(encodedContent)), nil)
var fileStore mocks.FileStore
fileStore.On("PutObject", ctx, mock.Anything, mock.Anything, videoMimeType).Return(int64(len(encodedContent)), nil)
fileStore.On("GetURL", ctx, mock.Anything).Return("a url", nil)
service := media.NewMediaSetService(&mockStore, &youtubeClient, &fileStore, nil, wp, config.Config{}, logger)
stream, err := service.GetVideo(ctx, mediaSetID)
require.NoError(t, err)
_, err = stream.Next()
assert.EqualError(t, err, "error waiting for progress: error saving to store: boom")
})
t.Run("OK", func(t *testing.T) {
var mockStore mocks.Store
mockStore.On("GetMediaSet", ctx, mediaSetID).Return(mediaSet, nil)
mockStore.On("SetVideoUploaded", ctx, mock.Anything).Return(mediaSet, nil)
defer mockStore.AssertExpectations(t)
encodedContent := make([]byte, videoContentLength)
reader := io.NopCloser(bytes.NewReader(encodedContent))
var youtubeClient mocks.YoutubeClient
youtubeClient.On("GetVideoContext", ctx, videoID).Return(video, nil)
youtubeClient.On("GetStreamContext", ctx, video, &video.Formats[0]).Return(reader, videoContentLength, nil)
defer youtubeClient.AssertExpectations(t)
var fileStore mocks.FileStore
fileStore.On("PutObject", ctx, mock.Anything, mock.Anything, videoMimeType).
Run(func(args mock.Arguments) {
n, err := io.Copy(io.Discard, args[2].(io.Reader))
require.NoError(t, err)
assert.Equal(t, videoContentLength, n)
}).
Return(videoContentLength, nil)
fileStore.On("GetURL", ctx, mock.Anything).Return("a url", nil)
defer fileStore.AssertExpectations(t)
service := media.NewMediaSetService(&mockStore, &youtubeClient, &fileStore, nil, wp, config.Config{}, logger)
stream, err := service.GetVideo(ctx, mediaSetID)
require.NoError(t, err)
var (
lastPercentComplete float32
lastURL string
)
for {
progress, err := stream.Next()
if err != io.EOF {
require.NoError(t, err)
}
assert.GreaterOrEqual(t, progress.PercentComplete, lastPercentComplete)
lastPercentComplete = progress.PercentComplete
lastURL = progress.URL
if err == io.EOF {
break
}
}
assert.Equal(t, float32(100), lastPercentComplete)
assert.Equal(t, "a url", lastURL)
})
}
type errorCloser struct {
io.Reader
}
func (c errorCloser) Close() error { return errors.New("close error") }
func TestGetVideoFromFileStore(t *testing.T) {
ctx := context.Background()
wp := media.NewTestWorkerPool()
logger := zap.NewNop().Sugar()
videoID := "video002"
mediaSetID := uuid.New()
mediaSet := store.MediaSet{
ID: mediaSetID,
YoutubeID: videoID,
VideoS3UploadedAt: sql.NullTime{Time: time.Now(), Valid: true},
VideoS3Key: sql.NullString{String: "videos/myvideo", Valid: true},
}
t.Run("NOK,ErrorFetchingMediaSet", func(t *testing.T) {
var mockStore mocks.Store
mockStore.On("GetMediaSet", ctx, mediaSetID).Return(store.MediaSet{}, errors.New("database fail"))
service := media.NewMediaSetService(&mockStore, nil, nil, nil, wp, config.Config{}, logger)
_, err := service.GetVideo(ctx, mediaSetID)
require.EqualError(t, err, "error getting media set: database fail")
})
t.Run("NOK,ErrorGettingObjectURL", func(t *testing.T) {
var mockStore mocks.Store
mockStore.On("GetMediaSet", ctx, mediaSetID).Return(mediaSet, nil)
var fileStore mocks.FileStore
fileStore.On("GetURL", ctx, "videos/myvideo").Return("", errors.New("key missing"))
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, wp, config.Config{}, logger)
_, err := service.GetVideo(ctx, mediaSetID)
require.EqualError(t, err, "error generating presigned URL: key missing")
})
t.Run("OK", func(t *testing.T) {
var mockStore mocks.Store
mockStore.On("GetMediaSet", ctx, mediaSetID).Return(mediaSet, nil)
const url = "https://www.example.com/audio"
var fileStore mocks.FileStore
fileStore.On("GetURL", ctx, "videos/myvideo").Return(url, nil)
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, wp, config.Config{}, logger)
stream, err := service.GetVideo(ctx, mediaSetID)
require.NoError(t, err)
progress, err := stream.Next()
assert.Equal(t, float32(100), progress.PercentComplete)
assert.Equal(t, url, progress.URL)
assert.Equal(t, io.EOF, err)
})
}

View File

@ -9,7 +9,6 @@ import (
"fmt"
"io"
"strconv"
"strings"
"time"
"git.netflux.io/rob/clipper/config"
@ -38,18 +37,16 @@ type MediaSetService struct {
youtube YoutubeClient
fileStore FileStore
commandFunc CommandFunc
workerPool *WorkerPool
config config.Config
logger *zap.SugaredLogger
}
func NewMediaSetService(store Store, youtubeClient YoutubeClient, fileStore FileStore, commandFunc CommandFunc, workerPool *WorkerPool, config config.Config, logger *zap.SugaredLogger) *MediaSetService {
func NewMediaSetService(store Store, youtubeClient YoutubeClient, fileStore FileStore, commandFunc CommandFunc, config config.Config, logger *zap.SugaredLogger) *MediaSetService {
return &MediaSetService{
store: store,
youtube: youtubeClient,
fileStore: fileStore,
commandFunc: commandFunc,
workerPool: workerPool,
config: config,
logger: logger,
}
@ -101,9 +98,6 @@ func (s *MediaSetService) createMediaSet(ctx context.Context, youtubeID string)
storeParams := store.CreateMediaSetParams{
YoutubeID: youtubeID,
Title: strings.TrimSpace(video.Title),
Description: strings.TrimSpace(video.Description),
Author: strings.TrimSpace(video.Author),
AudioYoutubeItag: int32(audioMetadata.YoutubeItag),
AudioChannels: int32(audioMetadata.Channels),
AudioFramesApprox: audioMetadata.ApproxFrames,
@ -121,13 +115,10 @@ func (s *MediaSetService) createMediaSet(ctx context.Context, youtubeID string)
}
return &MediaSet{
ID: mediaSet.ID,
YoutubeID: youtubeID,
Title: mediaSet.Title,
Description: mediaSet.Description,
Author: mediaSet.Author,
Audio: audioMetadata,
Video: videoMetadata,
ID: mediaSet.ID,
YoutubeID: youtubeID,
Audio: audioMetadata,
Video: videoMetadata,
}, nil
}
@ -142,11 +133,8 @@ func (s *MediaSetService) findMediaSet(ctx context.Context, youtubeID string) (*
}
return &MediaSet{
ID: mediaSet.ID,
YoutubeID: mediaSet.YoutubeID,
Title: mediaSet.Title,
Description: mediaSet.Description,
Author: mediaSet.Author,
ID: mediaSet.ID,
YoutubeID: mediaSet.YoutubeID,
Audio: Audio{
YoutubeItag: int(mediaSet.AudioYoutubeItag),
ContentLength: mediaSet.AudioContentLength,
@ -226,12 +214,11 @@ func (s *MediaSetService) GetVideo(ctx context.Context, id uuid.UUID) (GetVideoP
}
if mediaSet.VideoS3UploadedAt.Valid {
var url string
url, err = s.fileStore.GetURL(ctx, mediaSet.VideoS3Key.String)
url, err := s.fileStore.GetURL(ctx, mediaSet.VideoS3Key.String)
if err != nil {
return nil, fmt.Errorf("error generating presigned URL: %v", err)
}
videoGetter := videoGetterFromFileStore(url)
videoGetter := videoGetterDownloaded(url)
return &videoGetter, nil
}
@ -284,7 +271,7 @@ func (s *MediaSetService) GetPeaks(ctx context.Context, id uuid.UUID, numBins in
}
func (s *MediaSetService) getAudioFromYoutube(ctx context.Context, mediaSet store.MediaSet, numBins int) (GetPeaksProgressReader, error) {
audioGetter := newAudioGetter(s.store, s.youtube, s.fileStore, s.commandFunc, s.workerPool, s.config, s.logger)
audioGetter := newAudioGetter(s.store, s.youtube, s.fileStore, s.config, s.logger)
return audioGetter.GetAudio(ctx, mediaSet, numBins)
}
@ -367,7 +354,7 @@ outer:
}
func (s *MediaSetService) GetPeaksForSegment(ctx context.Context, id uuid.UUID, startFrame, endFrame int64, numBins int) ([]int16, error) {
if startFrame < 0 || endFrame < 0 || numBins <= 0 || startFrame == endFrame {
if startFrame < 0 || endFrame < 0 || numBins <= 0 {
s.logger.With("startFrame", startFrame, "endFrame", endFrame, "numBins", numBins).Error("invalid arguments")
return nil, errors.New("invalid arguments")
}
@ -398,54 +385,44 @@ func (s *MediaSetService) GetPeaksForSegment(ctx context.Context, id uuid.UUID,
sampleBuf := make([]int16, readBufSizeBytes/SizeOfInt16)
bytesExpected := (endFrame - startFrame) * int64(channels) * SizeOfInt16
var bytesRead int64
var closing bool
var (
bytesRead int64
closing bool
currPeakIndex int
currFrame int64
)
for bin := 0; bin < numBins; bin++ {
framesRemaining := framesPerBin
if bin == numBins-1 {
framesRemaining += totalFrames % int64(numBins)
for {
n, err := modReader.Read(readBuf)
if err == io.EOF {
closing = true
} else if err != nil {
return nil, fmt.Errorf("read error: %v", err)
}
for {
// Read as many bytes as possible, but not exceeding the available buffer
// size nor framesRemaining:
bytesToRead := framesRemaining * int64(channels) * SizeOfInt16
max := int64(len(readBuf))
if bytesToRead > max {
bytesToRead = max
}
bytesRead += int64(n)
samples := sampleBuf[:n/SizeOfInt16]
n, err := modReader.Read(readBuf[:bytesToRead])
if err == io.EOF {
closing = true
} else if err != nil {
return nil, fmt.Errorf("read error: %v", err)
}
if err := binary.Read(bytes.NewReader(readBuf[:n]), binary.LittleEndian, samples); err != nil {
return nil, fmt.Errorf("error interpreting samples: %v", err)
}
ss := sampleBuf[:n/SizeOfInt16]
if err := binary.Read(bytes.NewReader(readBuf[:n]), binary.LittleEndian, ss); err != nil {
return nil, fmt.Errorf("error interpreting samples: %v", err)
}
pi := bin * channels
for i := 0; i < len(ss); i += channels {
for j := 0; j < channels; j++ {
s := ss[i+j]
if s < 0 {
s = -s
}
if s > peaks[pi+j] {
peaks[pi+j] = s
}
for i := 0; i < len(samples); i += channels {
for j := 0; j < channels; j++ {
samp := sampleBuf[i+j]
if samp < 0 {
samp = -samp
}
if samp > peaks[currPeakIndex+j] {
peaks[currPeakIndex+j] = samp
}
}
framesRemaining -= int64(n) / int64(channels) / SizeOfInt16
bytesRead += int64(n)
if closing || framesRemaining == 0 {
break
if currFrame == framesPerBin {
currFrame = 0
currPeakIndex += channels
} else {
currFrame++
}
}
@ -461,7 +438,7 @@ func (s *MediaSetService) GetPeaksForSegment(ctx context.Context, id uuid.UUID,
return peaks, nil
}
func (s *MediaSetService) GetAudioSegment(ctx context.Context, id uuid.UUID, startFrame, endFrame int64, outFormat AudioFormat) (AudioSegmentStream, error) {
func (s *MediaSetService) GetAudioSegment(ctx context.Context, id uuid.UUID, startFrame, endFrame int64, outFormat AudioFormat) (*AudioSegmentStream, error) {
if startFrame > endFrame {
return nil, errors.New("invalid range")
}
@ -481,7 +458,7 @@ func (s *MediaSetService) GetAudioSegment(ctx context.Context, id uuid.UUID, sta
return nil, fmt.Errorf("error getting object from store: %v", err)
}
g := newAudioSegmentGetter(s.commandFunc, s.workerPool, rawAudio, mediaSet.AudioChannels, endByte-startByte, outFormat)
g := newAudioSegmentGetter(s.commandFunc, rawAudio, mediaSet.AudioChannels, endByte-startByte, outFormat)
go g.getAudioSegment(ctx)
return g.stream, nil

View File

@ -4,9 +4,9 @@ import (
"bytes"
"context"
"database/sql"
"errors"
"io"
"os"
"os/exec"
"testing"
"git.netflux.io/rob/clipper/config"
@ -20,31 +20,11 @@ import (
"go.uber.org/zap"
)
// segmentReader returns an error if provided after reading errBytes bytes.
type segmentReader struct {
r io.Reader
n, errBytes int64
err error
}
func (r *segmentReader) Read(p []byte) (int, error) {
n, err := r.r.Read(p)
r.n += int64(n)
if r.n >= r.errBytes && r.err != nil {
return n, r.err
}
return n, err
}
func (r *segmentReader) Close() error { return nil }
func TestPeaksForSegment(t *testing.T) {
testCases := []struct {
name string
fixturePath string
fixtureReadErrBytes int64
fixtureReadErr error
fixtureMaxRead int64
fixtureLen int64
startFrame, endFrame int64
channels int32
numBins int
@ -52,17 +32,9 @@ func TestPeaksForSegment(t *testing.T) {
wantErr string
}{
{
name: "NOK, invalid arguments",
fixturePath: "testdata/tone-44100-stereo-int16.raw",
startFrame: 0,
endFrame: 0,
channels: 2,
numBins: 1,
wantErr: "invalid arguments",
},
{
name: "OK, entire fixture, stereo, 1 bin",
name: "entire fixture, stereo, 1 bin",
fixturePath: "testdata/tone-44100-stereo-int16.raw",
fixtureLen: 176400,
startFrame: 0,
endFrame: 44100,
channels: 2,
@ -70,8 +42,9 @@ func TestPeaksForSegment(t *testing.T) {
wantPeaks: []int16{32747, 32747},
},
{
name: "OK, entire fixture, stereo, 4 bins",
name: "entire fixture, stereo, 4 bins",
fixturePath: "testdata/tone-44100-stereo-int16.raw",
fixtureLen: 176400,
startFrame: 0,
endFrame: 44100,
channels: 2,
@ -79,8 +52,9 @@ func TestPeaksForSegment(t *testing.T) {
wantPeaks: []int16{8173, 8177, 16366, 16370, 24557, 24555, 32747, 32747},
},
{
name: "OK, entire fixture, stereo, 16 bins",
name: "entire fixture, stereo, 16 bins",
fixturePath: "testdata/tone-44100-stereo-int16.raw",
fixtureLen: 176400,
startFrame: 0,
endFrame: 44100,
channels: 2,
@ -88,8 +62,9 @@ func TestPeaksForSegment(t *testing.T) {
wantPeaks: []int16{2029, 2029, 4075, 4076, 6124, 6125, 8173, 8177, 10222, 10221, 12267, 12265, 14314, 14313, 16366, 16370, 18413, 18411, 20453, 20454, 22505, 22508, 24557, 24555, 26604, 26605, 28644, 28643, 30698, 30694, 32747, 32747},
},
{
name: "OK, entire fixture, mono, 1 bin",
name: "entire fixture, mono, 1 bin",
fixturePath: "testdata/tone-44100-mono-int16.raw",
fixtureLen: 88200,
startFrame: 0,
endFrame: 44100,
channels: 1,
@ -97,34 +72,14 @@ func TestPeaksForSegment(t *testing.T) {
wantPeaks: []int16{32748},
},
{
name: "OK, entire fixture, mono, 32 bins",
name: "entire fixture, mono, 32 bins",
fixturePath: "testdata/tone-44100-mono-int16.raw",
fixtureLen: 88200,
startFrame: 0,
endFrame: 44100,
channels: 1,
numBins: 32,
wantPeaks: []int16{1018, 2030, 3060, 4075, 5092, 6126, 7129, 8172, 9174, 10217, 11227, 12264, 13272, 14315, 15319, 16364, 17370, 18412, 19417, 20453, 21457, 22504, 23513, 24554, 25564, 26607, 27607, 28642, 29647, 30700, 31699, 32748},
},
{
name: "NOK, entire fixture, mono, 32 bins, read returns io.EOF after 50% complete",
fixturePath: "testdata/tone-44100-mono-int16.raw",
fixtureMaxRead: 44100,
startFrame: 0,
endFrame: 44100,
channels: 1,
numBins: 32,
wantPeaks: []int16{1018, 2030, 3060, 4075, 5092, 6126, 7129, 8172, 9174, 10217, 11227, 12264, 13272, 14315, 15319, 16364, 2053, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
},
{
name: "NOK, entire fixture, mono, 32 bins, read error after 50% complete",
fixturePath: "testdata/tone-44100-mono-int16.raw",
fixtureReadErrBytes: 44100,
fixtureReadErr: errors.New("foo"),
startFrame: 0,
endFrame: 44100,
channels: 1,
numBins: 32,
wantErr: "read error: foo",
wantPeaks: []int16{1026, 2030, 3071, 4075, 5122, 6126, 7167, 8172, 9213, 10217, 11259, 12264, 13311, 14315, 15360, 16364, 17405, 18412, 19450, 20453, 21497, 22504, 23549, 24554, 25599, 26607, 27641, 28642, 29688, 30738, 31746, 32748},
},
}
@ -133,18 +88,11 @@ func TestPeaksForSegment(t *testing.T) {
startByte := tc.startFrame * int64(tc.channels) * media.SizeOfInt16
endByte := tc.endFrame * int64(tc.channels) * media.SizeOfInt16
expectedBytes := endByte - startByte
if tc.fixtureMaxRead != 0 {
expectedBytes = tc.fixtureMaxRead
}
fixture, err := os.Open(tc.fixturePath)
audioFile, err := os.Open(tc.fixturePath)
require.NoError(t, err)
defer fixture.Close()
sr := segmentReader{
r: io.LimitReader(fixture, int64(expectedBytes)),
err: tc.fixtureReadErr,
errBytes: tc.fixtureReadErrBytes,
}
defer audioFile.Close()
audioData := io.NopCloser(io.LimitReader(audioFile, int64(expectedBytes)))
mediaSet := store.MediaSet{
ID: uuid.New(),
@ -155,20 +103,19 @@ func TestPeaksForSegment(t *testing.T) {
// store is passed the mediaSetID and returns a mediaSet
store := &mocks.Store{}
store.On("GetMediaSet", mock.Anything, mediaSet.ID).Return(mediaSet, nil)
defer store.AssertExpectations(t)
// fileStore is passed the expected byte range, and returns an io.Reader
fileStore := &mocks.FileStore{}
fileStore.
On("GetObjectWithRange", mock.Anything, "foo", startByte, endByte).
Return(&sr, nil)
Return(audioData, nil)
service := media.NewMediaSetService(store, nil, fileStore, nil, media.NewTestWorkerPool(), config.Config{}, zap.NewNop().Sugar())
service := media.NewMediaSetService(store, nil, fileStore, exec.CommandContext, config.Config{}, zap.NewNop().Sugar())
peaks, err := service.GetPeaksForSegment(context.Background(), mediaSet.ID, tc.startFrame, tc.endFrame, tc.numBins)
if tc.wantErr == "" {
defer store.AssertExpectations(t)
require.NoError(t, err)
assert.NoError(t, err)
assert.Equal(t, tc.wantPeaks, peaks)
} else {
assert.EqualError(t, err, tc.wantErr)
@ -183,6 +130,7 @@ func BenchmarkGetPeaksForSegment(b *testing.B) {
endFrame = 1323000
channels = 2
fixturePath = "testdata/tone-44100-stereo-int16-30000ms.raw"
fixtureLen = 5292000
numBins = 2000
)
@ -206,7 +154,7 @@ func BenchmarkGetPeaksForSegment(b *testing.B) {
On("GetObjectWithRange", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(readCloser, nil)
service := media.NewMediaSetService(store, nil, fileStore, nil, media.NewTestWorkerPool(), config.Config{}, zap.NewNop().Sugar())
service := media.NewMediaSetService(store, nil, fileStore, exec.CommandContext, config.Config{}, zap.NewNop().Sugar())
b.StartTimer()
_, err = service.GetPeaksForSegment(context.Background(), mediaSetID, startFrame, endFrame, numBins)

View File

@ -1,103 +0,0 @@
package media_test
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"strconv"
"strings"
"testing"
"git.netflux.io/rob/clipper/media"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
// fixtureReader loads a fixture into a ReadCloser with the provided limit.
func fixtureReader(t *testing.T, fixturePath string, limit int64) io.ReadCloser {
fptr, err := os.Open(fixturePath)
require.NoError(t, err)
// limitReader to make the mock work realistically, not intended for assertions:
return struct {
io.Reader
io.Closer
}{
Reader: io.LimitReader(fptr, limit),
Closer: fptr,
}
}
// helperCommand returns a function that builds an *exec.Cmd which executes a
// test function in order to act as a mock process.
func helperCommand(t *testing.T, wantCommand, stdoutFile, stderrString string, forceExitCode int) media.CommandFunc {
return func(ctx context.Context, name string, args ...string) *exec.Cmd {
cs := []string{"-test.run=TestHelperProcess", "--", name}
cs = append(cs, args...)
cmd := exec.CommandContext(ctx, os.Args[0], cs...)
cmd.Env = []string{
"GO_WANT_HELPER_PROCESS=1",
"GO_WANT_COMMAND=" + wantCommand,
"GO_STDOUT_FILE=" + stdoutFile,
"GO_STDERR_STRING=" + stderrString,
"GO_FORCE_EXIT_CODE=" + strconv.Itoa(forceExitCode),
}
return cmd
}
}
// TestHelperProcess is the body for the mock executable process built by
// helperCommand.
func TestHelperProcess(t *testing.T) {
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
return
}
defer func() {
// Stop the helper process writing to stdout after the test has finished.
// This prevents it from writing the "PASS" string which is unwanted in
// this context.
if !t.Failed() {
os.Stdout, _ = os.Open(os.DevNull)
}
}()
if exitCode := os.Getenv("GO_FORCE_EXIT_CODE"); exitCode != "0" {
c, _ := strconv.Atoi(exitCode)
os.Stderr.WriteString(os.Getenv("GO_STDERR_STRING"))
os.Exit(c)
}
if wantCommand := os.Getenv("GO_WANT_COMMAND"); wantCommand != "" {
_, gotCmd, _ := strings.Cut(strings.Join(os.Args, " "), " -- ")
if wantCommand != gotCmd {
fmt.Fprintf(os.Stderr, "GO_WANT_COMMAND assertion failed:\nwant = %v\ngot = %v", wantCommand, gotCmd)
t.Fail() // necessary to make the test fail
}
}
// Copy stdin to /dev/null. This is required to avoid broken pipe errors in
// the tests:
_, err := io.Copy(io.Discard, os.Stdin)
require.NoError(t, err)
// If an output file is provided, then copy that to stdout:
if fname := os.Getenv("GO_STDOUT_FILE"); fname != "" {
fptr, err := os.Open(fname)
require.NoError(t, err)
defer fptr.Close()
_, err = io.Copy(os.Stdout, fptr)
require.NoError(t, err)
}
}
// testLogger returns a functional development logger.
//lint:ignore U1000 helper method
func testLogger(t *testing.T) *zap.Logger {
l, err := zap.NewDevelopment()
require.NoError(t, err)
return l
}

View File

@ -20,11 +20,10 @@ const SizeOfInt16 = 2
// MediaSet represents the media and metadata associated with a single media
// resource (for example, a YouTube video).
type MediaSet struct {
Audio Audio
Video Video
ID uuid.UUID
YoutubeID string
Title, Description, Author string
Audio Audio
Video Video
ID uuid.UUID
YoutubeID string
}
// Audio contains the metadata for the audio part of the media set.

View File

@ -1,73 +0,0 @@
package media
import (
"context"
"errors"
"time"
"go.uber.org/zap"
)
// WorkerPool is a pool of workers that can consume and run a queue of tasks.
type WorkerPool struct {
size int
ch chan func()
logger *zap.SugaredLogger
}
// NewWorkerPool returns a new WorkerPool containing the specified number of
// workers, and with the provided maximum queue size. Jobs added to the queue
// after it reaches this size limit will be rejected.
func NewWorkerPool(size int, maxQueueSize int, logger *zap.SugaredLogger) *WorkerPool {
return &WorkerPool{
size: size,
ch: make(chan func(), maxQueueSize),
logger: logger,
}
}
// NewTestWorkerPool returns a new running WorkerPool with a single worker,
// and noop logger, suitable for test environments.
func NewTestWorkerPool() *WorkerPool {
p := NewWorkerPool(1, 256, zap.NewNop().Sugar())
p.Run()
return p
}
// Run launches the workers, and returns immediately.
func (p *WorkerPool) Run() {
for i := 0; i < p.size; i++ {
go func() {
for task := range p.ch {
task()
}
}()
}
}
// WaitForTask blocks while the provided task is executed by a worker,
// returning the error returned by the task.
func (p *WorkerPool) WaitForTask(ctx context.Context, taskFunc func() error) error {
done := make(chan error)
queuedAt := time.Now()
fn := func() {
startedAt := time.Now()
result := taskFunc()
durTotal := time.Since(queuedAt)
durTask := time.Since(startedAt)
durQueue := startedAt.Sub(queuedAt)
p.logger.With("task", durTask, "queue", durQueue, "total", durTotal).Infof("Completed task")
done <- result
}
select {
case p.ch <- fn:
return <-done
case <-ctx.Done():
return ctx.Err()
default:
return errors.New("worker queue full")
}
}

View File

@ -1,53 +0,0 @@
package media_test
import (
"context"
"sync"
"testing"
"time"
"git.netflux.io/rob/clipper/media"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
func TestWorkerPool(t *testing.T) {
ctx := context.Background()
p := media.NewWorkerPool(2, 1, zap.NewNop().Sugar())
p.Run()
const taskCount = 4
const dur = time.Millisecond * 100
ch := make(chan error, taskCount)
var wg sync.WaitGroup
wg.Add(taskCount)
for i := 0; i < taskCount; i++ {
go func() {
defer wg.Done()
ch <- p.WaitForTask(ctx, func() error { time.Sleep(dur); return nil })
}()
}
wg.Wait()
close(ch)
var okCount, errCount int
for err := range ch {
if err == nil {
okCount++
} else {
errCount++
require.EqualError(t, err, "worker queue full")
}
}
// There can either be 1 or 2 failures, depending on whether a worker picks
// up one job before the last one is added to the queue.
ok := (okCount == 2 && errCount == 2) || (okCount == 3 && errCount == 1)
assert.True(t, ok)
}

View File

@ -1,218 +0,0 @@
package server
//go:generate mockery --recursive --name MediaSetService --output ../generated/mocks
import (
"context"
"errors"
"io"
pbmediaset "git.netflux.io/rob/clipper/generated/pb/media_set"
"git.netflux.io/rob/clipper/media"
"github.com/google/uuid"
"go.uber.org/zap"
"google.golang.org/protobuf/types/known/durationpb"
)
// mediaSetServiceController implements gRPC controller for MediaSetService
type mediaSetServiceController struct {
pbmediaset.UnimplementedMediaSetServiceServer
mediaSetService MediaSetService
logger *zap.SugaredLogger
}
// Get returns a pbMediaSet.MediaSet
func (c *mediaSetServiceController) Get(ctx context.Context, request *pbmediaset.GetRequest) (*pbmediaset.MediaSet, error) {
mediaSet, err := c.mediaSetService.Get(ctx, request.GetYoutubeId())
if err != nil {
return nil, newResponseError(err)
}
result := pbmediaset.MediaSet{
Id: mediaSet.ID.String(),
Title: mediaSet.Title,
Description: mediaSet.Description,
Author: mediaSet.Author,
YoutubeId: mediaSet.YoutubeID,
AudioChannels: int32(mediaSet.Audio.Channels),
AudioFrames: mediaSet.Audio.Frames,
AudioApproxFrames: mediaSet.Audio.ApproxFrames,
AudioSampleRate: int32(mediaSet.Audio.SampleRate),
AudioYoutubeItag: int32(mediaSet.Audio.YoutubeItag),
AudioMimeType: mediaSet.Audio.MimeType,
VideoDuration: durationpb.New(mediaSet.Video.Duration),
VideoYoutubeItag: int32(mediaSet.Video.YoutubeItag),
VideoMimeType: mediaSet.Video.MimeType,
}
return &result, nil
}
// GetPeaks returns a stream of GetPeaksProgress relating to the entire audio
// part of the MediaSet.
func (c *mediaSetServiceController) GetPeaks(request *pbmediaset.GetPeaksRequest, stream pbmediaset.MediaSetService_GetPeaksServer) error {
// TODO: reduce timeout when fetching from S3
ctx, cancel := context.WithTimeout(stream.Context(), getPeaksTimeout)
defer cancel()
id, err := uuid.Parse(request.GetId())
if err != nil {
return newResponseError(err)
}
reader, err := c.mediaSetService.GetPeaks(ctx, id, int(request.GetNumBins()))
if err != nil {
return newResponseError(err)
}
for {
progress, err := reader.Next()
if err != nil && err != io.EOF {
return newResponseError(err)
}
peaks := make([]int32, len(progress.Peaks))
for i, p := range progress.Peaks {
peaks[i] = int32(p)
}
progressPb := pbmediaset.GetPeaksProgress{
PercentComplete: progress.PercentComplete,
Peaks: peaks,
Url: progress.URL,
AudioFrames: progress.AudioFrames,
}
stream.Send(&progressPb)
if err == io.EOF {
break
}
}
return nil
}
// GetPeaksForSegment returns a set of peaks for a segment of an audio part of
// a MediaSet.
func (c *mediaSetServiceController) GetPeaksForSegment(ctx context.Context, request *pbmediaset.GetPeaksForSegmentRequest) (*pbmediaset.GetPeaksForSegmentResponse, error) {
ctx, cancel := context.WithTimeout(ctx, getPeaksForSegmentTimeout)
defer cancel()
id, err := uuid.Parse(request.GetId())
if err != nil {
return nil, newResponseError(err)
}
peaks, err := c.mediaSetService.GetPeaksForSegment(ctx, id, request.StartFrame, request.EndFrame, int(request.GetNumBins()))
if err != nil {
return nil, newResponseError(err)
}
peaks32 := make([]int32, len(peaks))
for i, p := range peaks {
peaks32[i] = int32(p)
}
return &pbmediaset.GetPeaksForSegmentResponse{Peaks: peaks32}, nil
}
func (c *mediaSetServiceController) GetAudioSegment(request *pbmediaset.GetAudioSegmentRequest, outStream pbmediaset.MediaSetService_GetAudioSegmentServer) error {
ctx, cancel := context.WithTimeout(outStream.Context(), getPeaksForSegmentTimeout)
defer cancel()
id, err := uuid.Parse(request.GetId())
if err != nil {
return newResponseError(err)
}
var format media.AudioFormat
switch request.Format {
case pbmediaset.AudioFormat_MP3:
format = media.AudioFormatMP3
case pbmediaset.AudioFormat_WAV:
format = media.AudioFormatWAV
default:
return newResponseError(errors.New("unknown format"))
}
stream, err := c.mediaSetService.GetAudioSegment(ctx, id, request.StartFrame, request.EndFrame, format)
if err != nil {
return newResponseError(err)
}
for {
progress, err := stream.Next(ctx)
if err != nil && err != io.EOF {
return newResponseError(err)
}
progressPb := pbmediaset.GetAudioSegmentProgress{
PercentComplete: progress.PercentComplete,
AudioData: progress.Data,
}
outStream.Send(&progressPb)
if err == io.EOF {
break
}
}
return nil
}
func (c *mediaSetServiceController) GetVideo(request *pbmediaset.GetVideoRequest, stream pbmediaset.MediaSetService_GetVideoServer) error {
// TODO: reduce timeout when already fetched from Youtube
ctx, cancel := context.WithTimeout(stream.Context(), getVideoTimeout)
defer cancel()
id, err := uuid.Parse(request.GetId())
if err != nil {
return newResponseError(err)
}
reader, err := c.mediaSetService.GetVideo(ctx, id)
if err != nil {
return newResponseError(err)
}
for {
progress, err := reader.Next()
if err != nil && err != io.EOF {
return newResponseError(err)
}
progressPb := pbmediaset.GetVideoProgress{
PercentComplete: progress.PercentComplete,
Url: progress.URL,
}
stream.Send(&progressPb)
if err == io.EOF {
break
}
}
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
}

View File

@ -1,180 +0,0 @@
package server
import (
"context"
"io"
"net/http"
"path/filepath"
"git.netflux.io/rob/clipper/config"
"git.netflux.io/rob/clipper/filestore"
"git.netflux.io/rob/clipper/media"
"github.com/google/uuid"
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
"github.com/gorilla/schema"
"github.com/improbable-eng/grpc-web/go/grpcweb"
"go.uber.org/zap"
)
type httpHandler struct {
*mux.Router
grpcHandler *grpcweb.WrappedGrpcServer
mediaSetService MediaSetService
logger *zap.SugaredLogger
}
func newHTTPHandler(grpcHandler *grpcweb.WrappedGrpcServer, mediaSetService MediaSetService, c config.Config, logger *zap.SugaredLogger) *httpHandler {
fileStoreHandler := http.NotFoundHandler()
if c.FileStoreHTTPRoot != "" {
logger.With("root", c.FileStoreHTTPRoot, "baseURL", c.FileStoreHTTPBaseURL.String()).Info("Configured to serve file store over HTTP")
fileStoreHandler = http.FileServer(&indexedFileSystem{http.Dir(c.FileStoreHTTPRoot)})
}
assetsHandler := http.NotFoundHandler()
if c.AssetsHTTPRoot != "" {
logger.With("root", c.AssetsHTTPRoot).Info("Configured to serve assets over HTTP")
assetsHandler = http.FileServer(&indexedFileSystem{http.Dir(c.AssetsHTTPRoot)})
}
// If FileSystemStore AND assets serving are both enabled,
// FileStoreHTTPBaseURL *must* be set to a value other than "/" to avoid
// clobbering the assets routes.
h := &httpHandler{
Router: mux.NewRouter(),
grpcHandler: grpcHandler,
mediaSetService: mediaSetService,
logger: logger,
}
h.
Methods("POST").
Path("/api/media_sets/{id}/clip").
Handler(http.HandlerFunc(h.handleClip))
if c.FileStore == config.FileSystemStore {
h.
Methods("GET").
PathPrefix(c.FileStoreHTTPBaseURL.Path).
Handler(filestore.NewFileSystemStoreHTTPMiddleware(c.FileStoreHTTPBaseURL, fileStoreHandler))
}
h.
Methods("GET").
Handler(assetsHandler)
h.Use(handlers.CORS(handlers.AllowedOrigins(c.CORSAllowedOrigins)))
return h
}
func (h *httpHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if !h.grpcHandler.IsGrpcWebRequest(r) && !h.grpcHandler.IsAcceptableGrpcCorsRequest(r) {
h.Router.ServeHTTP(w, r)
return
}
h.grpcHandler.ServeHTTP(w, r)
}
func (h *httpHandler) handleClip(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(context.Background(), getPeaksForSegmentTimeout)
defer cancel()
if err := r.ParseForm(); err != nil {
h.logger.With("err", err).Info("error parsing form")
w.WriteHeader(http.StatusBadRequest)
return
}
var params struct {
StartFrame int64 `schema:"start_frame,required"`
EndFrame int64 `schema:"end_frame,required"`
Format string `schema:"format,required"`
}
decoder := schema.NewDecoder()
if err := decoder.Decode(&params, r.PostForm); err != nil {
h.logger.With("err", err).Info("error decoding form")
w.WriteHeader(http.StatusBadRequest)
return
}
vars := mux.Vars(r)
id, err := uuid.Parse(vars["id"])
if err != nil {
h.logger.With("err", err).Info("error parsing ID")
w.WriteHeader(http.StatusNotFound)
return
}
var format media.AudioFormat
switch params.Format {
case "mp3":
format = media.AudioFormatMP3
case "wav":
format = media.AudioFormatWAV
default:
h.logger.Info("bad format")
w.WriteHeader(http.StatusBadRequest)
return
}
stream, err := h.mediaSetService.GetAudioSegment(ctx, id, params.StartFrame, params.EndFrame, format)
if err != nil {
h.logger.With("err", err).Info("error getting audio segment")
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("content-type", "audio/"+format.String())
w.Header().Set("content-disposition", "attachment; filename=clip."+format.String())
w.WriteHeader(http.StatusOK)
var closing bool
for {
progress, err := stream.Next(ctx)
if err == io.EOF {
closing = true
} else if err != nil {
h.logger.With("err", err).Error("error reading audio segment stream")
return
}
w.Write(progress.Data)
if closing {
break
}
}
}
// indexedFileSystem is an HTTP file system which handles index.html files if
// they exist, but does not serve directory listings.
//
// Ref: https://www.alexedwards.net/blog/disable-http-fileserver-directory-listings
type indexedFileSystem struct {
httpFS http.FileSystem
}
func (ifs *indexedFileSystem) Open(path string) (http.File, error) {
f, err := ifs.httpFS.Open(path)
if err != nil {
return nil, err
}
s, err := f.Stat()
if err != nil {
return nil, err
}
if !s.IsDir() {
return f, nil
}
index := filepath.Join(path, "index.html")
if _, err := ifs.httpFS.Open(index); err != nil {
_ = f.Close() // ignore error
return nil, err
}
return f, nil
}

View File

@ -1,254 +0,0 @@
package server
import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"git.netflux.io/rob/clipper/config"
"git.netflux.io/rob/clipper/generated/mocks"
"git.netflux.io/rob/clipper/media"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
func TestHandler(t *testing.T) {
testCases := []struct {
name string
path, body, method, contentType, origin string
config config.Config
wantStartFrame, wantEndFrame int64
wantAudioFormat media.AudioFormat
wantStatus int
wantHeaders map[string]string
wantBody string
}{
{
name: "assets disabled, file system store disabled, GET /",
path: "/",
method: http.MethodGet,
config: config.Config{FileStore: config.S3Store},
wantStatus: http.StatusNotFound,
},
{
name: "assets disabled, file system store disabled, GET /foo.js",
path: "/foo.js",
method: http.MethodGet,
config: config.Config{FileStore: config.S3Store},
wantStatus: http.StatusNotFound,
},
{
name: "assets enabled, file system store disabled, index.html exists, GET /",
path: "/",
method: http.MethodGet,
config: config.Config{FileStore: config.S3Store, AssetsHTTPRoot: "testdata/http/assets"},
wantStatus: http.StatusOK,
wantBody: "index",
},
{
name: "assets enabled, file system store disabled, index.html does not exist, GET /css/",
path: "/css/",
method: http.MethodGet,
config: config.Config{FileStore: config.S3Store, AssetsHTTPRoot: "testdata/http/assets"},
wantStatus: http.StatusNotFound,
},
{
name: "assets enabled, file system store disabled, index.html does not exist, GET /css/style.css",
path: "/css/style.css",
method: http.MethodGet,
config: config.Config{FileStore: config.S3Store, AssetsHTTPRoot: "testdata/http/assets"},
wantStatus: http.StatusOK,
wantBody: "css",
},
{
name: "assets enabled, file system store disabled, GET /foo.js",
path: "/foo.js",
method: http.MethodGet,
config: config.Config{FileStore: config.S3Store, AssetsHTTPRoot: "testdata/http/assets"},
wantStatus: http.StatusOK,
wantBody: "foo",
},
{
name: "assets enabled, file system store enabled with path prefix /store/, GET /foo.js",
path: "/foo.js",
method: http.MethodGet,
config: config.Config{FileStore: config.FileSystemStore, FileStoreHTTPBaseURL: mustParseURL(t, "/store/"), FileStoreHTTPRoot: "testdata/http/filestore", AssetsHTTPRoot: "testdata/http/assets"},
wantStatus: http.StatusOK,
wantBody: "foo",
},
{
name: "assets enabled, file system store enabled with path prefix /store/, GET /store/bar.mp4",
path: "/store/bar.mp4",
method: http.MethodGet,
config: config.Config{FileStore: config.FileSystemStore, FileStoreHTTPBaseURL: mustParseURL(t, "/store/"), FileStoreHTTPRoot: "testdata/http/filestore", AssetsHTTPRoot: "testdata/http/assets"},
wantStatus: http.StatusOK,
wantBody: "bar",
},
{
name: "assets enabled, file system store enabled with path prefix /store/, GET /store/",
path: "/store/",
method: http.MethodGet,
config: config.Config{FileStore: config.FileSystemStore, FileStoreHTTPBaseURL: mustParseURL(t, "/store/"), FileStoreHTTPRoot: "testdata/http/filestore", AssetsHTTPRoot: "testdata/http/assets"},
wantStatus: http.StatusNotFound,
},
{
name: "assets enabled, file system store enabled with path prefix /store/, GET /",
path: "/",
method: http.MethodGet,
config: config.Config{FileStore: config.FileSystemStore, FileStoreHTTPBaseURL: mustParseURL(t, "/store/"), FileStoreHTTPRoot: "testdata/http/filestore", AssetsHTTPRoot: "testdata/http/assets"},
wantStatus: http.StatusOK,
wantBody: "index",
},
{
name: "assets enabled, file system store enabled with path prefix /, GET / clobbers the assets routes",
path: "/",
method: http.MethodGet,
config: config.Config{FileStore: config.FileSystemStore, FileStoreHTTPBaseURL: mustParseURL(t, "/"), FileStoreHTTPRoot: "testdata/http/filestore", AssetsHTTPRoot: "testdata/http/assets"},
wantStatus: http.StatusNotFound,
},
{
name: "assets enabled, configured with custom Allowed-Origins header, origin does not match",
path: "/css/style.css",
method: http.MethodGet,
origin: "https://localhost:3000",
config: config.Config{
FileStore: config.S3Store,
AssetsHTTPRoot: "testdata/http/assets",
CORSAllowedOrigins: []string{"https://www.example.com"},
},
wantHeaders: map[string]string{"access-control-allow-origin": ""},
wantStatus: http.StatusOK,
},
{
name: "assets enabled, configured with custom Allowed-Origins header, origin does match",
path: "/css/style.css",
method: http.MethodGet,
origin: "https://www.example.com",
config: config.Config{
FileStore: config.S3Store,
AssetsHTTPRoot: "testdata/http/assets",
CORSAllowedOrigins: []string{"https://www.example.com"},
},
wantHeaders: map[string]string{"access-control-allow-origin": "https://www.example.com"},
wantStatus: http.StatusOK,
},
{
name: "POST /api/media_sets/:id/clip, NOK, no body",
path: "/api/media_sets/05951a4d-584e-4056-9ae7-08b9e4cd355d/clip",
contentType: "application/x-www-form-urlencoded",
method: http.MethodPost,
config: config.Config{FileStore: config.FileSystemStore, FileStoreHTTPBaseURL: mustParseURL(t, "/store/")},
wantStatus: http.StatusBadRequest,
},
{
name: "POST /api/media_sets/:id/clip, NOK, missing params",
path: "/api/media_sets/05951a4d-584e-4056-9ae7-08b9e4cd355d/clip",
body: "start_frame=0&end_frame=1024",
contentType: "application/x-www-form-urlencoded",
method: http.MethodPost,
config: config.Config{FileStore: config.FileSystemStore, FileStoreHTTPBaseURL: mustParseURL(t, "/store/")},
wantStatus: http.StatusBadRequest,
},
{
name: "POST /api/media_sets/:id/clip, NOK, invalid UUID",
path: "/api/media_sets/123/clip",
body: "start_frame=0&end_frame=1024&format=mp3",
contentType: "application/x-www-form-urlencoded",
method: http.MethodPost,
config: config.Config{FileStore: config.FileSystemStore, FileStoreHTTPBaseURL: mustParseURL(t, "/store/")},
wantStatus: http.StatusNotFound,
},
{
name: "POST /api/media_sets/:id/clip, MP3, OK",
path: "/api/media_sets/05951a4d-584e-4056-9ae7-08b9e4cd355d/clip",
body: "start_frame=0&end_frame=1024&format=mp3",
contentType: "application/x-www-form-urlencoded",
method: http.MethodPost,
config: config.Config{FileStore: config.FileSystemStore, FileStoreHTTPBaseURL: mustParseURL(t, "/store/")},
wantStartFrame: 0,
wantEndFrame: 1024,
wantAudioFormat: media.AudioFormatMP3,
wantHeaders: map[string]string{
"content-type": "audio/mp3",
"content-disposition": "attachment; filename=clip.mp3",
},
wantStatus: http.StatusOK,
wantBody: "an audio file",
},
{
name: "POST /api/media_sets/:id/clip, WAV, OK",
path: "/api/media_sets/05951a4d-584e-4056-9ae7-08b9e4cd355d/clip",
body: "start_frame=4096&end_frame=8192&format=wav",
contentType: "application/x-www-form-urlencoded",
method: http.MethodPost,
config: config.Config{FileStore: config.FileSystemStore, FileStoreHTTPBaseURL: mustParseURL(t, "/store/")},
wantStartFrame: 4096,
wantEndFrame: 8192,
wantAudioFormat: media.AudioFormatWAV,
wantHeaders: map[string]string{
"content-type": "audio/wav",
"content-disposition": "attachment; filename=clip.wav",
},
wantStatus: http.StatusOK,
wantBody: "an audio file",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var stream mocks.AudioSegmentStream
stream.On("Next", mock.Anything).Return(media.AudioSegmentProgress{PercentComplete: 60, Data: []byte("an aud")}, nil).Once()
stream.On("Next", mock.Anything).Return(media.AudioSegmentProgress{PercentComplete: 80, Data: []byte("io file")}, nil).Once()
stream.On("Next", mock.Anything).Return(media.AudioSegmentProgress{PercentComplete: 100}, io.EOF).Once()
var mediaSetService mocks.MediaSetService
mediaSetService.
On("GetAudioSegment", mock.Anything, uuid.MustParse("05951a4d-584e-4056-9ae7-08b9e4cd355d"), tc.wantStartFrame, tc.wantEndFrame, tc.wantAudioFormat).
Return(&stream, nil)
if tc.wantStartFrame != 0 {
defer stream.AssertExpectations(t)
defer mediaSetService.AssertExpectations(t)
}
handler := newHTTPHandler(nil, &mediaSetService, tc.config, zap.NewNop().Sugar())
var body io.Reader
if tc.body != "" {
body = strings.NewReader(tc.body)
}
req := httptest.NewRequest(tc.method, tc.path, body)
if tc.origin != "" {
req.Header.Add("origin", tc.origin)
}
if tc.contentType != "" {
req.Header.Add("content-type", tc.contentType)
}
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
resp := w.Result()
assert.Equal(t, tc.wantStatus, resp.StatusCode)
if tc.wantBody != "" {
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Equal(t, tc.wantBody, string(body))
}
for k, v := range tc.wantHeaders {
assert.Equal(t, v, resp.Header.Get(k))
}
})
}
}
func mustParseURL(t *testing.T, u string) *url.URL {
pu, err := url.Parse(u)
require.NoError(t, err)
return pu
}

View File

@ -2,7 +2,9 @@ package server
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os/exec"
"time"
@ -19,6 +21,7 @@ import (
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/durationpb"
)
const (
@ -38,15 +41,6 @@ const (
getVideoTimeout = time.Minute * 5
)
type MediaSetService interface {
Get(context.Context, string) (*media.MediaSet, error)
GetAudioSegment(context.Context, uuid.UUID, int64, int64, media.AudioFormat) (media.AudioSegmentStream, error)
GetPeaks(context.Context, uuid.UUID, int) (media.GetPeaksProgressReader, error)
GetPeaksForSegment(context.Context, uuid.UUID, int64, int64, int) ([]int16, error)
GetVideo(context.Context, uuid.UUID) (media.GetVideoProgressReader, error)
GetVideoThumbnail(context.Context, uuid.UUID) (media.VideoThumbnail, error)
}
type ResponseError struct {
err error
s string
@ -74,55 +68,260 @@ type Options struct {
Store media.Store
YoutubeClient media.YoutubeClient
FileStore media.FileStore
WorkerPool *media.WorkerPool
Logger *zap.Logger
}
func Start(options Options) error {
conf := options.Config
// mediaSetServiceController implements gRPC controller for MediaSetService
type mediaSetServiceController struct {
pbmediaset.UnimplementedMediaSetServiceServer
mediaSetService := media.NewMediaSetService(
mediaSetService *media.MediaSetService
logger *zap.SugaredLogger
}
// Get returns a pbMediaSet.MediaSet
func (c *mediaSetServiceController) Get(ctx context.Context, request *pbmediaset.GetRequest) (*pbmediaset.MediaSet, error) {
mediaSet, err := c.mediaSetService.Get(ctx, request.GetYoutubeId())
if err != nil {
return nil, newResponseError(err)
}
result := pbmediaset.MediaSet{
Id: mediaSet.ID.String(),
YoutubeId: mediaSet.YoutubeID,
AudioChannels: int32(mediaSet.Audio.Channels),
AudioFrames: mediaSet.Audio.Frames,
AudioApproxFrames: mediaSet.Audio.ApproxFrames,
AudioSampleRate: int32(mediaSet.Audio.SampleRate),
AudioYoutubeItag: int32(mediaSet.Audio.YoutubeItag),
AudioMimeType: mediaSet.Audio.MimeType,
VideoDuration: durationpb.New(mediaSet.Video.Duration),
VideoYoutubeItag: int32(mediaSet.Video.YoutubeItag),
VideoMimeType: mediaSet.Video.MimeType,
}
return &result, nil
}
// GetPeaks returns a stream of GetPeaksProgress relating to the entire audio
// part of the MediaSet.
func (c *mediaSetServiceController) GetPeaks(request *pbmediaset.GetPeaksRequest, stream pbmediaset.MediaSetService_GetPeaksServer) error {
// TODO: reduce timeout when fetching from S3
ctx, cancel := context.WithTimeout(context.Background(), getPeaksTimeout)
defer cancel()
id, err := uuid.Parse(request.GetId())
if err != nil {
return newResponseError(err)
}
reader, err := c.mediaSetService.GetPeaks(ctx, id, int(request.GetNumBins()))
if err != nil {
return newResponseError(err)
}
for {
progress, err := reader.Next()
if err != nil && err != io.EOF {
return newResponseError(err)
}
peaks := make([]int32, len(progress.Peaks))
for i, p := range progress.Peaks {
peaks[i] = int32(p)
}
progressPb := pbmediaset.GetPeaksProgress{
PercentComplete: progress.PercentComplete,
Url: progress.URL,
Peaks: peaks,
}
stream.Send(&progressPb)
if err == io.EOF {
break
}
}
return nil
}
// GetPeaksForSegment returns a set of peaks for a segment of an audio part of
// a MediaSet.
func (c *mediaSetServiceController) GetPeaksForSegment(ctx context.Context, request *pbmediaset.GetPeaksForSegmentRequest) (*pbmediaset.GetPeaksForSegmentResponse, error) {
ctx, cancel := context.WithTimeout(ctx, getPeaksForSegmentTimeout)
defer cancel()
id, err := uuid.Parse(request.GetId())
if err != nil {
return nil, newResponseError(err)
}
peaks, err := c.mediaSetService.GetPeaksForSegment(ctx, id, request.StartFrame, request.EndFrame, int(request.GetNumBins()))
if err != nil {
return nil, newResponseError(err)
}
peaks32 := make([]int32, len(peaks))
for i, p := range peaks {
peaks32[i] = int32(p)
}
return &pbmediaset.GetPeaksForSegmentResponse{Peaks: peaks32}, nil
}
func (c *mediaSetServiceController) GetAudioSegment(request *pbmediaset.GetAudioSegmentRequest, outStream pbmediaset.MediaSetService_GetAudioSegmentServer) error {
ctx, cancel := context.WithTimeout(context.Background(), getPeaksForSegmentTimeout)
defer cancel()
id, err := uuid.Parse(request.GetId())
if err != nil {
return newResponseError(err)
}
var format media.AudioFormat
switch request.Format {
case pbmediaset.AudioFormat_MP3:
format = media.AudioFormatMP3
case pbmediaset.AudioFormat_WAV:
format = media.AudioFormatWAV
default:
return newResponseError(errors.New("unknown format"))
}
stream, err := c.mediaSetService.GetAudioSegment(ctx, id, request.StartFrame, request.EndFrame, format)
if err != nil {
return newResponseError(err)
}
for {
progress, err := stream.Next(ctx)
if err != nil && err != io.EOF {
return newResponseError(err)
}
progressPb := pbmediaset.GetAudioSegmentProgress{
PercentComplete: progress.PercentComplete,
AudioData: progress.Data,
}
outStream.Send(&progressPb)
if err == io.EOF {
break
}
}
return nil
}
func (c *mediaSetServiceController) GetVideo(request *pbmediaset.GetVideoRequest, stream pbmediaset.MediaSetService_GetVideoServer) error {
// TODO: reduce timeout when already fetched from Youtube
ctx, cancel := context.WithTimeout(context.Background(), getVideoTimeout)
defer cancel()
id, err := uuid.Parse(request.GetId())
if err != nil {
return newResponseError(err)
}
reader, err := c.mediaSetService.GetVideo(ctx, id)
if err != nil {
return newResponseError(err)
}
for {
progress, err := reader.Next()
if err != nil && err != io.EOF {
return newResponseError(err)
}
progressPb := pbmediaset.GetVideoProgress{
PercentComplete: progress.PercentComplete,
Url: progress.URL,
}
stream.Send(&progressPb)
if err == io.EOF {
break
}
}
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 {
fetchMediaSetService := media.NewMediaSetService(
options.Store,
options.YoutubeClient,
options.FileStore,
exec.CommandContext,
options.WorkerPool,
conf,
options.Config,
options.Logger.Sugar().Named("mediaSetService"),
)
grpcServer, err := buildGRPCServer(conf, options.Logger)
grpcServer, err := buildGRPCServer(options.Config, options.Logger)
if err != nil {
return fmt.Errorf("error building server: %v", err)
}
mediaSetController := &mediaSetServiceController{mediaSetService: mediaSetService, logger: options.Logger.Sugar().Named("controller")}
mediaSetController := &mediaSetServiceController{mediaSetService: fetchMediaSetService, logger: options.Logger.Sugar().Named("controller")}
pbmediaset.RegisterMediaSetServiceServer(grpcServer, mediaSetController)
// TODO: convert CORSAllowedOrigins to a map[string]struct{}
originChecker := func(origin string) bool {
for _, s := range conf.CORSAllowedOrigins {
if origin == s {
return true
}
}
return false
}
grpcHandler := grpcweb.WrapServer(grpcServer, grpcweb.WithOriginFunc(originChecker))
httpHandler := newHTTPHandler(grpcHandler, mediaSetService, conf, options.Logger.Sugar().Named("httpHandler"))
httpServer := http.Server{
Addr: conf.BindAddr,
ReadTimeout: options.Timeout,
WriteTimeout: options.Timeout,
Handler: httpHandler,
}
// TODO: configure CORS
grpcWebServer := grpcweb.WrapServer(grpcServer, grpcweb.WithOriginFunc(func(string) bool { return true }))
log := options.Logger.Sugar()
fileHandler := http.NotFoundHandler()
// Enabling the file system store disables serving assets over HTTP.
// TODO: fix this.
if options.Config.AssetsHTTPRoot != "" {
log.With("root", options.Config.AssetsHTTPRoot).Info("Configured to serve assets over HTTP")
fileHandler = http.FileServer(http.Dir(options.Config.AssetsHTTPRoot))
}
if options.Config.FileStoreHTTPRoot != "" {
log.With("root", options.Config.FileStoreHTTPRoot).Info("Configured to serve file store over HTTP")
fileHandler = http.FileServer(http.Dir(options.Config.FileStoreHTTPRoot))
}
httpServer := http.Server{
Addr: options.Config.BindAddr,
ReadTimeout: options.Timeout,
WriteTimeout: options.Timeout,
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !grpcWebServer.IsGrpcWebRequest(r) && !grpcWebServer.IsAcceptableGrpcCorsRequest(r) {
fileHandler.ServeHTTP(w, r)
return
}
grpcWebServer.ServeHTTP(w, r)
}),
}
log.Infof("Listening at %s", options.Config.BindAddr)
if conf.TLSCertFile != "" && conf.TLSKeyFile != "" {
return httpServer.ListenAndServeTLS(conf.TLSCertFile, conf.TLSKeyFile)
if options.Config.TLSCertFile != "" && options.Config.TLSKeyFile != "" {
return httpServer.ListenAndServeTLS(options.Config.TLSCertFile, options.Config.TLSKeyFile)
}
return httpServer.ListenAndServe()

View File

@ -1 +0,0 @@
css

View File

@ -1 +0,0 @@
foo

View File

@ -1 +0,0 @@
index

View File

@ -1 +0,0 @@
bar

View File

@ -1,3 +0,0 @@
ALTER TABLE media_sets DROP COLUMN title;
ALTER TABLE media_sets DROP COLUMN description;
ALTER TABLE media_sets DROP COLUMN author;

View File

@ -1,5 +0,0 @@
ALTER TABLE media_sets ADD COLUMN title CHARACTER VARYING(256) NOT NULL DEFAULT '';
ALTER TABLE media_sets ADD COLUMN description text NOT NULL DEFAULT '';
ALTER TABLE media_sets ADD COLUMN author CHARACTER VARYING(256) NOT NULL DEFAULT '';
ALTER TABLE media_sets ADD CONSTRAINT check_description_length CHECK (LENGTH(description) <= 4096);

View File

@ -5,8 +5,8 @@ SELECT * FROM media_sets WHERE id = $1;
SELECT * FROM media_sets WHERE youtube_id = $1;
-- name: CreateMediaSet :one
INSERT INTO media_sets (youtube_id, title, description, author, audio_youtube_itag, audio_channels, audio_frames_approx, audio_sample_rate, audio_content_length, audio_encoded_mime_type, video_youtube_itag, video_content_length, video_mime_type, video_duration_nanos, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, NOW(), NOW())
INSERT INTO media_sets (youtube_id, audio_youtube_itag, audio_channels, audio_frames_approx, audio_sample_rate, audio_content_length, audio_encoded_mime_type, video_youtube_itag, video_content_length, video_mime_type, video_duration_nanos, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), NOW())
RETURNING *;
-- name: SetRawAudioUploaded :one

View File

@ -16,11 +16,6 @@ You will also see any lint errors in the console.
### `yarn test`
Launches the test runner **not** in interactive watch mode - this is crucial for the sake of running tests on CI, since a non-terminating process will cause CI to hang and then fail.
### `yarn test:watch`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.

View File

@ -3,7 +3,6 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@heroicons/react": "^1.0.5",
"@improbable-eng/grpc-web": "^0.14.1",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
@ -15,15 +14,15 @@
"google-protobuf": "^3.19.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-scripts": "5.0.0",
"react-router-dom": "^6.2.1",
"react-scripts": "4.0.3",
"typescript": "^4.1.2",
"web-vitals": "^1.0.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --watchAll=false",
"test:watch": "react-scripts test",
"test": "echo 'no tests yet' # react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
@ -48,14 +47,11 @@
"@types/wicg-file-system-access": "^2020.9.4",
"@typescript-eslint/eslint-plugin": "^4.31.0",
"@typescript-eslint/parser": "^4.31.0",
"autoprefixer": "^10.4.2",
"eslint": "^7.32.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-react": "^7.25.1",
"postcss": "^8.4.5",
"prettier": "2.4.0",
"rxjs": "^7.4.0",
"tailwindcss": "^3.0.12",
"ts-proto": "^1.85.0"
}
}

View File

@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -1,6 +1,6 @@
{
"short_name": "Clipper",
"name": "Clipper",
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
@ -20,7 +20,6 @@
],
"start_url": ".",
"display": "standalone",
"orientation": "landscape",
"theme_color": "#000000",
"background_color": "#ffffff"
}

7
frontend/src/App.css Normal file
View File

@ -0,0 +1,7 @@
body {
background-color: #333;
}
.App {
text-align: center;
}

View File

@ -0,0 +1,9 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View File

@ -1,410 +1,19 @@
import {
GrpcWebImpl,
MediaSetServiceClientImpl,
GetVideoProgress,
GetPeaksProgress,
} from './generated/media_set';
import { BrowserRouter, Route, Routes } from "react-router-dom";
import HomePage from "./components/HomePage";
import VideoPage from "./components/VideoPage";
import { GrpcWebImpl } from "./generated/media_set";
import "./App.css";
import { useEffect, useCallback, useReducer } from 'react';
import { State, stateReducer, zoomFactor, PlayState } from './AppState';
import { AudioFormat } from './generated/media_set';
import { WaveformCanvas } from './WaveformCanvas';
import { HudCanvas } from './HudCanvas';
import { Player } from './Player';
import {
CanvasWidth,
CanvasHeight,
EmptySelectionAction,
} from './HudCanvasState';
import { ControlBar } from './ControlBar';
import { SeekBar } from './SeekBar';
import { firstValueFrom, from, Observable } from 'rxjs';
import { first, map, bufferCount } from 'rxjs/operators';
import { canZoomViewportIn, canZoomViewportOut } from './helpers/zoom';
import toHHMMSS from './helpers/toHHMMSS';
import framesToDuration from './helpers/framesToDuration';
import frameToWaveformCanvasX from './helpers/frameToWaveformCanvasX';
import { ClockIcon, ExternalLinkIcon } from '@heroicons/react/solid';
// ported from backend, where should they live?
const thumbnailWidth = 177; // height 100
const apiURL = process.env.REACT_APP_API_URL || 'http://localhost:8888';
// Frames represents a range of audio frames.
export interface Frames {
start: number;
end: number;
}
export interface VideoPosition {
currentTime: number;
percent: number;
}
const initialState: State = {
selection: { start: 0, end: 0 },
viewport: { start: 0, end: 0 },
overviewPeaks: from([]),
waveformPeaks: from([]),
selectionCanvas: { x1: 0, x2: 0 },
viewportCanvas: { x1: 0, x2: 0 },
position: { currentTime: 0, frame: 0, percent: 0 },
audioSrc: '',
videoSrc: '',
currentTime: 0,
playState: PlayState.Paused,
};
const apiURL = process.env.REACT_APP_API_URL || "http://localhost:8888";
function App(): JSX.Element {
const [state, dispatch] = useReducer(stateReducer, { ...initialState });
const {
mediaSet,
waveformPeaks,
overviewPeaks,
selection,
selectionCanvas,
viewport,
viewportCanvas,
position,
playState,
} = state;
// effects
// TODO: error handling
const videoID = new URLSearchParams(window.location.search).get('video_id');
if (videoID == null) {
return <></>;
}
// fetch mediaset on page load:
useEffect(() => {
(async function () {
const rpc = newRPC();
const service = new MediaSetServiceClientImpl(rpc);
const mediaSet = await service.Get({ youtubeId: videoID });
console.log('got media set:', mediaSet);
dispatch({ type: 'mediasetloaded', mediaSet: mediaSet });
// fetch audio asynchronously
console.log('fetching audio...');
const audioProgressStream = service.GetPeaks({
id: mediaSet.id,
numBins: CanvasWidth,
});
const peaks = audioProgressStream.pipe(map((progress) => progress.peaks));
dispatch({ type: 'overviewpeaksloaded', peaks: peaks });
const audioPipe = audioProgressStream.pipe(
first((progress: GetPeaksProgress) => progress.url != '')
);
const fetchAudioTask = firstValueFrom(audioPipe);
// fetch video asynchronously
console.log('fetching video...');
const videoProgressStream = service.GetVideo({ id: mediaSet.id });
const videoPipe = videoProgressStream.pipe(
first((progress: GetVideoProgress) => progress.url != '')
);
const fetchVideoTask = firstValueFrom(videoPipe);
// wait for both audio, then video.
const audioProgress = await fetchAudioTask;
dispatch({
type: 'audiosourceloaded',
src: audioProgress.url,
numFrames: audioProgress.audioFrames,
});
const videoProgress = await fetchVideoTask;
dispatch({ type: 'videosourceloaded', src: videoProgress.url });
})();
}, []);
// load waveform peaks on MediaSet change
useEffect(() => {
(async function () {
const { mediaSet, viewport } = state;
if (mediaSet == null) {
return;
}
if (viewport.start >= viewport.end) {
return;
}
const service = new MediaSetServiceClientImpl(newRPC());
const segment = await service.GetPeaksForSegment({
id: mediaSet.id,
numBins: CanvasWidth,
startFrame: viewport.start,
endFrame: viewport.end,
});
console.log('got segment', segment);
const peaks: Observable<number[]> = from(segment.peaks).pipe(
bufferCount(mediaSet.audioChannels)
);
dispatch({ type: 'waveformpeaksloaded', peaks: peaks });
})();
}, [viewport, mediaSet]);
// bind to keypress handler.
useEffect(() => {
document.addEventListener('keypress', handleKeyPress);
return () => document.removeEventListener('keypress', handleKeyPress);
});
useEffect(() => {
console.debug('viewport updated', viewport);
}, [viewport]);
// handlers
const togglePlay = () => (playState == PlayState.Paused ? play() : pause());
const play = () => dispatch({ type: 'play' });
const pause = () => dispatch({ type: 'pause' });
const handleKeyPress = (evt: KeyboardEvent) => {
if (evt.code != 'Space') {
return;
}
togglePlay();
};
const handleClip = () => {
if (!window.showSaveFilePicker) {
downloadClipHTTP();
return;
}
downloadClipFileSystemAccessAPI();
};
const downloadClipHTTP = () => {
(async function () {
if (mediaSet == null) {
return;
}
console.debug('clip http', selection);
const form = document.createElement('form');
form.method = 'POST';
form.action = `${apiURL}/api/media_sets/${mediaSet.id}/clip`;
const startFrameInput = document.createElement('input');
startFrameInput.type = 'hidden';
startFrameInput.name = 'start_frame';
startFrameInput.value = String(selection.start);
form.appendChild(startFrameInput);
const endFrameInput = document.createElement('input');
endFrameInput.type = 'hidden';
endFrameInput.name = 'end_frame';
endFrameInput.value = String(selection.end);
form.appendChild(endFrameInput);
const formatInput = document.createElement('input');
formatInput.type = 'hidden';
formatInput.name = 'format';
formatInput.value = 'mp3';
form.appendChild(formatInput);
document.body.appendChild(form);
form.submit();
})();
};
const downloadClipFileSystemAccessAPI = () => {
(async function () {
if (mediaSet == null) {
return;
}
console.debug('clip grpc', selection);
const h = await window.showSaveFilePicker({ suggestedName: 'clip.mp3' });
const fileStream = await h.createWritable();
const rpc = newRPC();
const service = new MediaSetServiceClientImpl(rpc);
const stream = service.GetAudioSegment({
id: mediaSet.id,
format: AudioFormat.MP3,
startFrame: selection.start,
endFrame: selection.end,
});
await stream.forEach((p) => fileStream.write(p.audioData));
console.debug('finished writing stream');
await fileStream.close();
console.debug('closed stream');
})();
};
const durationString = useCallback((): string => {
if (!mediaSet || !mediaSet.videoDuration) {
return '';
}
const { selection } = state;
const totalDur = toHHMMSS(mediaSet.videoDuration);
if (selection.start == selection.end) {
return totalDur;
}
const clipDur = toHHMMSS(
framesToDuration(
selection.end - selection.start,
mediaSet.audioSampleRate
)
);
return `Selected ${clipDur} of ${totalDur}`;
}, [mediaSet, selection]);
// render component
const offsetPixels = Math.floor(thumbnailWidth / 2);
const marginClass = 'mx-[88px]'; // offsetPixels
if (mediaSet == null) {
// TODO: improve
return <></>;
}
return (
<>
<div className="App bg-gray-800 h-screen flex flex-col">
<header className="bg-green-900 h-16 grow-0 flex items-center mb-12 px-[88px]">
<h1 className="text-3xl font-bold">Clipper</h1>
</header>
<div className="flex flex-col grow bg-gray-800 w-full h-full mx-auto">
<div className={`flex flex-col grow ${marginClass}`}>
<div className="flex grow-0 h-8 pt-4 pb-2 items-center space-x-2 text-white">
<span className="text-gray-300">{mediaSet.author}</span>
<span>/</span>
<span>{mediaSet.title}</span>
<a
href={`https://www.youtube.com/watch?v=${mediaSet.youtubeId}`}
target="_blank"
rel="noreferrer"
title="Open in YouTube"
>
<ExternalLinkIcon className="h-6 w-6 text-gray-500 hover:text-gray-200" />
</a>
<span className="flex grow justify-end text-gray-500">
<ClockIcon className="h-5 w-5 mr-1 mt-0.5" />
{durationString()}
</span>
</div>
<ControlBar
playState={playState}
zoomInEnabled={canZoomViewportIn(viewport, selection, zoomFactor)}
zoomOutEnabled={canZoomViewportOut(
viewport,
mediaSet.audioFrames
)}
onTogglePlay={togglePlay}
onClip={handleClip}
onZoomIn={() => dispatch({ type: 'zoomin' })}
onZoomOut={() => dispatch({ type: 'zoomout' })}
downloadClipEnabled={selection.start != selection.end}
/>
<div className="w-full bg-gray-600 h-6"></div>
<div className={`relative grow-0 h-16`}>
<WaveformCanvas
peaks={overviewPeaks}
channels={mediaSet.audioChannels}
width={CanvasWidth}
height={CanvasHeight}
strokeStyle="black"
fillStyle="#003300"
alpha={1}
></WaveformCanvas>
<HudCanvas
width={CanvasWidth}
height={CanvasHeight}
emptySelectionAction={EmptySelectionAction.SelectPrevious}
styles={{
borderLineWidth: 4,
borderStrokeStyle: 'red',
positionLineWidth: 4,
positionStrokeStyle: 'red',
hoverPositionStrokeStyle: 'transparent',
}}
position={(position.percent / 100) * CanvasWidth}
selection={viewportCanvas}
onSelectionChange={(event) =>
dispatch({ type: 'viewportchanged', event })
}
/>
</div>
<div className={`relative grow`}>
<WaveformCanvas
peaks={waveformPeaks}
channels={mediaSet.audioChannels}
width={CanvasWidth}
height={CanvasHeight}
strokeStyle="green"
fillStyle="black"
alpha={1}
></WaveformCanvas>
<HudCanvas
width={CanvasWidth}
height={CanvasHeight}
emptySelectionAction={EmptySelectionAction.SelectNothing}
styles={{
borderLineWidth: 0,
borderStrokeStyle: 'transparent',
positionLineWidth: 6,
positionStrokeStyle: 'red',
hoverPositionStrokeStyle: '#666666',
}}
position={frameToWaveformCanvasX(
position.frame,
viewport,
CanvasWidth
)}
selection={selectionCanvas}
onSelectionChange={(event) =>
dispatch({
type: 'waveformselectionchanged',
event,
})
}
/>
</div>
</div>
<SeekBar
position={position.currentTime}
duration={mediaSet.audioFrames / mediaSet.audioSampleRate}
offsetPixels={offsetPixels}
onPositionChanged={(currentTime: number) => {
dispatch({ type: 'skip', currentTime });
}}
/>
<Player
mediaSet={mediaSet}
playState={playState}
audioSrc={state.audioSrc}
videoSrc={state.videoSrc}
currentTime={state.currentTime}
onPositionChanged={(currentTime) =>
dispatch({ type: 'positionchanged', currentTime: currentTime })
}
/>
</div>
</div>
</>
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/video/:videoId" element={<VideoPage />} />
</Routes>
</BrowserRouter>
);
}

View File

@ -1,392 +0,0 @@
import { MediaSet } from './generated/media_set';
import { stateReducer, State, PlayState } from './AppState';
import { from } from 'rxjs';
import { CanvasWidth, SelectionMode } from './HudCanvasState';
const initialState: State = {
selection: { start: 0, end: 0 },
viewport: { start: 0, end: 441000 },
overviewPeaks: from([]),
waveformPeaks: from([]),
selectionCanvas: { x1: 0, x2: 0 },
viewportCanvas: { x1: 0, x2: CanvasWidth },
position: { currentTime: 0, frame: 0, percent: 0 },
audioSrc: '',
videoSrc: '',
currentTime: undefined,
playState: PlayState.Paused,
};
describe('stateReducer', () => {
describe('audiosourceloaded', () => {
describe.each([
{
src: 'foo.opus',
numFrames: 22050,
wantViewport: { start: 0, end: 1100 },
wantViewportCanvas: { x1: 0, x2: 100 },
},
{
src: 'foo.opus',
numFrames: 44100000,
wantViewport: { start: 0, end: 2205000 },
wantViewportCanvas: { x1: 0, x2: 100 },
},
])(
'$numFrames frames',
({ src, numFrames, wantViewport, wantViewportCanvas }) => {
it('generates the expected state', () => {
const mediaSet = MediaSet.fromPartial({
id: '123',
audioFrames: 0,
});
const state = stateReducer(
{ ...initialState, mediaSet },
{ type: 'audiosourceloaded', src, numFrames }
);
expect(state.mediaSet!.audioFrames).toEqual(numFrames);
expect(state.audioSrc).toEqual(src);
expect(state.viewport).toEqual(wantViewport);
expect(state.viewportCanvas).toEqual(wantViewportCanvas);
});
}
);
});
describe('setviewport', () => {
describe.each([
{
audioFrames: 441000,
viewport: { start: 0, end: 44100 },
selection: { start: 0, end: 0 },
wantViewportCanvas: { x1: 0, x2: 200 },
wantSelectionCanvas: { x1: 0, x2: 0 },
},
{
audioFrames: 441000,
viewport: { start: 0, end: 441000 },
selection: { start: 0, end: 0 },
wantViewportCanvas: { x1: 0, x2: 2000 },
wantSelectionCanvas: { x1: 0, x2: 0 },
},
{
audioFrames: 441000,
viewport: { start: 0, end: 441000 },
selection: { start: 0, end: 44100 },
wantViewportCanvas: { x1: 0, x2: 2000 },
wantSelectionCanvas: { x1: 0, x2: 200 },
},
{
audioFrames: 441000,
viewport: { start: 0, end: 22050 },
selection: { start: 0, end: 44100 },
wantViewportCanvas: { x1: 0, x2: 100 },
wantSelectionCanvas: { x1: 0, x2: 2000 },
},
{
audioFrames: 441000,
viewport: { start: 44100, end: 88200 },
selection: { start: 22050, end: 66150 },
wantViewportCanvas: { x1: 200, x2: 400 },
wantSelectionCanvas: { x1: 0, x2: 1000 },
},
])(
'selection $selection.start-$selection.end, viewport: $viewport.start-$viewport.end',
({
audioFrames,
viewport,
selection,
wantViewportCanvas,
wantSelectionCanvas,
}) => {
it('generates the expected state', () => {
const mediaSet = MediaSet.fromPartial({
id: '123',
audioFrames: audioFrames,
});
const state = stateReducer(
{ ...initialState, mediaSet: mediaSet, selection: selection },
{ type: 'setviewport', viewport: viewport }
);
expect(state.viewport).toEqual(viewport);
expect(state.selection).toEqual(selection);
expect(state.viewportCanvas).toEqual(wantViewportCanvas);
expect(state.selectionCanvas).toEqual(wantSelectionCanvas);
});
}
);
});
describe('viewportchanged', () => {
describe.each([
{
audioFrames: 441000,
event: {
mode: SelectionMode.Selecting,
prevMode: SelectionMode.Selecting,
selection: { x1: 0, x2: 200 },
},
selection: { start: 0, end: 0 },
wantViewport: { start: 0, end: 441000 },
wantSelectionCanvas: { x1: 0, x2: 0 },
},
{
audioFrames: 441000,
event: {
mode: SelectionMode.Normal,
prevMode: SelectionMode.Selecting,
selection: { x1: 0, x2: 200 },
},
selection: { start: 0, end: 0 },
wantViewport: { start: 0, end: 44100 },
wantSelectionCanvas: { x1: 0, x2: 0 },
},
{
audioFrames: 441000,
event: {
mode: SelectionMode.Normal,
prevMode: SelectionMode.Selecting,
selection: { x1: 0, x2: 200 },
},
selection: { start: 0, end: 22050 },
wantViewport: { start: 0, end: 44100 },
wantSelectionCanvas: { x1: 0, x2: 1000 },
},
{
audioFrames: 441000,
event: {
mode: SelectionMode.Normal,
prevMode: SelectionMode.Selecting,
selection: { x1: 1000, x2: 1500 },
},
selection: { start: 220500, end: 264600 },
wantViewport: { start: 220500, end: 330750 },
wantSelectionCanvas: { x1: 0, x2: 800 },
},
])(
'mode $event.mode, audioFrames $audioFrames, canvas range $event.selection.x1-$event.selection.x2, selectedFrames $selection.start-$selection.end',
({
audioFrames,
event,
selection,
wantViewport,
wantSelectionCanvas,
}) => {
it('generates the expected state', () => {
const mediaSet = MediaSet.fromPartial({
id: '123',
audioFrames: audioFrames,
});
const state = stateReducer(
{
...initialState,
mediaSet: mediaSet,
viewport: { start: 0, end: audioFrames },
selection: selection,
},
{ type: 'viewportchanged', event }
);
expect(state.selection).toEqual(selection);
expect(state.viewport).toEqual(wantViewport);
expect(state.selectionCanvas).toEqual(wantSelectionCanvas);
});
}
);
});
describe('waveformselectionchanged', () => {
describe.each([
{
name: 'paused',
audioSampleRate: 44100,
event: {
mode: SelectionMode.Selecting,
prevMode: SelectionMode.Selecting,
selection: { x1: 100, x2: 200 },
},
playState: PlayState.Paused,
position: { frame: 0, currentTime: 0, percent: 0 },
viewport: { start: 0, end: 88200 },
wantSelection: { start: 4410, end: 8820 },
wantCurrentTime: undefined,
},
{
name: 'playing, viewport 100%, selection is in progress',
audioSampleRate: 44100,
event: {
mode: SelectionMode.Selecting,
prevMode: SelectionMode.Selecting,
selection: { x1: 200, x2: 220 },
},
playState: PlayState.Playing,
position: { frame: 22000, currentTime: 0.4988, percent: 4.98 },
viewport: { start: 0, end: 441000 },
wantSelection: { start: 44100, end: 48510 },
wantCurrentTime: undefined,
},
{
name: 'playing, viewport partial, selection is in progress',
audioSampleRate: 44100,
event: {
mode: SelectionMode.Selecting,
prevMode: SelectionMode.Selecting,
selection: { x1: 0, x2: 100 },
},
playState: PlayState.Playing,
position: { frame: 22000, currentTime: 0.4988, percent: 4.98 },
viewport: { start: 88200, end: 176400 },
wantSelection: { start: 88200, end: 92610 },
wantCurrentTime: undefined,
},
{
name: 'playing, selection is ending, currFrame is before selection start',
audioSampleRate: 44100,
event: {
mode: SelectionMode.Normal,
prevMode: SelectionMode.Selecting,
selection: { x1: 1100, x2: 1200 },
},
playState: PlayState.Playing,
position: { frame: 22000, currentTime: 0.4988, percent: 4.98 },
viewport: { start: 0, end: 88200 },
wantSelection: { start: 48510, end: 52920 },
wantCurrentTime: 1.1,
},
{
name: 'playing, selection is ending, currFrame is within selection',
audioSampleRate: 44100,
event: {
mode: SelectionMode.Normal,
prevMode: SelectionMode.Selecting,
selection: { x1: 1001, x2: 1200 },
},
playState: PlayState.Playing,
position: { frame: 50000, currentTime: 1.133, percent: 11.33 },
viewport: { start: 0, end: 88200 },
wantSelection: { start: 44144, end: 52920 },
wantCurrentTime: undefined,
},
{
name: 'playing, selection is ending, currFrame is after selection end',
audioSampleRate: 44100,
event: {
mode: SelectionMode.Normal,
prevMode: SelectionMode.Selecting,
selection: { x1: 1001, x2: 1200 },
},
playState: PlayState.Playing,
position: { frame: 88200, currentTime: 2.0, percent: 20.0 },
viewport: { start: 0, end: 88200 },
wantSelection: { start: 44144, end: 52920 },
wantCurrentTime: 1.000997732426304,
},
])(
'$name',
({
audioSampleRate,
event,
playState,
position,
viewport,
wantSelection,
wantCurrentTime,
}) => {
it('generates the expected state', () => {
const mediaSet = MediaSet.fromPartial({
id: '123',
audioFrames: 441000,
audioSampleRate: audioSampleRate,
});
const state = stateReducer(
{
...initialState,
position,
mediaSet,
playState,
viewport,
},
{ type: 'waveformselectionchanged', event }
);
expect(state.selection).toEqual(wantSelection);
expect(state.currentTime).toEqual(wantCurrentTime);
});
}
);
});
describe('positionchanged', () => {
describe.each([
{
name: 'playing, 48k',
audioSampleRate: 48000,
newCurrentTime: 0.02,
position: { frame: 0, currentTime: 0, percent: 0 },
selection: { start: 0, end: 0 },
wantPosition: {
frame: 960,
currentTime: 0.02,
percent: 0.21768707482993196,
},
wantCurrentTime: undefined,
wantPlayState: PlayState.Playing,
},
{
name: 'playing, 44.1k',
audioSampleRate: 44100,
newCurrentTime: 8.51,
position: { frame: 360000, currentTime: 8.16, percent: 81.6 },
selection: { start: 0, end: 0 },
wantPosition: { frame: 375291, currentTime: 8.51, percent: 85.1 },
wantCurrentTime: undefined,
wantPlayState: PlayState.Playing,
},
{
name: 'playing, passed selection end',
audioSampleRate: 44100,
newCurrentTime: 8.51,
position: { frame: 360000, currentTime: 8.16, percent: 81.6 },
selection: { start: 22050, end: 375290 },
wantPosition: { frame: 375291, currentTime: 8.51, percent: 85.1 },
wantCurrentTime: 0.5,
wantPlayState: PlayState.Paused,
},
])(
'$name',
({
audioSampleRate,
newCurrentTime,
position,
selection,
wantPosition,
wantCurrentTime,
wantPlayState,
}) => {
it('generates the expected state', () => {
const mediaSet = MediaSet.fromPartial({
id: '123',
audioFrames: 441000,
audioSampleRate: audioSampleRate,
});
const state = stateReducer(
{
...initialState,
playState: PlayState.Playing,
mediaSet,
position,
selection,
},
{ type: 'positionchanged', currentTime: newCurrentTime }
);
expect(state.position).toEqual(wantPosition);
expect(state.playState).toEqual(wantPlayState);
expect(state.currentTime).toEqual(wantCurrentTime);
});
}
);
});
});

View File

@ -1,424 +0,0 @@
import { MediaSet } from './generated/media_set';
import { Observable } from 'rxjs';
import { SelectionChangeEvent } from './HudCanvas';
import { CanvasRange, SelectionMode, CanvasWidth } from './HudCanvasState';
import { zoomViewportIn, zoomViewportOut } from './helpers/zoom';
import frameToWaveformCanvasX from './helpers/frameToWaveformCanvasX';
export const zoomFactor = 2;
const initialViewportCanvasPixels = 100;
export interface FrameRange {
start: number;
end: number;
}
interface Position {
currentTime: number;
frame: number;
percent: number;
}
export enum PlayState {
Paused,
Playing,
}
export interface State {
mediaSet?: MediaSet;
selection: FrameRange;
viewport: FrameRange;
overviewPeaks: Observable<number[]>;
waveformPeaks: Observable<number[]>;
// selection canvas. Not kept up-to-date, only used for pushing updates.
selectionCanvas: CanvasRange;
// viewport canvas. Not kept up-to-date, only used for pushing updates.
viewportCanvas: CanvasRange;
audioSrc: string;
videoSrc: string;
position: Position;
// playback position in seconds, only used for forcing a change of position.
currentTime?: number;
playState: PlayState;
}
interface MediaSetLoadedAction {
type: 'mediasetloaded';
mediaSet: MediaSet;
}
interface OverviewPeaksLoadedAction {
type: 'overviewpeaksloaded';
peaks: Observable<number[]>;
}
interface WaveformPeaksLoadedAction {
type: 'waveformpeaksloaded';
peaks: Observable<number[]>;
}
interface AudioSourceLoadedAction {
type: 'audiosourceloaded';
numFrames: number;
src: string;
}
interface VideoSourceLoadedAction {
type: 'videosourceloaded';
src: string;
}
interface SetViewportAction {
type: 'setviewport';
viewport: FrameRange;
}
interface ZoomInAction {
type: 'zoomin';
}
interface ZoomOutAction {
type: 'zoomout';
}
interface ViewportChangedAction {
type: 'viewportchanged';
event: SelectionChangeEvent;
}
interface WaveformSelectionChangedAction {
type: 'waveformselectionchanged';
event: SelectionChangeEvent;
}
interface PositionChangedAction {
type: 'positionchanged';
currentTime: number;
}
interface SkipAction {
type: 'skip';
currentTime: number;
}
interface PlayAction {
type: 'play';
}
interface PauseAction {
type: 'pause';
}
type Action =
| MediaSetLoadedAction
| OverviewPeaksLoadedAction
| WaveformPeaksLoadedAction
| AudioSourceLoadedAction
| VideoSourceLoadedAction
| SetViewportAction
| ZoomInAction
| ZoomOutAction
| ViewportChangedAction
| WaveformSelectionChangedAction
| PositionChangedAction
| SkipAction
| PlayAction
| PauseAction;
export const stateReducer = (state: State, action: Action): State => {
switch (action.type) {
case 'mediasetloaded':
return handleMediaSetLoaded(state, action);
case 'overviewpeaksloaded':
return handleOverviewPeaksLoaded(state, action);
case 'waveformpeaksloaded':
return handleWaveformPeaksLoaded(state, action);
case 'audiosourceloaded':
return handleAudioSourceLoaded(state, action);
case 'videosourceloaded':
return handleVideoSourceLoaded(state, action);
case 'setviewport':
return setViewport(state, action);
case 'zoomin':
return handleZoomIn(state);
case 'zoomout':
return handleZoomOut(state);
case 'viewportchanged':
return handleViewportChanged(state, action);
case 'waveformselectionchanged':
return handleWaveformSelectionChanged(state, action);
case 'positionchanged':
return handlePositionChanged(state, action);
case 'skip':
return skip(state, action);
case 'play':
return play(state);
case 'pause':
return pause(state);
}
};
function handleMediaSetLoaded(
state: State,
{ mediaSet }: MediaSetLoadedAction
): State {
return { ...state, mediaSet };
}
function handleOverviewPeaksLoaded(
state: State,
{ peaks }: OverviewPeaksLoadedAction
) {
return { ...state, overviewPeaks: peaks };
}
function handleWaveformPeaksLoaded(
state: State,
{ peaks }: WaveformPeaksLoadedAction
) {
return { ...state, waveformPeaks: peaks };
}
function handleAudioSourceLoaded(
state: State,
{ src, numFrames }: AudioSourceLoadedAction
): State {
const mediaSet = state.mediaSet;
if (mediaSet == null) {
return state;
}
const viewportEnd = Math.min(
Math.round(numFrames / CanvasWidth) * initialViewportCanvasPixels,
numFrames
);
return setViewport(
{
...state,
audioSrc: src,
mediaSet: {
...mediaSet,
audioFrames: numFrames,
},
},
{ type: 'setviewport', viewport: { start: 0, end: viewportEnd } }
);
}
function handleVideoSourceLoaded(
state: State,
{ src }: VideoSourceLoadedAction
): State {
return { ...state, videoSrc: src };
}
function setViewport(state: State, { viewport }: SetViewportAction): State {
const { mediaSet, selection } = state;
if (!mediaSet) {
return state;
}
return {
...state,
viewport: viewport,
viewportCanvas: {
x1: Math.round((viewport.start / mediaSet.audioFrames) * CanvasWidth),
x2: Math.round((viewport.end / mediaSet.audioFrames) * CanvasWidth),
},
selectionCanvas: selectionToWaveformCanvasRange(selection, viewport),
};
}
function handleZoomIn(state: State): State {
const {
mediaSet,
viewport,
selection,
position: { frame },
} = state;
if (!mediaSet) {
return state;
}
const newViewport = zoomViewportIn(
viewport,
mediaSet.audioFrames,
selection,
frame,
zoomFactor
);
// TODO: refactoring zoom helpers to use CanvasRange may avoid this step:
return setViewport(state, { type: 'setviewport', viewport: newViewport });
}
function handleZoomOut(state: State): State {
const {
mediaSet,
viewport,
selection,
position: { currentTime },
} = state;
if (!mediaSet) {
return state;
}
const newViewport = zoomViewportOut(
viewport,
mediaSet.audioFrames,
selection,
currentTime,
zoomFactor
);
// TODO: refactoring zoom helpers to use CanvasRange may avoid this step:
return setViewport(state, { type: 'setviewport', viewport: newViewport });
}
function handleViewportChanged(
state: State,
{ event: { mode, selection: canvasRange } }: ViewportChangedAction
): State {
const { mediaSet, selection } = state;
if (!mediaSet) {
return state;
}
if (mode != SelectionMode.Normal) {
return state;
}
const newViewport = {
start: Math.round(mediaSet.audioFrames * (canvasRange.x1 / CanvasWidth)),
end: Math.round(mediaSet.audioFrames * (canvasRange.x2 / CanvasWidth)),
};
return {
...state,
viewport: newViewport,
selectionCanvas: selectionToWaveformCanvasRange(selection, newViewport),
};
}
function handleWaveformSelectionChanged(
state: State,
{
event: { mode, prevMode, selection: canvasRange },
}: WaveformSelectionChangedAction
): State {
const {
mediaSet,
playState,
viewport,
position: { frame: currFrame },
} = state;
if (mediaSet == null) {
return state;
}
const framesPerPixel = (viewport.end - viewport.start) / CanvasWidth;
const newSelection = {
start: Math.round(viewport.start + canvasRange.x1 * framesPerPixel),
end: Math.round(viewport.start + canvasRange.x2 * framesPerPixel),
};
let currentTime = state.currentTime;
if (
prevMode != SelectionMode.Normal &&
mode == SelectionMode.Normal &&
(playState == PlayState.Paused ||
currFrame < newSelection.start ||
currFrame > newSelection.end)
) {
currentTime = newSelection.start / mediaSet.audioSampleRate;
}
return {
...state,
selection: newSelection,
currentTime: currentTime,
};
}
function handlePositionChanged(
state: State,
{ currentTime }: PositionChangedAction
): State {
const {
mediaSet,
selection,
position: { frame: prevFrame },
} = state;
if (mediaSet == null) {
return state;
}
const frame = Math.round(currentTime * mediaSet.audioSampleRate);
const percent = (frame / mediaSet.audioFrames) * 100;
// reset play position and pause if selection end passed.
let playState = state.playState;
let forceCurrentTime;
if (
selection.start != selection.end &&
prevFrame < selection.end &&
frame >= selection.end
) {
playState = PlayState.Paused;
forceCurrentTime = selection.start / mediaSet.audioSampleRate;
}
return {
...state,
playState,
currentTime: forceCurrentTime,
position: {
currentTime,
frame,
percent,
},
};
}
function skip(state: State, { currentTime }: SkipAction): State {
return { ...state, currentTime: currentTime };
}
function play(state: State): State {
return { ...state, playState: PlayState.Playing };
}
function pause(state: State): State {
const { mediaSet, selection } = state;
if (!mediaSet) {
return state;
}
let currentTime;
if (selection.start != selection.end) {
currentTime = selection.start / mediaSet.audioSampleRate;
}
return { ...state, currentTime, playState: PlayState.Paused };
}
// helpers
function selectionToWaveformCanvasRange(
selection: FrameRange,
viewport: FrameRange
): CanvasRange {
const x1 = frameToWaveformCanvasX(selection.start, viewport, CanvasWidth);
const x2 = frameToWaveformCanvasX(selection.end, viewport, CanvasWidth);
if (x1 == x2) {
return { x1: 0, x2: 0 };
}
return { x1: x1 || 0, x2: x2 || CanvasWidth };
}

View File

@ -1,86 +1,34 @@
import React from 'react';
import { PlayState } from './AppState';
import {
CloudDownloadIcon,
PauseIcon,
PlayIcon,
ZoomInIcon,
ZoomOutIcon,
} from '@heroicons/react/solid';
interface Props {
playState: PlayState;
zoomInEnabled: boolean;
zoomOutEnabled: boolean;
onTogglePlay: () => void;
onPlay: () => void;
onPause: () => void;
onClip: () => void;
onZoomIn: () => void;
onZoomOut: () => void;
downloadClipEnabled: boolean;
}
const ControlBar: React.FC<Props> = React.memo((props: Props) => {
const buttonStyle =
'bg-gray-600 hover:bg-gray-500 text-white font-bold py-2 px-4 rounded';
const disabledButtonStyle =
'bg-gray-700 text-white font-bold py-2 px-4 rounded cursor-auto';
const downloadButtonStyle = props.downloadClipEnabled
? 'bg-green-600 hover:bg-green-600 text-white font-bold py-2 px-4 rounded absolute right-0'
: 'bg-gray-600 hover:cursor-not-allowed text-gray-500 font-bold py-2 px-4 rounded absolute right-0';
const iconStyle = 'inline h-7 w-7 text-white-500';
const playPauseComponent =
props.playState == PlayState.Playing ? (
<PauseIcon className={iconStyle} />
) : (
<PlayIcon className={iconStyle} />
);
const handleClip = () => {
if (props.downloadClipEnabled) {
props.onClip();
}
};
// Detect if the space bar has been used to trigger this event, and ignore
// it if so. This conflicts with the player interface.
const filterMouseEvent = (evt: React.MouseEvent, cb: () => void) => {
if (evt.detail == 0) {
return;
}
cb();
const styles = { width: '100%', flexGrow: 0 };
const buttonStyles = {
cursor: 'pointer',
background: 'black',
outline: 'none',
border: 'none',
color: 'green',
display: 'inline-block',
margin: '0 2px',
};
return (
<>
<div className="relative grow-0 w-full py-2 space-x-2">
<button
className={buttonStyle}
onClick={(evt) => filterMouseEvent(evt, props.onTogglePlay)}
>
{playPauseComponent}
<div style={styles}>
<button style={buttonStyles} onClick={props.onPlay}>
Play
</button>
<button
className={props.zoomInEnabled ? buttonStyle : disabledButtonStyle}
onClick={(evt) => filterMouseEvent(evt, props.onZoomIn)}
>
<ZoomInIcon className={iconStyle} />
<button style={buttonStyles} onClick={props.onPause}>
Pause
</button>
<button
className={props.zoomOutEnabled ? buttonStyle : disabledButtonStyle}
onClick={(evt) => filterMouseEvent(evt, props.onZoomOut)}
>
<ZoomOutIcon className={iconStyle} />
</button>
<button
className={downloadButtonStyle}
onClick={(evt) => filterMouseEvent(evt, handleClip)}
>
<CloudDownloadIcon className={`${iconStyle} mr-2`} />
Download clip as MP3
<button style={buttonStyles} onClick={props.onClip}>
Clip
</button>
</div>
</>

View File

@ -1,112 +1,85 @@
import { useEffect, useRef, useReducer, MouseEvent } from 'react';
import {
stateReducer,
State,
SelectionMode,
HoverState,
EmptySelectionAction,
CanvasRange,
} from './HudCanvasState';
import constrainNumeric from './helpers/constrainNumeric';
export { EmptySelectionAction } from './HudCanvasState';
import { useState, useEffect, useRef, useCallback, MouseEvent } from 'react';
interface Styles {
borderLineWidth: number;
borderStrokeStyle: string;
positionLineWidth: number;
positionStrokeStyle: string;
hoverPositionStrokeStyle: string;
}
interface Props {
width: number;
height: number;
zIndex: number;
emptySelectionAction: EmptySelectionAction;
styles: Styles;
position: number | null;
selection: CanvasRange;
onSelectionChange: (selectionState: SelectionChangeEvent) => void;
selection: Selection;
onSelectionChange: (selection: Selection) => void;
}
export interface SelectionChangeEvent {
selection: CanvasRange;
mode: SelectionMode;
prevMode: SelectionMode;
enum Mode {
Normal,
Selecting,
Dragging,
ResizingStart,
ResizingEnd,
}
const emptySelection: CanvasRange = { x1: 0, x2: 0 };
enum HoverState {
Normal,
OverSelectionStart,
OverSelectionEnd,
OverSelection,
}
const initialState: State = {
width: 0,
emptySelectionAction: EmptySelectionAction.SelectNothing,
hoverX: 0,
selection: emptySelection,
origSelection: emptySelection,
mousedownX: 0,
mode: SelectionMode.Normal,
prevMode: SelectionMode.Normal,
cursorClass: 'cursor-auto',
hoverState: HoverState.Normal,
shouldPublish: false,
};
export enum EmptySelectionAction {
SelectNothing,
SelectPrevious,
}
const getCanvasX = (evt: MouseEvent<HTMLCanvasElement>): number => {
const rect = evt.currentTarget.getBoundingClientRect();
const x = Math.round(
((evt.clientX - rect.left) / rect.width) * evt.currentTarget.width
);
return constrainNumeric(x, evt.currentTarget.width);
};
export interface Selection {
start: number;
end: number;
}
const emptySelection: Selection = { start: 0, end: 0 };
export const HudCanvas: React.FC<Props> = ({
width,
height,
zIndex,
emptySelectionAction,
styles: {
borderLineWidth,
borderStrokeStyle,
positionLineWidth,
positionStrokeStyle,
hoverPositionStrokeStyle,
},
position,
selection: selection,
selection,
onSelectionChange,
}: Props) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [state, dispatch] = useReducer(stateReducer, {
...initialState,
width,
selection,
emptySelectionAction,
// selection and newSelection are in canvas logical pixels:
const [newSelection, setNewSelection] = useState({
...emptySelection,
});
const [mode, setMode] = useState(Mode.Normal);
const [hoverState, setHoverState] = useState(HoverState.Normal);
const [cursor, setCursor] = useState('auto');
const canvasRef = useRef<HTMLCanvasElement>(null);
const moveOffsetX = useRef(0);
// side effects
useEffect(() => {
dispatch({ selection: selection, x: 0, type: 'setselection' });
}, [selection]);
// handle global mouse up
useEffect(() => {
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mouseup', handleMouseUp);
};
}, [state]);
// trigger onSelectionChange callback.
useEffect(() => {
if (!state.shouldPublish) {
return;
}
onSelectionChange({
selection: state.selection,
mode: state.mode,
prevMode: state.prevMode,
});
}, [state]);
}, [mode, newSelection]);
// draw the overview HUD
useEffect(() => {
@ -125,38 +98,32 @@ export const HudCanvas: React.FC<Props> = ({
// draw selection
const currentSelection = state.selection;
let currentSelection: Selection;
if (
mode == Mode.Selecting ||
mode == Mode.Dragging ||
mode == Mode.ResizingStart ||
mode == Mode.ResizingEnd
) {
currentSelection = newSelection;
} else {
currentSelection = selection;
}
ctx.beginPath();
ctx.strokeStyle = borderStrokeStyle;
ctx.lineWidth = borderLineWidth;
const alpha =
state.hoverState == HoverState.OverSelection ? '0.15' : '0.13';
const alpha = hoverState == HoverState.OverSelection ? '0.15' : '0.13';
ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`;
ctx.rect(
currentSelection.x1,
currentSelection.start,
borderLineWidth,
currentSelection.x2 - currentSelection.x1,
currentSelection.end - currentSelection.start,
canvas.height - borderLineWidth * 2
);
ctx.fill();
ctx.stroke();
// draw hover position
const hoverX = state.hoverX;
if (
(hoverX != 0 && hoverX < currentSelection.x1) ||
hoverX > currentSelection.x2
) {
ctx.beginPath();
ctx.strokeStyle = hoverPositionStrokeStyle;
ctx.lineWidth = 2;
ctx.moveTo(hoverX, 0);
ctx.lineTo(hoverX, canvas.height);
ctx.stroke();
}
// draw position marker
if (position == null) {
@ -167,41 +134,188 @@ export const HudCanvas: React.FC<Props> = ({
ctx.strokeStyle = positionStrokeStyle;
ctx.lineWidth = positionLineWidth;
ctx.moveTo(position, 0);
ctx.lineWidth = position == 0 ? 8 : 4;
ctx.lineTo(position, canvas.height);
ctx.lineWidth = 4;
ctx.lineTo(position, canvas.height - 4);
ctx.stroke();
});
}, [state, position]);
}, [selection, newSelection, position]);
// handlers
const hoverOffset = 10;
const isHoveringSelectionStart = (x: number): boolean => {
return (
x > selection.start - hoverOffset && x < selection.start + hoverOffset
);
};
const isHoveringSelectionEnd = (x: number): boolean => {
return x > selection.end - hoverOffset && x < selection.end + hoverOffset;
};
const isHoveringSelection = (x: number): boolean => {
return x >= selection.start && x <= selection.end;
};
const getCanvasX = (evt: MouseEvent<HTMLCanvasElement>): number => {
const rect = evt.currentTarget.getBoundingClientRect();
const x = Math.round(((evt.clientX - rect.left) / rect.width) * width);
return constrainXToCanvas(x);
};
const constrainXToCanvas = (x: number): number => {
if (x < 0) {
return 0;
}
if (x > width) {
return width;
}
return x;
};
const handleMouseDown = (evt: MouseEvent<HTMLCanvasElement>) => {
if (state.mode != SelectionMode.Normal) {
if (mode != Mode.Normal) {
return;
}
dispatch({ x: getCanvasX(evt), type: 'mousedown' });
const x = getCanvasX(evt);
if (isHoveringSelectionStart(x)) {
setMode(Mode.ResizingStart);
moveOffsetX.current = x;
} else if (isHoveringSelectionEnd(x)) {
setMode(Mode.ResizingEnd);
moveOffsetX.current = x;
} else if (isHoveringSelection(x)) {
setMode(Mode.Dragging);
setCursor('pointer');
moveOffsetX.current = x;
} else {
setMode(Mode.Selecting);
setCursor('col-resize');
moveOffsetX.current = x;
setNewSelection({ start: x, end: x });
}
};
const handleMouseMove = (evt: MouseEvent<HTMLCanvasElement>) => {
dispatch({ x: getCanvasX(evt), type: 'mousemove' });
const x = getCanvasX(evt);
switch (mode) {
case Mode.Normal: {
if (isHoveringSelectionStart(x)) {
setHoverState(HoverState.OverSelectionStart);
setCursor('col-resize');
} else if (isHoveringSelectionEnd(x)) {
setHoverState(HoverState.OverSelectionEnd);
setCursor('col-resize');
} else if (isHoveringSelection(x)) {
setHoverState(HoverState.OverSelection);
setCursor('pointer');
} else {
setCursor('auto');
}
break;
}
case Mode.ResizingStart: {
const diff = x - moveOffsetX.current;
const start = constrainXToCanvas(selection.start + diff);
if (start > selection.end) {
setNewSelection({ start: selection.end, end: start });
break;
}
setNewSelection({ ...selection, start: start });
break;
}
case Mode.ResizingEnd: {
const diff = x - moveOffsetX.current;
const end = constrainXToCanvas(selection.end + diff);
if (end < selection.start) {
setNewSelection({ start: Math.max(0, end), end: selection.start });
break;
}
setNewSelection({ ...selection, end: end });
break;
}
case Mode.Dragging: {
const diff = x - moveOffsetX.current;
const selectionWidth = selection.end - selection.start;
let start = Math.max(0, selection.start + diff);
let end = start + selectionWidth;
if (end > width) {
end = width;
start = end - selectionWidth;
}
setNewSelection({ start: start, end: end });
break;
}
case Mode.Selecting: {
if (x < moveOffsetX.current) {
setNewSelection({
start: x,
end: moveOffsetX.current,
});
} else {
setNewSelection({ start: moveOffsetX.current, end: x });
}
break;
}
}
};
const handleMouseUp = () => {
if (state.mode == SelectionMode.Normal) {
if (mode == Mode.Normal) {
return;
}
dispatch({ x: state.hoverX, type: 'mouseup' });
setMode(Mode.Normal);
setCursor('auto');
if (newSelection.start == newSelection.end) {
handleEmptySelectionAction();
return;
}
onSelectionChange({ ...newSelection });
};
const handleMouseLeave = () => {
dispatch({ x: state.hoverX, type: 'mouseleave' });
const handleEmptySelectionAction = useCallback(() => {
switch (emptySelectionAction) {
case EmptySelectionAction.SelectPrevious:
setNewSelection({ ...selection });
break;
case EmptySelectionAction.SelectNothing:
onSelectionChange({ start: 0, end: 0 });
break;
}
}, [selection]);
const handleMouseLeave = (_evt: MouseEvent<HTMLCanvasElement>) => {
setHoverState(HoverState.Normal);
};
const canvasStyles = {
display: 'block',
position: 'absolute',
width: '100%',
height: '100%',
zIndex: zIndex,
cursor: cursor,
} as React.CSSProperties;
return (
<>
<canvas
ref={canvasRef}
className={`block absolute w-full h-full ${state.cursorClass} z-20`}
width={width}
height={height}
style={canvasStyles}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}

View File

@ -1,294 +0,0 @@
import {
stateReducer,
SelectionMode,
EmptySelectionAction,
HoverState,
} from './HudCanvasState';
const initialState = {
width: 5000,
emptySelectionAction: EmptySelectionAction.SelectNothing,
hoverX: 0,
selection: { x1: 0, x2: 0 },
origSelection: { x1: 0, x2: 0 },
mousedownX: 0,
mode: SelectionMode.Normal,
prevMode: SelectionMode.Normal,
cursorClass: 'cursor-auto',
hoverState: HoverState.Normal,
shouldPublish: false,
};
describe('stateReducer', () => {
describe('setselection', () => {
it('sets the selection', () => {
const state = stateReducer(
{ ...initialState },
{ type: 'setselection', x: 0, selection: { x1: 100, x2: 200 } }
);
expect(state.selection).toEqual({ x1: 100, x2: 200 });
expect(state.shouldPublish).toBeFalsy();
});
});
describe.each([
{
name: 'entering resizing start',
x: 995,
selection: { x1: 1000, x2: 2000 },
wantMode: SelectionMode.ResizingStart,
wantSelection: { x1: 1000, x2: 2000 },
},
{
name: 'entering resizing end',
x: 2003,
selection: { x1: 1000, x2: 2000 },
wantMode: SelectionMode.ResizingEnd,
wantSelection: { x1: 1000, x2: 2000 },
},
{
name: 'entering dragging',
x: 1500,
selection: { x1: 1000, x2: 2000 },
wantMode: SelectionMode.Dragging,
wantSelection: { x1: 1000, x2: 2000 },
},
{
name: 'entering selecting',
x: 10,
selection: { x1: 1000, x2: 2000 },
wantMode: SelectionMode.Selecting,
wantSelection: { x1: 10, x2: 10 },
},
])('mousedown', ({ name, x, selection, wantMode, wantSelection }) => {
test(`${name} generates the expected state`, () => {
const state = stateReducer(
{ ...initialState, selection: selection },
{ type: 'mousedown', x: x }
);
expect(state.mode).toEqual(wantMode);
expect(state.selection).toEqual(wantSelection);
expect(state.mousedownX).toEqual(x);
expect(state.shouldPublish).toBeTruthy();
});
});
describe.each([
{
name: 're-entering normal mode',
x: 1200,
selection: { x1: 1000, x2: 2000 },
emptySelectionAction: EmptySelectionAction.SelectNothing,
wantSelection: { x1: 1000, x2: 2000 },
},
{
name: 'when nothing is selected and emptySelectionAction is SelectNothing',
x: 1200,
selection: { x1: 1000, x2: 1000 },
emptySelectionAction: EmptySelectionAction.SelectNothing,
wantSelection: { x1: 1200, x2: 1200 },
},
{
// TODO: broken
name: 'when nothing is selected and emptySelectionAction is SelectPrevious',
x: 1200,
selection: { x1: 1000, x2: 2000 },
emptySelectionAction: EmptySelectionAction.SelectPrevious,
wantSelection: { x1: 1000, x2: 2000 },
},
])(
'mouseup',
({ name, x, selection, emptySelectionAction, wantSelection }) => {
test(`${name} generates the expected state`, () => {
const state = stateReducer(
{
...initialState,
selection: selection,
mode: SelectionMode.Selecting,
emptySelectionAction: emptySelectionAction,
},
{ type: 'mouseup', x: x }
);
expect(state.mode).toEqual(SelectionMode.Normal);
expect(state.prevMode).toEqual(SelectionMode.Selecting);
expect(state.selection).toEqual(wantSelection);
expect(state.shouldPublish).toBeTruthy();
});
}
);
describe('mouseleave', () => {
it('sets the state', () => {
const state = stateReducer(
{
...initialState,
selection: { x1: 2000, x2: 3000 },
mode: SelectionMode.Dragging,
mousedownX: 475,
},
{ type: 'mouseleave', x: 500 }
);
expect(state.mode).toEqual(SelectionMode.Dragging);
expect(state.selection).toEqual({ x1: 2000, x2: 3000 });
expect(state.mousedownX).toEqual(475);
expect(state.hoverX).toEqual(0);
expect(state.shouldPublish).toBeFalsy();
});
});
describe('mousemove', () => {
describe.each([
// Normal mode
{
name: 'hovering over selection start',
mode: SelectionMode.Normal,
x: 997,
selection: { x1: 1000, x2: 3000 },
wantHoverState: HoverState.OverSelectionStart,
shouldPublish: false,
},
{
name: 'hovering over selection end',
mode: SelectionMode.Normal,
x: 3009,
selection: { x1: 1000, x2: 3000 },
wantHoverState: HoverState.OverSelectionEnd,
shouldPublish: false,
},
{
name: 'hovering over selection',
mode: SelectionMode.Normal,
x: 1200,
selection: { x1: 1000, x2: 3000 },
wantHoverState: HoverState.OverSelection,
shouldPublish: false,
},
{
name: 'hovering elsewhere',
mode: SelectionMode.Normal,
x: 300,
selection: { x1: 1000, x2: 3000 },
wantHoverState: HoverState.Normal,
shouldPublish: false,
},
// Selecting mode
{
name: 'when not crossing over',
mode: SelectionMode.Selecting,
x: 3005,
mousedownX: 2000,
selection: { x1: 2000, x2: 3000 },
wantSelection: { x1: 2000, x2: 3005 },
shouldPublish: true,
},
{
name: 'when crossing over',
mode: SelectionMode.Selecting,
x: 1995,
mousedownX: 2000,
selection: { x1: 2000, x2: 2002 },
wantSelection: { x1: 1995, x2: 2000 },
shouldPublish: true,
},
// Dragging mode
{
name: 'in the middle of the canvas',
mode: SelectionMode.Dragging,
x: 1220,
mousedownX: 1200,
selection: { x1: 1000, x2: 1500 },
origSelection: { x1: 1000, x2: 1500 },
wantSelection: { x1: 1020, x2: 1520 },
shouldPublish: true,
},
{
name: 'at the start of the canvas',
mode: SelectionMode.Dragging,
x: 30,
mousedownX: 50,
selection: { x1: 10, x2: 210 },
origSelection: { x1: 10, x2: 210 },
wantSelection: { x1: 0, x2: 200 },
shouldPublish: true,
},
{
name: 'at the end of the canvas',
mode: SelectionMode.Dragging,
x: 1400,
mousedownX: 1250,
selection: { x1: 4800, x2: 4900 },
origSelection: { x1: 4800, x2: 4900 },
wantSelection: { x1: 4900, x2: 5000 },
shouldPublish: true,
},
// ResizingStart mode
{
name: 'when not crossing over',
mode: SelectionMode.ResizingStart,
x: 2020,
selection: { x1: 2000, x2: 3000 },
origSelection: { x1: 2000, x2: 3000 },
wantSelection: { x1: 2020, x2: 3000 },
shouldPublish: true,
},
{
name: 'when crossing over',
mode: SelectionMode.ResizingStart,
x: 2010,
selection: { x1: 2000, x2: 2002 },
origSelection: { x1: 2000, x2: 2002 },
wantSelection: { x1: 2002, x2: 2010 },
shouldPublish: true,
},
// ResizingEnd mode
{
name: 'when not crossing over',
mode: SelectionMode.ResizingEnd,
x: 2007,
selection: { x1: 1000, x2: 2000 },
origSelection: { x1: 1000, x2: 2000 },
wantSelection: { x1: 1000, x2: 2007 },
shouldPublish: true,
},
{
name: 'when crossing over',
mode: SelectionMode.ResizingEnd,
x: 1995,
selection: { x1: 2000, x2: 2002 },
origSelection: { x1: 2000, x2: 2002 },
wantSelection: { x1: 1995, x2: 2000 },
shouldPublish: true,
},
])(
'mousemove',
({
name,
mode,
x,
mousedownX = 0,
selection,
origSelection = { x1: 0, x2: 0 },
wantSelection = selection,
wantHoverState = HoverState.Normal,
shouldPublish,
}) => {
test(`${SelectionMode[mode]} mode: ${name} generates the expected state`, () => {
const state = stateReducer(
{
...initialState,
selection: selection,
origSelection: origSelection,
mode: mode,
mousedownX: mousedownX,
},
{ type: 'mousemove', x: x }
);
expect(state.mode).toEqual(mode);
expect(state.selection).toEqual(wantSelection);
expect(state.hoverState).toEqual(wantHoverState);
expect(state.shouldPublish).toEqual(shouldPublish);
});
}
);
});
});

View File

@ -1,266 +0,0 @@
import constrainNumeric from './helpers/constrainNumeric';
export const CanvasWidth = 2000;
export const CanvasHeight = 500;
export interface CanvasRange {
x1: number;
x2: number;
}
export enum HoverState {
Normal,
OverSelectionStart,
OverSelectionEnd,
OverSelection,
}
export enum EmptySelectionAction {
SelectNothing,
SelectPrevious,
}
export enum SelectionMode {
Normal,
Selecting,
Dragging,
ResizingStart,
ResizingEnd,
}
export interface State {
width: number;
emptySelectionAction: EmptySelectionAction;
hoverX: number;
selection: CanvasRange;
origSelection: CanvasRange;
mousedownX: number;
mode: SelectionMode;
prevMode: SelectionMode;
cursorClass: string;
hoverState: HoverState;
shouldPublish: boolean;
}
interface SelectionAction {
type: string;
x: number;
// TODO: selection is only used for the setselection SelectionAction. Improve
// the typing here.
selection?: CanvasRange;
}
export const stateReducer = (
{
selection: prevSelection,
origSelection,
mousedownX: prevMousedownX,
mode: prevMode,
width,
emptySelectionAction,
}: State,
{ x, type, selection: selectionToSet }: SelectionAction
): State => {
let mode: SelectionMode;
let newSelection: CanvasRange;
let hoverX: number;
let mousedownX: number;
let cursorClass: string;
let hoverState: HoverState;
let shouldPublish: boolean | null = null;
switch (type) {
case 'setselection':
newSelection = selectionToSet || { x1: 0, x2: 0 };
mousedownX = prevMousedownX;
mode = SelectionMode.Normal;
cursorClass = 'cursor-auto';
hoverX = x;
hoverState = HoverState.Normal;
shouldPublish = false;
break;
case 'mousedown':
mousedownX = x;
cursorClass = 'cursor-auto';
hoverX = x;
hoverState = HoverState.Normal;
if (isHoveringSelectionStart(x, prevSelection)) {
newSelection = prevSelection;
mode = SelectionMode.ResizingStart;
} else if (isHoveringSelectionEnd(x, prevSelection)) {
newSelection = prevSelection;
mode = SelectionMode.ResizingEnd;
} else if (isHoveringSelection(x, prevSelection)) {
newSelection = prevSelection;
mode = SelectionMode.Dragging;
cursorClass = 'cursor-move';
} else {
newSelection = { x1: x, x2: x };
mode = SelectionMode.Selecting;
cursorClass = 'cursor-col-resize';
}
origSelection = newSelection;
break;
case 'mouseup':
newSelection = prevSelection;
mousedownX = prevMousedownX;
mode = SelectionMode.Normal;
cursorClass = 'cursor-auto';
hoverX = x;
hoverState = HoverState.Normal;
if (
newSelection.x1 == newSelection.x2 &&
emptySelectionAction == EmptySelectionAction.SelectNothing
) {
newSelection = { x1: x, x2: x };
}
break;
case 'mouseleave':
newSelection = prevSelection;
mousedownX = prevMousedownX;
mode = prevMode;
cursorClass = 'cursor-auto';
hoverX = 0;
hoverState = HoverState.Normal;
break;
case 'mousemove':
mousedownX = prevMousedownX;
hoverX = x;
hoverState = HoverState.Normal;
switch (prevMode) {
case SelectionMode.Normal: {
newSelection = prevSelection;
mode = SelectionMode.Normal;
if (isHoveringSelectionStart(x, prevSelection)) {
cursorClass = 'cursor-col-resize';
hoverState = HoverState.OverSelectionStart;
} else if (isHoveringSelectionEnd(x, prevSelection)) {
cursorClass = 'cursor-col-resize';
hoverState = HoverState.OverSelectionEnd;
} else if (isHoveringSelection(x, prevSelection)) {
cursorClass = 'cursor-move';
hoverState = HoverState.OverSelection;
} else {
cursorClass = 'cursor-auto';
hoverState = HoverState.Normal;
}
break;
}
case SelectionMode.Selecting: {
cursorClass = 'cursor-col-resize';
mode = SelectionMode.Selecting;
if (x < prevMousedownX) {
newSelection = {
x1: x,
x2: prevMousedownX,
};
} else {
newSelection = {
x1: prevMousedownX,
x2: x,
};
}
break;
}
case SelectionMode.Dragging: {
mode = SelectionMode.Dragging;
cursorClass = 'cursor-move';
const diff = x - prevMousedownX;
const selectionWidth = origSelection.x2 - origSelection.x1;
let start = Math.max(0, origSelection.x1 + diff);
let end = start + selectionWidth;
if (end > width) {
end = width;
start = end - selectionWidth;
}
newSelection = { x1: start, x2: end };
break;
}
case SelectionMode.ResizingStart: {
mode = SelectionMode.ResizingStart;
cursorClass = 'cursor-col-resize';
const start = constrainNumeric(x, width);
if (start > origSelection.x2) {
newSelection = { x1: origSelection.x2, x2: start };
} else {
newSelection = { ...origSelection, x1: start };
}
break;
}
case SelectionMode.ResizingEnd: {
mode = SelectionMode.ResizingEnd;
cursorClass = 'cursor-col-resize';
const end = constrainNumeric(x, width);
if (end < origSelection.x1) {
newSelection = {
x1: end,
x2: origSelection.x1,
};
} else {
newSelection = { ...origSelection, x2: x };
}
break;
}
}
break;
default:
throw new Error();
}
// by default, only trigger the callback if the selection or mode has changed.
if (shouldPublish == null) {
shouldPublish = newSelection != prevSelection || mode != prevMode;
}
return {
width: width,
emptySelectionAction: emptySelectionAction,
hoverX: hoverX,
selection: newSelection,
origSelection: origSelection,
mousedownX: mousedownX,
mode: mode,
prevMode: prevMode,
cursorClass: cursorClass,
hoverState: hoverState,
shouldPublish: shouldPublish,
};
};
// helpers
const hoverOffset = 10;
const isHoveringSelectionStart = (
x: number,
selection: CanvasRange
): boolean => {
return x > selection.x1 - hoverOffset && x < selection.x1 + hoverOffset;
};
const isHoveringSelectionEnd = (x: number, selection: CanvasRange): boolean => {
return x > selection.x2 - hoverOffset && x < selection.x2 + hoverOffset;
};
const isHoveringSelection = (x: number, selection: CanvasRange): boolean => {
return x >= selection.x1 && x <= selection.x2;
};

114
frontend/src/Overview.tsx Normal file
View File

@ -0,0 +1,114 @@
import { useState, useEffect, useCallback } from 'react';
import { MediaSet } from './generated/media_set';
import { WaveformCanvas } from './WaveformCanvas';
import { HudCanvas, EmptySelectionAction } from './HudCanvas';
import { Observable } from 'rxjs';
export interface Selection {
start: number;
end: number;
}
interface Props {
peaks: Observable<number[]>;
mediaSet: MediaSet;
height: number;
offsetPixels: number;
position: VideoPosition;
viewport: Frames;
onSelectionChange: (selection: Selection) => void;
}
export const CanvasLogicalWidth = 2_000;
export const CanvasLogicalHeight = 500;
export const Overview: React.FC<Props> = ({
peaks,
mediaSet,
height,
offsetPixels,
position,
viewport,
onSelectionChange,
}: Props) => {
const [selectedPixels, setSelectedPixels] = useState({ start: 0, end: 0 });
const [positionPixels, setPositionPixels] = useState(0);
// side effects
// convert viewport from frames to canvas pixels.
useEffect(() => {
setSelectedPixels({
start: Math.round(
(viewport.start / mediaSet.audioFrames) * CanvasLogicalWidth
),
end: Math.round(
(viewport.end / mediaSet.audioFrames) * CanvasLogicalWidth
),
});
}, [viewport, mediaSet]);
// convert position from frames to canvas pixels:
useEffect(() => {
const ratio =
position.currentTime / (mediaSet.audioFrames / mediaSet.audioSampleRate);
setPositionPixels(Math.round(ratio * CanvasLogicalWidth));
frames;
}, [mediaSet, position]);
// handlers
// convert selection change from canvas pixels to frames, and trigger callback.
const handleSelectionChange = useCallback(
({ start, end }: Selection) => {
onSelectionChange({
start: Math.round((start / CanvasLogicalWidth) * mediaSet.audioFrames),
end: Math.round((end / CanvasLogicalWidth) * mediaSet.audioFrames),
});
},
[mediaSet]
);
// render component
const containerStyles = {
flexGrow: 0,
position: 'relative',
margin: `0 ${offsetPixels}px`,
height: `${height}px`,
} as React.CSSProperties;
const hudStyles = {
borderLineWidth: 4,
borderStrokeStyle: 'red',
positionLineWidth: 4,
positionStrokeStyle: 'red',
};
return (
<>
<div style={containerStyles}>
<WaveformCanvas
peaks={peaks}
channels={mediaSet.audioChannels}
width={CanvasLogicalWidth}
height={CanvasLogicalHeight}
strokeStyle="black"
fillStyle="#003300"
zIndex={1}
alpha={1}
></WaveformCanvas>
<HudCanvas
width={CanvasLogicalWidth}
height={CanvasLogicalHeight}
zIndex={1}
emptySelectionAction={EmptySelectionAction.SelectPrevious}
styles={hudStyles}
position={positionPixels}
selection={selectedPixels}
onSelectionChange={handleSelectionChange}
/>
</div>
</>
);
};

View File

@ -1,155 +0,0 @@
import { MediaSet, MediaSetServiceClientImpl } from './generated/media_set';
import { newRPC } from './App';
import { PlayState } from './AppState';
import { useEffect, useRef } from 'react';
import millisFromDuration from './helpers/millisFromDuration';
interface Props {
mediaSet: MediaSet;
playState: PlayState;
audioSrc: string;
videoSrc: string;
// used to jump to a new position:
currentTime?: number;
onPositionChanged: (currentTime: number) => void;
}
const triggerCallbackIntervalMillis = 20;
// eslint is complaining about prop validation which doesn't make much sense.
/* eslint-disable react/prop-types */
export const Player: React.FC<Props> = ({
mediaSet,
playState,
audioSrc,
videoSrc,
currentTime,
onPositionChanged,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const audioRef = useRef(new Audio());
const videoRef = useRef(document.createElement('video'));
useEffect(() => {
setInterval(() => {
if (audioRef.current.paused) {
return;
}
onPositionChanged(audioRef.current.currentTime);
}, triggerCallbackIntervalMillis);
}, []);
useEffect(() => {
if (playState == PlayState.Paused && !audioRef.current.paused) {
audioRef.current.pause();
videoRef.current.pause();
return;
}
if (playState == PlayState.Playing && audioRef.current.paused) {
audioRef.current.play();
videoRef.current.play();
return;
}
}, [playState]);
useEffect(() => {
if (audioSrc == '') {
return;
}
audioRef.current.src = audioSrc;
console.log('set audio src', audioSrc);
}, [audioSrc]);
useEffect(() => {
if (videoSrc == '') {
return;
}
videoRef.current.src = videoSrc;
console.log('set video src', videoSrc);
}, [videoSrc]);
useEffect(() => {
if (currentTime == undefined) {
return;
}
audioRef.current.currentTime = currentTime;
videoRef.current.currentTime = currentTime;
onPositionChanged(currentTime);
}, [currentTime]);
// render canvas
useEffect(() => {
// TODO: not sure if requestAnimationFrame is recommended here.
requestAnimationFrame(() => {
(async function () {
if (!mediaSet) {
return;
}
const canvas = canvasRef.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);
// If the required position is 0, display the thumbnail instead of
// trying to render the video. The most important use case is before a
// click event has happened, when autoplay restrictions will prevent
// the video being rendered to canvas.
if (videoRef.current.currentTime == 0) {
const service = new MediaSetServiceClientImpl(newRPC());
const thumbnail = await service.GetVideoThumbnail({
id: mediaSet.id,
});
// TODO: avoid fetching the image every re-render:
const url = URL.createObjectURL(
new Blob([thumbnail.image], { type: 'image/jpeg' })
);
const img = new Image(thumbnail.width, thumbnail.height);
img.src = url;
img.onload = () => ctx.drawImage(img, 0, 0, 177, 100);
return;
}
// otherwise, render the video, which (should) work now.
const duration = millisFromDuration(mediaSet.videoDuration);
const durSecs = duration / 1000;
const ratio = videoRef.current.currentTime / durSecs;
const x = (canvas.width - 177) * ratio;
ctx.clearRect(0, 0, x, canvas.height);
ctx.clearRect(x + 177, 0, canvas.width - 177 - x, canvas.height);
ctx.drawImage(videoRef.current, x, 0, 177, 100);
})();
});
}, [mediaSet, videoRef.current.currentTime]);
// render component
return (
<>
<div className={`relative grow-0 h-[100px]`}>
<canvas
className="absolute block w-full h-full"
width="500"
height="100"
ref={canvasRef}
></canvas>
</div>
</>
);
};

View File

@ -24,7 +24,7 @@ export const SeekBar: React.FC<Props> = ({
onPositionChanged,
}: Props) => {
const [mode, setMode] = useState(Mode.Normal);
const [cursor, setCursor] = useState('cursor-auto');
const [cursor, setCursor] = useState('auto');
const canvasRef = useRef<HTMLCanvasElement>(null);
// render canvas
@ -45,11 +45,12 @@ export const SeekBar: React.FC<Props> = ({
canvas.width = canvas.height * (canvas.clientWidth / canvas.clientHeight);
// background
ctx.fillStyle = 'transparent';
ctx.fillStyle = '#444444';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// seek bar
const offset = offsetCanvas(canvas);
const pixelRatio = canvas.width / canvas.clientWidth;
const offset = offsetPixels * pixelRatio;
const width = canvas.width - offset * 2;
ctx.fillStyle = 'black';
ctx.fillRect(offset, InnerMargin, width, canvas.height - InnerMargin * 2);
@ -67,33 +68,23 @@ export const SeekBar: React.FC<Props> = ({
// helpers
const emitPositionEvent = (x: number, canvas: HTMLCanvasElement) => {
const emitPositionEvent = (evt: MouseEvent<HTMLCanvasElement>) => {
const canvas = evt.currentTarget;
const { x } = mouseEventToCanvasPoint(evt);
const pixelRatio = canvas.width / canvas.clientWidth;
const offset = offsetPixels * pixelRatio;
const ratio = (x - offset) / (canvas.width - offset * 2);
onPositionChanged(ratio * duration);
};
const offsetCanvas = (canvas: HTMLCanvasElement): number => {
return Math.round(offsetPixels * (canvas.width / canvas.clientWidth));
};
// handlers
const handleMouseDown = (evt: MouseEvent<HTMLCanvasElement>) => {
if (mode != Mode.Normal) return;
const canvas = evt.currentTarget;
const offset = offsetCanvas(canvas);
const { x } = mouseEventToCanvasPoint(evt);
if (x < offset || x > evt.currentTarget.width - offset) {
return;
}
setMode(Mode.Dragging);
emitPositionEvent(x, canvas);
emitPositionEvent(evt);
};
const handleMouseUp = () => {
@ -103,35 +94,18 @@ export const SeekBar: React.FC<Props> = ({
};
const handleMouseMove = (evt: MouseEvent<HTMLCanvasElement>) => {
const canvas = evt.currentTarget;
const offset = offsetCanvas(canvas);
const coords = mouseEventToCanvasPoint(evt);
const { y } = coords;
let { x } = coords;
const { y } = mouseEventToCanvasPoint(evt);
// TODO: improve mouse detection around knob.
if (
x >= offset &&
x < canvas.width - offset &&
y > InnerMargin &&
y < LogicalHeight - InnerMargin
) {
setCursor('cursor-pointer');
if (y > InnerMargin && y < LogicalHeight - InnerMargin) {
setCursor('pointer');
} else {
setCursor('cursor-auto');
}
if (x < offset) {
x = offset;
}
if (x > canvas.width - offset) {
x = canvas.width - offset;
setCursor('auto');
}
if (mode == Mode.Normal) return;
emitPositionEvent(x, canvas);
emitPositionEvent(evt);
};
const handleMouseEnter = () => {
@ -142,10 +116,17 @@ export const SeekBar: React.FC<Props> = ({
// render component
const styles = {
width: '100%',
height: '30px',
margin: '0 auto',
cursor: cursor,
};
return (
<>
<canvas
className={`w-full bg-gray-700 h-10 mx-0 my-auto ${cursor}`}
style={styles}
ref={canvasRef}
width={LogicalWidth}
height={LogicalHeight}

View File

@ -0,0 +1,106 @@
import { MediaSet, MediaSetServiceClientImpl } from './generated/media_set';
import { newRPC } from './App';
import { useEffect, useRef } from 'react';
interface Props {
mediaSet: MediaSet;
position: VideoPosition;
duration: number;
height: number;
video: HTMLVideoElement;
}
export const VideoPreview: React.FC<Props> = ({
mediaSet,
position,
duration,
height,
video,
}: Props) => {
const videoCanvasRef = useRef<HTMLCanvasElement>(null);
// effects
// render canvas
useEffect(() => {
// TODO: not sure if requestAnimationFrame is recommended here.
requestAnimationFrame(() => {
(async function () {
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);
// If the required position is 0, display the thumbnail instead of
// trying to render the video. The most important use case is before a
// click event has happened, when autoplay restrictions will prevent
// the video being rendered to canvas.
if (position.currentTime == 0) {
const service = new MediaSetServiceClientImpl(newRPC());
const thumbnail = await service.GetVideoThumbnail({
id: mediaSet.id,
});
// TODO: avoid fetching the image every re-render:
const url = URL.createObjectURL(
new Blob([thumbnail.image], { type: 'image/jpeg' })
);
const img = new Image(thumbnail.width, thumbnail.height);
img.src = url;
img.onload = () => ctx.drawImage(img, 0, 0, 177, 100);
return;
}
// otherwise, render the video, which (should) work now.
const durSecs = duration / 1000;
const ratio = position.currentTime / durSecs;
const x = (canvas.width - 177) * ratio;
ctx.clearRect(0, 0, x, canvas.height);
ctx.clearRect(x + 177, 0, canvas.width - 177 - x, canvas.height);
ctx.drawImage(video, x, 0, 177, 100);
})();
});
}, [mediaSet, position.currentTime]);
// render component
const containerStyles = {
height: height + 'px',
position: 'relative',
flexGrow: 0,
} as React.CSSProperties;
const canvasStyles = {
position: 'absolute',
width: '100%',
height: '100%',
display: 'block',
zIndex: 1,
} as React.CSSProperties;
return (
<>
<div style={containerStyles}>
<canvas
width="500"
height="100"
ref={videoCanvasRef}
style={canvasStyles}
></canvas>
<canvas style={canvasStyles}></canvas>
</div>
</>
);
};

161
frontend/src/Waveform.tsx Normal file
View File

@ -0,0 +1,161 @@
import { useEffect, useState, useCallback } from "react";
import { newRPC } from "./App";
import { MediaSetServiceClientImpl, MediaSet } from "./generated/media_set";
import { WaveformCanvas } from "./WaveformCanvas";
import { Selection, HudCanvas, EmptySelectionAction } from "./HudCanvas";
import { from, Observable } from "rxjs";
import { bufferCount } from "rxjs/operators";
interface Props {
mediaSet: MediaSet;
position: VideoPosition;
viewport: Frames;
offsetPixels: number;
onSelectionChange: (selection: Selection) => void;
}
export const CanvasLogicalWidth = 2000;
export const CanvasLogicalHeight = 500;
export const Waveform: React.FC<Props> = ({
mediaSet,
position,
viewport,
offsetPixels,
onSelectionChange,
}: Props) => {
const [peaks, setPeaks] = useState<Observable<number[]>>(from([]));
const [selectedFrames, setSelectedFrames] = useState({ start: 0, end: 0 });
const [selectedPixels, setSelectedPixels] = useState({
start: 0,
end: 0,
});
const [positionPixels, setPositionPixels] = useState<number | null>(0);
// effects
// load peaks on MediaSet change
useEffect(() => {
(async function () {
if (mediaSet == null) {
return;
}
if (viewport.start >= viewport.end) {
return;
}
console.log("fetch audio segment, frames", viewport);
const service = new MediaSetServiceClientImpl(newRPC());
const segment = await service.GetPeaksForSegment({
id: mediaSet.id,
numBins: CanvasLogicalWidth,
startFrame: viewport.start,
endFrame: viewport.end,
});
console.log("got segment", segment);
const peaks = from(segment.peaks).pipe(
bufferCount(mediaSet.audioChannels)
);
setPeaks(peaks);
})();
}, [viewport]);
// convert position to canvas pixels
useEffect(() => {
const frame = Math.round(position.currentTime * mediaSet.audioSampleRate);
if (frame < viewport.start || frame > viewport.end) {
setPositionPixels(null);
return;
}
const pixelsPerFrame = CanvasLogicalWidth / (viewport.end - viewport.start);
const positionPixels = (frame - viewport.start) * pixelsPerFrame;
setPositionPixels(positionPixels);
}, [mediaSet, position, viewport]);
// update selectedPixels on viewport change
useEffect(() => {
const start = Math.max(frameToCanvasX(selectedFrames.start), 0);
const end = Math.min(
frameToCanvasX(selectedFrames.end),
CanvasLogicalWidth
);
setSelectedPixels({ start, end });
}, [viewport, selectedFrames]);
// handlers
const handleSelectionChange = useCallback(
(selection: Selection) => {
setSelectedPixels(selection);
const framesPerPixel =
(viewport.end - viewport.start) / CanvasLogicalWidth;
const selectedFrames = {
start: Math.round(viewport.start + selection.start * framesPerPixel),
end: Math.round(viewport.start + selection.end * framesPerPixel),
};
setSelectedFrames(selectedFrames);
onSelectionChange(selectedFrames);
},
[viewport, selectedFrames]
);
// helpers
const frameToCanvasX = useCallback(
(frame: number): number => {
const pixelsPerFrame =
CanvasLogicalWidth / (viewport.end - viewport.start);
return Math.round((frame - viewport.start) * pixelsPerFrame);
},
[viewport]
);
// render component
const containerStyles = {
background: "black",
margin: "0 " + offsetPixels + "px",
flexGrow: 1,
position: "relative",
} as React.CSSProperties;
const hudStyles = {
borderLineWidth: 0,
borderStrokeStyle: "transparent",
positionLineWidth: 6,
positionStrokeStyle: "red",
};
return (
<>
<div style={containerStyles}>
<WaveformCanvas
peaks={peaks}
channels={mediaSet.audioChannels}
width={CanvasLogicalWidth}
height={CanvasLogicalHeight}
strokeStyle="green"
fillStyle="black"
zIndex={0}
alpha={1}
></WaveformCanvas>
<HudCanvas
width={CanvasLogicalWidth}
height={CanvasLogicalHeight}
zIndex={1}
emptySelectionAction={EmptySelectionAction.SelectNothing}
styles={hudStyles}
position={positionPixels}
selection={selectedPixels}
onSelectionChange={handleSelectionChange}
/>
</div>
</>
);
};

View File

@ -10,10 +10,18 @@ interface Props {
channels: number;
strokeStyle: string;
fillStyle: string;
zIndex: number;
alpha: number;
}
// Canvas is a generic component that renders a waveform to a canvas.
//
// Properties:
//
// peaks: a 2d array of uint16s representing the peak values. Each inner array length should match logicalWidth
// strokeStyle: waveform style
// fillStyle: background style
// style: React.CSSProperties applied to canvas element
const WaveformCanvas: React.FC<Props> = React.memo((props: Props) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
@ -63,13 +71,21 @@ const WaveformCanvas: React.FC<Props> = React.memo((props: Props) => {
})();
}, [props.peaks]);
const canvasStyles = {
display: 'block',
position: 'absolute',
width: '100%',
height: '100%',
zIndex: props.zIndex,
} as React.CSSProperties;
return (
<>
<canvas
ref={canvasRef}
className={`block absolute w-full h-full z-10`}
width={props.width}
height={props.height}
style={canvasStyles}
></canvas>
</>
);

View File

@ -0,0 +1,40 @@
import { ChangeEventHandler, MouseEventHandler, useState } from "react";
import { useNavigate } from "react-router-dom";
const extractVideoIDFromURL = (input: string): string | null => {
const { searchParams } = new URL(input);
return searchParams.get("v");
};
function HomePage(): JSX.Element {
const [input, setInput] = useState<string>("");
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
setInput(event.target.value);
};
const handleSubmit: MouseEventHandler<HTMLButtonElement> = () => {
try {
const videoId = extractVideoIDFromURL(input);
if (videoId === null) {
setError("URL not valid, please enter a valid YouTube URL");
} else {
navigate(`/video/${videoId}`);
}
} catch (err) {
setError("URL not valid, please enter a valid YouTube URL");
}
};
return (
<div>
<input value={input} onChange={handleChange} />
<button onClick={handleSubmit}>Submit</button>
{Boolean(error) && <div>{error}</div>}
</div>
);
}
export default HomePage;

View File

@ -0,0 +1,391 @@
import {
MediaSet,
MediaSetServiceClientImpl,
GetVideoProgress,
GetPeaksProgress,
} from '../generated/media_set';
import { useState, useEffect, useRef, useCallback } from 'react';
import { AudioFormat } from '../generated/media_set';
import { VideoPreview } from '../VideoPreview';
import { Overview, CanvasLogicalWidth } from '../Overview';
import { Waveform } from '../Waveform';
import { ControlBar } from '../ControlBar';
import { SeekBar } from '../SeekBar';
import { Duration } from '../generated/google/protobuf/duration';
import { firstValueFrom, from, Observable } from 'rxjs';
import { first, map } from 'rxjs/operators';
import { newRPC } from '../App';
import { useParams } from 'react-router-dom';
// ported from backend, where should they live?
const thumbnailWidth = 177;
const thumbnailHeight = 100;
const initialViewportCanvasPixels = 100;
// Frames represents a range of audio frames.
const video = document.createElement('video');
const audio = document.createElement('audio');
type VideoPageParams = {
videoId: string;
}
function VideoPage(): JSX.Element {
const [mediaSet, setMediaSet] = useState<MediaSet | null>(null);
const [viewport, setViewport] = useState<Frames>({ start: 0, end: 0 });
const [selection, setSelection] = useState<Frames>({ start: 0, end: 0 });
const [overviewPeaks, setOverviewPeaks] = useState<Observable<number[]>>(
from([])
);
const { videoId } = useParams<VideoPageParams>();
// position stores the current playback position. positionRef makes it
// available inside a setInterval callback.
const [position, setPosition] = useState({ currentTime: 0, percent: 0 });
const positionRef = useRef(position);
positionRef.current = position;
// effects
// TODO: error handling
if (videoId == null) {
return <></>;
}
// fetch mediaset on page load:
useEffect(() => {
(async function () {
const rpc = newRPC();
const service = new MediaSetServiceClientImpl(rpc);
const mediaSet = await service.Get({ youtubeId: videoId });
console.log('got media set:', mediaSet);
setMediaSet(mediaSet);
})();
}, []);
const updatePlayerPositionIntevalMillis = 30;
// setup player on first page load only:
useEffect(() => {
if (mediaSet == null) {
return;
}
const intervalID = setInterval(() => {
const currTime = audio.currentTime;
if (currTime == positionRef.current.currentTime) {
return;
}
const duration = mediaSet.audioFrames / mediaSet.audioSampleRate;
const percent = (currTime / duration) * 100;
// check if the end of selection has been passed, and pause if so:
if (
currentTimeToFrame(position.currentTime) < selection.end &&
currentTimeToFrame(currTime) >= selection.end
) {
handlePause();
}
// update the current position
setPosition({ currentTime: audio.currentTime, percent: percent });
}, updatePlayerPositionIntevalMillis);
return () => clearInterval(intervalID);
}, [mediaSet, selection]);
// bind to keypress handler.
// selection is a dependency of the handleKeyPress handler, and must be
// included here.
useEffect(() => {
document.addEventListener('keypress', handleKeyPress);
return () => document.removeEventListener('keypress', handleKeyPress);
}, [selection]);
// load audio when MediaSet is loaded:
useEffect(() => {
(async function () {
if (mediaSet == null) {
return;
}
console.log('fetching audio...');
const service = new MediaSetServiceClientImpl(newRPC());
const audioProgressStream = service.GetPeaks({
id: mediaSet.id,
numBins: CanvasLogicalWidth,
});
const peaks = audioProgressStream.pipe(map((progress) => progress.peaks));
setOverviewPeaks(peaks);
const pipe = audioProgressStream.pipe(
first((progress: GetPeaksProgress) => progress.url != '')
);
const progressWithURL = await firstValueFrom(pipe);
audio.src = progressWithURL.url;
audio.muted = false;
audio.volume = 1;
console.log('set audio src', progressWithURL.url);
})();
}, [mediaSet]);
// load video when MediaSet is loaded:
useEffect(() => {
(async function () {
if (mediaSet == null) {
return;
}
console.log('fetching video...');
const service = new MediaSetServiceClientImpl(newRPC());
const videoProgressStream = service.GetVideo({ id: mediaSet.id });
const pipe = videoProgressStream.pipe(
first((progress: GetVideoProgress) => progress.url != '')
);
const progressWithURL = await firstValueFrom(pipe);
video.src = progressWithURL.url;
console.log('set video src', progressWithURL.url);
})();
}, [mediaSet]);
// set viewport when MediaSet is loaded:
useEffect(() => {
if (mediaSet == null) {
return;
}
const numFrames = Math.min(
Math.round(mediaSet.audioFrames / CanvasLogicalWidth) *
initialViewportCanvasPixels,
mediaSet.audioFrames
);
setViewport({ start: 0, end: numFrames });
}, [mediaSet]);
useEffect(() => {
console.debug('viewport updated', viewport);
}, [viewport]);
// handlers
const handleKeyPress = useCallback(
(evt: KeyboardEvent) => {
if (evt.code != 'Space') {
return;
}
if (audio.paused) {
handlePlay();
} else {
handlePause();
}
},
[selection]
);
// handler called when the selection in the overview (zoom setting) is changed.
const handleOverviewSelectionChange = useCallback(
(newViewport: Frames) => {
if (mediaSet == null) {
return;
}
console.log('set new viewport', newViewport);
setViewport({ ...newViewport });
if (!audio.paused) {
return;
}
setPositionFromFrame(newViewport.start);
},
[mediaSet, audio, video, selection]
);
// handler called when the selection in the main waveform view is changed.
const handleWaveformSelectionChange = useCallback(
(newSelection: Frames) => {
setSelection(newSelection);
if (mediaSet == null) {
return;
}
// move playback position to start of selection
const ratio = newSelection.start / mediaSet.audioFrames;
const currentTime =
(mediaSet.audioFrames / mediaSet.audioSampleRate) * ratio;
audio.currentTime = currentTime;
video.currentTime = currentTime;
},
[mediaSet, audio, video, selection]
);
const handlePlay = useCallback(() => {
audio.play();
video.play();
}, [audio, video]);
const handlePause = useCallback(() => {
video.pause();
audio.pause();
if (selection.start != selection.end) {
setPositionFromFrame(selection.start);
}
}, [audio, video, selection]);
const handleClip = useCallback(() => {
(async function () {
console.debug('clip', selection);
if (mediaSet == null) {
return;
}
// TODO: support File System Access API fallback
const h = await window.showSaveFilePicker({ suggestedName: 'clip.mp3' });
const fileStream = await h.createWritable();
const rpc = newRPC();
const service = new MediaSetServiceClientImpl(rpc);
const stream = service.GetAudioSegment({
id: mediaSet.id,
format: AudioFormat.MP3,
startFrame: selection.start,
endFrame: selection.end,
});
await stream.forEach((p) => fileStream.write(p.audioData));
console.debug('finished writing stream');
await fileStream.close();
console.debug('closed stream');
})();
}, [mediaSet, selection]);
const setPositionFromFrame = useCallback(
(frame: number) => {
if (mediaSet == null) {
return;
}
const ratio = frame / mediaSet.audioFrames;
const currentTime =
(mediaSet.audioFrames / mediaSet.audioSampleRate) * ratio;
audio.currentTime = currentTime;
video.currentTime = currentTime;
},
[mediaSet, audio, video]
);
// helpers
const currentTimeToFrame = useCallback(
(currentTime: number): number => {
if (mediaSet == null) {
return 0;
}
const dur = mediaSet.audioFrames / mediaSet.audioSampleRate;
const ratio = currentTime / dur;
return Math.round(mediaSet.audioFrames * ratio);
},
[mediaSet]
);
// render component
const containerStyles = {
border: '1px solid black',
width: '90%',
margin: '1em auto',
minHeight: '500px',
height: '700px',
display: 'flex',
flexDirection: 'column',
} as React.CSSProperties;
const offsetPixels = Math.floor(thumbnailWidth / 2);
if (mediaSet == null) {
// TODO: improve
return <></>;
}
return (
<div className="App">
<div style={containerStyles}>
<ControlBar
onPlay={handlePlay}
onPause={handlePause}
onClip={handleClip}
/>
<Overview
peaks={overviewPeaks}
mediaSet={mediaSet}
offsetPixels={offsetPixels}
height={80}
viewport={viewport}
position={position}
onSelectionChange={handleOverviewSelectionChange}
/>
<Waveform
mediaSet={mediaSet}
position={position}
viewport={viewport}
offsetPixels={offsetPixels}
onSelectionChange={handleWaveformSelectionChange}
/>
<SeekBar
position={video.currentTime}
duration={mediaSet.audioFrames / mediaSet.audioSampleRate}
offsetPixels={offsetPixels}
onPositionChanged={(position: number) => {
video.currentTime = position;
audio.currentTime = position;
}}
/>
<VideoPreview
mediaSet={mediaSet}
video={video}
position={position}
duration={millisFromDuration(mediaSet.videoDuration)}
height={thumbnailHeight}
/>
</div>
<ul style={{ listStyleType: 'none' } as React.CSSProperties}>
<li>Frames: {mediaSet.audioFrames}</li>
<li>
Viewport (frames): {viewport.start} to {viewport.end}
</li>
<li>
Selection (frames): {selection.start} to {selection.end}
</li>
<li>
Position (frames):{' '}
{Math.round(mediaSet.audioFrames * (position.percent / 100))}
</li>
<li>Position (seconds): {position.currentTime}</li>
<li></li>
</ul>
</div>
);
}
export default VideoPage;
function millisFromDuration(dur?: Duration): number {
if (dur == undefined) {
return 0;
}
return Math.floor(dur.seconds * 1000.0 + dur.nanos / 1000.0 / 1000.0);
}

View File

@ -82,9 +82,7 @@ export interface Duration {
nanos: number;
}
function createBaseDuration(): Duration {
return { seconds: 0, nanos: 0 };
}
const baseDuration: object = { seconds: 0, nanos: 0 };
export const Duration = {
encode(
@ -103,7 +101,7 @@ export const Duration = {
decode(input: _m0.Reader | Uint8Array, length?: number): Duration {
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseDuration();
const message = { ...baseDuration } as Duration;
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
@ -122,22 +120,27 @@ export const Duration = {
},
fromJSON(object: any): Duration {
return {
seconds: isSet(object.seconds) ? Number(object.seconds) : 0,
nanos: isSet(object.nanos) ? Number(object.nanos) : 0,
};
const message = { ...baseDuration } as Duration;
message.seconds =
object.seconds !== undefined && object.seconds !== null
? Number(object.seconds)
: 0;
message.nanos =
object.nanos !== undefined && object.nanos !== null
? Number(object.nanos)
: 0;
return message;
},
toJSON(message: Duration): unknown {
const obj: any = {};
message.seconds !== undefined &&
(obj.seconds = Math.round(message.seconds));
message.nanos !== undefined && (obj.nanos = Math.round(message.nanos));
message.seconds !== undefined && (obj.seconds = message.seconds);
message.nanos !== undefined && (obj.nanos = message.nanos);
return obj;
},
fromPartial<I extends Exact<DeepPartial<Duration>, I>>(object: I): Duration {
const message = createBaseDuration();
const message = { ...baseDuration } as Duration;
message.seconds = object.seconds ?? 0;
message.nanos = object.nanos ?? 0;
return message;
@ -193,7 +196,3 @@ if (_m0.util.Long !== Long) {
_m0.util.Long = Long as any;
_m0.configure();
}
function isSet(value: any): boolean {
return value !== null && value !== undefined;
}

View File

@ -44,9 +44,6 @@ export function audioFormatToJSON(object: AudioFormat): string {
export interface MediaSet {
id: string;
youtubeId: string;
title: string;
description: string;
author: string;
audioChannels: number;
audioApproxFrames: number;
audioFrames: number;
@ -71,7 +68,6 @@ export interface GetPeaksProgress {
peaks: number[];
percentComplete: number;
url: string;
audioFrames: number;
}
export interface GetPeaksForSegmentRequest {
@ -93,6 +89,8 @@ export interface GetAudioSegmentRequest {
}
export interface GetAudioSegmentProgress {
mimeType: string;
message: string;
percentComplete: number;
audioData: Uint8Array;
}
@ -116,24 +114,18 @@ export interface GetVideoThumbnailResponse {
height: number;
}
function createBaseMediaSet(): MediaSet {
return {
id: "",
youtubeId: "",
title: "",
description: "",
author: "",
audioChannels: 0,
audioApproxFrames: 0,
audioFrames: 0,
audioSampleRate: 0,
audioYoutubeItag: 0,
audioMimeType: "",
videoDuration: undefined,
videoYoutubeItag: 0,
videoMimeType: "",
};
}
const baseMediaSet: object = {
id: "",
youtubeId: "",
audioChannels: 0,
audioApproxFrames: 0,
audioFrames: 0,
audioSampleRate: 0,
audioYoutubeItag: 0,
audioMimeType: "",
videoYoutubeItag: 0,
videoMimeType: "",
};
export const MediaSet = {
encode(
@ -146,15 +138,6 @@ export const MediaSet = {
if (message.youtubeId !== "") {
writer.uint32(18).string(message.youtubeId);
}
if (message.title !== "") {
writer.uint32(98).string(message.title);
}
if (message.description !== "") {
writer.uint32(106).string(message.description);
}
if (message.author !== "") {
writer.uint32(114).string(message.author);
}
if (message.audioChannels !== 0) {
writer.uint32(24).int32(message.audioChannels);
}
@ -188,7 +171,7 @@ export const MediaSet = {
decode(input: _m0.Reader | Uint8Array, length?: number): MediaSet {
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseMediaSet();
const message = { ...baseMediaSet } as MediaSet;
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
@ -198,15 +181,6 @@ export const MediaSet = {
case 2:
message.youtubeId = reader.string();
break;
case 12:
message.title = reader.string();
break;
case 13:
message.description = reader.string();
break;
case 14:
message.author = reader.string();
break;
case 3:
message.audioChannels = reader.int32();
break;
@ -243,58 +217,67 @@ export const MediaSet = {
},
fromJSON(object: any): MediaSet {
return {
id: isSet(object.id) ? String(object.id) : "",
youtubeId: isSet(object.youtubeId) ? String(object.youtubeId) : "",
title: isSet(object.title) ? String(object.title) : "",
description: isSet(object.description) ? String(object.description) : "",
author: isSet(object.author) ? String(object.author) : "",
audioChannels: isSet(object.audioChannels)
const message = { ...baseMediaSet } as MediaSet;
message.id =
object.id !== undefined && object.id !== null ? String(object.id) : "";
message.youtubeId =
object.youtubeId !== undefined && object.youtubeId !== null
? String(object.youtubeId)
: "";
message.audioChannels =
object.audioChannels !== undefined && object.audioChannels !== null
? Number(object.audioChannels)
: 0,
audioApproxFrames: isSet(object.audioApproxFrames)
: 0;
message.audioApproxFrames =
object.audioApproxFrames !== undefined &&
object.audioApproxFrames !== null
? Number(object.audioApproxFrames)
: 0,
audioFrames: isSet(object.audioFrames) ? Number(object.audioFrames) : 0,
audioSampleRate: isSet(object.audioSampleRate)
: 0;
message.audioFrames =
object.audioFrames !== undefined && object.audioFrames !== null
? Number(object.audioFrames)
: 0;
message.audioSampleRate =
object.audioSampleRate !== undefined && object.audioSampleRate !== null
? Number(object.audioSampleRate)
: 0,
audioYoutubeItag: isSet(object.audioYoutubeItag)
: 0;
message.audioYoutubeItag =
object.audioYoutubeItag !== undefined && object.audioYoutubeItag !== null
? Number(object.audioYoutubeItag)
: 0,
audioMimeType: isSet(object.audioMimeType)
: 0;
message.audioMimeType =
object.audioMimeType !== undefined && object.audioMimeType !== null
? String(object.audioMimeType)
: "",
videoDuration: isSet(object.videoDuration)
: "";
message.videoDuration =
object.videoDuration !== undefined && object.videoDuration !== null
? Duration.fromJSON(object.videoDuration)
: undefined,
videoYoutubeItag: isSet(object.videoYoutubeItag)
: undefined;
message.videoYoutubeItag =
object.videoYoutubeItag !== undefined && object.videoYoutubeItag !== null
? Number(object.videoYoutubeItag)
: 0,
videoMimeType: isSet(object.videoMimeType)
: 0;
message.videoMimeType =
object.videoMimeType !== undefined && object.videoMimeType !== null
? String(object.videoMimeType)
: "",
};
: "";
return message;
},
toJSON(message: MediaSet): unknown {
const obj: any = {};
message.id !== undefined && (obj.id = message.id);
message.youtubeId !== undefined && (obj.youtubeId = message.youtubeId);
message.title !== undefined && (obj.title = message.title);
message.description !== undefined &&
(obj.description = message.description);
message.author !== undefined && (obj.author = message.author);
message.audioChannels !== undefined &&
(obj.audioChannels = Math.round(message.audioChannels));
(obj.audioChannels = message.audioChannels);
message.audioApproxFrames !== undefined &&
(obj.audioApproxFrames = Math.round(message.audioApproxFrames));
(obj.audioApproxFrames = message.audioApproxFrames);
message.audioFrames !== undefined &&
(obj.audioFrames = Math.round(message.audioFrames));
(obj.audioFrames = message.audioFrames);
message.audioSampleRate !== undefined &&
(obj.audioSampleRate = Math.round(message.audioSampleRate));
(obj.audioSampleRate = message.audioSampleRate);
message.audioYoutubeItag !== undefined &&
(obj.audioYoutubeItag = Math.round(message.audioYoutubeItag));
(obj.audioYoutubeItag = message.audioYoutubeItag);
message.audioMimeType !== undefined &&
(obj.audioMimeType = message.audioMimeType);
message.videoDuration !== undefined &&
@ -302,19 +285,16 @@ export const MediaSet = {
? Duration.toJSON(message.videoDuration)
: undefined);
message.videoYoutubeItag !== undefined &&
(obj.videoYoutubeItag = Math.round(message.videoYoutubeItag));
(obj.videoYoutubeItag = message.videoYoutubeItag);
message.videoMimeType !== undefined &&
(obj.videoMimeType = message.videoMimeType);
return obj;
},
fromPartial<I extends Exact<DeepPartial<MediaSet>, I>>(object: I): MediaSet {
const message = createBaseMediaSet();
const message = { ...baseMediaSet } as MediaSet;
message.id = object.id ?? "";
message.youtubeId = object.youtubeId ?? "";
message.title = object.title ?? "";
message.description = object.description ?? "";
message.author = object.author ?? "";
message.audioChannels = object.audioChannels ?? 0;
message.audioApproxFrames = object.audioApproxFrames ?? 0;
message.audioFrames = object.audioFrames ?? 0;
@ -331,9 +311,7 @@ export const MediaSet = {
},
};
function createBaseGetRequest(): GetRequest {
return { youtubeId: "" };
}
const baseGetRequest: object = { youtubeId: "" };
export const GetRequest = {
encode(
@ -349,7 +327,7 @@ export const GetRequest = {
decode(input: _m0.Reader | Uint8Array, length?: number): GetRequest {
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseGetRequest();
const message = { ...baseGetRequest } as GetRequest;
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
@ -365,9 +343,12 @@ export const GetRequest = {
},
fromJSON(object: any): GetRequest {
return {
youtubeId: isSet(object.youtubeId) ? String(object.youtubeId) : "",
};
const message = { ...baseGetRequest } as GetRequest;
message.youtubeId =
object.youtubeId !== undefined && object.youtubeId !== null
? String(object.youtubeId)
: "";
return message;
},
toJSON(message: GetRequest): unknown {
@ -379,15 +360,13 @@ export const GetRequest = {
fromPartial<I extends Exact<DeepPartial<GetRequest>, I>>(
object: I
): GetRequest {
const message = createBaseGetRequest();
const message = { ...baseGetRequest } as GetRequest;
message.youtubeId = object.youtubeId ?? "";
return message;
},
};
function createBaseGetPeaksRequest(): GetPeaksRequest {
return { id: "", numBins: 0 };
}
const baseGetPeaksRequest: object = { id: "", numBins: 0 };
export const GetPeaksRequest = {
encode(
@ -406,7 +385,7 @@ export const GetPeaksRequest = {
decode(input: _m0.Reader | Uint8Array, length?: number): GetPeaksRequest {
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseGetPeaksRequest();
const message = { ...baseGetPeaksRequest } as GetPeaksRequest;
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
@ -425,33 +404,34 @@ export const GetPeaksRequest = {
},
fromJSON(object: any): GetPeaksRequest {
return {
id: isSet(object.id) ? String(object.id) : "",
numBins: isSet(object.numBins) ? Number(object.numBins) : 0,
};
const message = { ...baseGetPeaksRequest } as GetPeaksRequest;
message.id =
object.id !== undefined && object.id !== null ? String(object.id) : "";
message.numBins =
object.numBins !== undefined && object.numBins !== null
? Number(object.numBins)
: 0;
return message;
},
toJSON(message: GetPeaksRequest): unknown {
const obj: any = {};
message.id !== undefined && (obj.id = message.id);
message.numBins !== undefined &&
(obj.numBins = Math.round(message.numBins));
message.numBins !== undefined && (obj.numBins = message.numBins);
return obj;
},
fromPartial<I extends Exact<DeepPartial<GetPeaksRequest>, I>>(
object: I
): GetPeaksRequest {
const message = createBaseGetPeaksRequest();
const message = { ...baseGetPeaksRequest } as GetPeaksRequest;
message.id = object.id ?? "";
message.numBins = object.numBins ?? 0;
return message;
},
};
function createBaseGetPeaksProgress(): GetPeaksProgress {
return { peaks: [], percentComplete: 0, url: "", audioFrames: 0 };
}
const baseGetPeaksProgress: object = { peaks: 0, percentComplete: 0, url: "" };
export const GetPeaksProgress = {
encode(
@ -469,16 +449,14 @@ export const GetPeaksProgress = {
if (message.url !== "") {
writer.uint32(26).string(message.url);
}
if (message.audioFrames !== 0) {
writer.uint32(32).int64(message.audioFrames);
}
return writer;
},
decode(input: _m0.Reader | Uint8Array, length?: number): GetPeaksProgress {
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseGetPeaksProgress();
const message = { ...baseGetPeaksProgress } as GetPeaksProgress;
message.peaks = [];
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
@ -498,9 +476,6 @@ export const GetPeaksProgress = {
case 3:
message.url = reader.string();
break;
case 4:
message.audioFrames = longToNumber(reader.int64() as Long);
break;
default:
reader.skipType(tag & 7);
break;
@ -510,48 +485,47 @@ export const GetPeaksProgress = {
},
fromJSON(object: any): GetPeaksProgress {
return {
peaks: Array.isArray(object?.peaks)
? object.peaks.map((e: any) => Number(e))
: [],
percentComplete: isSet(object.percentComplete)
const message = { ...baseGetPeaksProgress } as GetPeaksProgress;
message.peaks = (object.peaks ?? []).map((e: any) => Number(e));
message.percentComplete =
object.percentComplete !== undefined && object.percentComplete !== null
? Number(object.percentComplete)
: 0,
url: isSet(object.url) ? String(object.url) : "",
audioFrames: isSet(object.audioFrames) ? Number(object.audioFrames) : 0,
};
: 0;
message.url =
object.url !== undefined && object.url !== null ? String(object.url) : "";
return message;
},
toJSON(message: GetPeaksProgress): unknown {
const obj: any = {};
if (message.peaks) {
obj.peaks = message.peaks.map((e) => Math.round(e));
obj.peaks = message.peaks.map((e) => e);
} else {
obj.peaks = [];
}
message.percentComplete !== undefined &&
(obj.percentComplete = message.percentComplete);
message.url !== undefined && (obj.url = message.url);
message.audioFrames !== undefined &&
(obj.audioFrames = Math.round(message.audioFrames));
return obj;
},
fromPartial<I extends Exact<DeepPartial<GetPeaksProgress>, I>>(
object: I
): GetPeaksProgress {
const message = createBaseGetPeaksProgress();
const message = { ...baseGetPeaksProgress } as GetPeaksProgress;
message.peaks = object.peaks?.map((e) => e) || [];
message.percentComplete = object.percentComplete ?? 0;
message.url = object.url ?? "";
message.audioFrames = object.audioFrames ?? 0;
return message;
},
};
function createBaseGetPeaksForSegmentRequest(): GetPeaksForSegmentRequest {
return { id: "", numBins: 0, startFrame: 0, endFrame: 0 };
}
const baseGetPeaksForSegmentRequest: object = {
id: "",
numBins: 0,
startFrame: 0,
endFrame: 0,
};
export const GetPeaksForSegmentRequest = {
encode(
@ -579,7 +553,9 @@ export const GetPeaksForSegmentRequest = {
): GetPeaksForSegmentRequest {
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseGetPeaksForSegmentRequest();
const message = {
...baseGetPeaksForSegmentRequest,
} as GetPeaksForSegmentRequest;
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
@ -604,30 +580,41 @@ export const GetPeaksForSegmentRequest = {
},
fromJSON(object: any): GetPeaksForSegmentRequest {
return {
id: isSet(object.id) ? String(object.id) : "",
numBins: isSet(object.numBins) ? Number(object.numBins) : 0,
startFrame: isSet(object.startFrame) ? Number(object.startFrame) : 0,
endFrame: isSet(object.endFrame) ? Number(object.endFrame) : 0,
};
const message = {
...baseGetPeaksForSegmentRequest,
} as GetPeaksForSegmentRequest;
message.id =
object.id !== undefined && object.id !== null ? String(object.id) : "";
message.numBins =
object.numBins !== undefined && object.numBins !== null
? Number(object.numBins)
: 0;
message.startFrame =
object.startFrame !== undefined && object.startFrame !== null
? Number(object.startFrame)
: 0;
message.endFrame =
object.endFrame !== undefined && object.endFrame !== null
? Number(object.endFrame)
: 0;
return message;
},
toJSON(message: GetPeaksForSegmentRequest): unknown {
const obj: any = {};
message.id !== undefined && (obj.id = message.id);
message.numBins !== undefined &&
(obj.numBins = Math.round(message.numBins));
message.startFrame !== undefined &&
(obj.startFrame = Math.round(message.startFrame));
message.endFrame !== undefined &&
(obj.endFrame = Math.round(message.endFrame));
message.numBins !== undefined && (obj.numBins = message.numBins);
message.startFrame !== undefined && (obj.startFrame = message.startFrame);
message.endFrame !== undefined && (obj.endFrame = message.endFrame);
return obj;
},
fromPartial<I extends Exact<DeepPartial<GetPeaksForSegmentRequest>, I>>(
object: I
): GetPeaksForSegmentRequest {
const message = createBaseGetPeaksForSegmentRequest();
const message = {
...baseGetPeaksForSegmentRequest,
} as GetPeaksForSegmentRequest;
message.id = object.id ?? "";
message.numBins = object.numBins ?? 0;
message.startFrame = object.startFrame ?? 0;
@ -636,9 +623,7 @@ export const GetPeaksForSegmentRequest = {
},
};
function createBaseGetPeaksForSegmentResponse(): GetPeaksForSegmentResponse {
return { peaks: [] };
}
const baseGetPeaksForSegmentResponse: object = { peaks: 0 };
export const GetPeaksForSegmentResponse = {
encode(
@ -659,7 +644,10 @@ export const GetPeaksForSegmentResponse = {
): GetPeaksForSegmentResponse {
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseGetPeaksForSegmentResponse();
const message = {
...baseGetPeaksForSegmentResponse,
} as GetPeaksForSegmentResponse;
message.peaks = [];
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
@ -682,17 +670,17 @@ export const GetPeaksForSegmentResponse = {
},
fromJSON(object: any): GetPeaksForSegmentResponse {
return {
peaks: Array.isArray(object?.peaks)
? object.peaks.map((e: any) => Number(e))
: [],
};
const message = {
...baseGetPeaksForSegmentResponse,
} as GetPeaksForSegmentResponse;
message.peaks = (object.peaks ?? []).map((e: any) => Number(e));
return message;
},
toJSON(message: GetPeaksForSegmentResponse): unknown {
const obj: any = {};
if (message.peaks) {
obj.peaks = message.peaks.map((e) => Math.round(e));
obj.peaks = message.peaks.map((e) => e);
} else {
obj.peaks = [];
}
@ -702,15 +690,20 @@ export const GetPeaksForSegmentResponse = {
fromPartial<I extends Exact<DeepPartial<GetPeaksForSegmentResponse>, I>>(
object: I
): GetPeaksForSegmentResponse {
const message = createBaseGetPeaksForSegmentResponse();
const message = {
...baseGetPeaksForSegmentResponse,
} as GetPeaksForSegmentResponse;
message.peaks = object.peaks?.map((e) => e) || [];
return message;
},
};
function createBaseGetAudioSegmentRequest(): GetAudioSegmentRequest {
return { id: "", startFrame: 0, endFrame: 0, format: 0 };
}
const baseGetAudioSegmentRequest: object = {
id: "",
startFrame: 0,
endFrame: 0,
format: 0,
};
export const GetAudioSegmentRequest = {
encode(
@ -738,7 +731,7 @@ export const GetAudioSegmentRequest = {
): GetAudioSegmentRequest {
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseGetAudioSegmentRequest();
const message = { ...baseGetAudioSegmentRequest } as GetAudioSegmentRequest;
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
@ -763,21 +756,29 @@ export const GetAudioSegmentRequest = {
},
fromJSON(object: any): GetAudioSegmentRequest {
return {
id: isSet(object.id) ? String(object.id) : "",
startFrame: isSet(object.startFrame) ? Number(object.startFrame) : 0,
endFrame: isSet(object.endFrame) ? Number(object.endFrame) : 0,
format: isSet(object.format) ? audioFormatFromJSON(object.format) : 0,
};
const message = { ...baseGetAudioSegmentRequest } as GetAudioSegmentRequest;
message.id =
object.id !== undefined && object.id !== null ? String(object.id) : "";
message.startFrame =
object.startFrame !== undefined && object.startFrame !== null
? Number(object.startFrame)
: 0;
message.endFrame =
object.endFrame !== undefined && object.endFrame !== null
? Number(object.endFrame)
: 0;
message.format =
object.format !== undefined && object.format !== null
? audioFormatFromJSON(object.format)
: 0;
return message;
},
toJSON(message: GetAudioSegmentRequest): unknown {
const obj: any = {};
message.id !== undefined && (obj.id = message.id);
message.startFrame !== undefined &&
(obj.startFrame = Math.round(message.startFrame));
message.endFrame !== undefined &&
(obj.endFrame = Math.round(message.endFrame));
message.startFrame !== undefined && (obj.startFrame = message.startFrame);
message.endFrame !== undefined && (obj.endFrame = message.endFrame);
message.format !== undefined &&
(obj.format = audioFormatToJSON(message.format));
return obj;
@ -786,7 +787,7 @@ export const GetAudioSegmentRequest = {
fromPartial<I extends Exact<DeepPartial<GetAudioSegmentRequest>, I>>(
object: I
): GetAudioSegmentRequest {
const message = createBaseGetAudioSegmentRequest();
const message = { ...baseGetAudioSegmentRequest } as GetAudioSegmentRequest;
message.id = object.id ?? "";
message.startFrame = object.startFrame ?? 0;
message.endFrame = object.endFrame ?? 0;
@ -795,15 +796,23 @@ export const GetAudioSegmentRequest = {
},
};
function createBaseGetAudioSegmentProgress(): GetAudioSegmentProgress {
return { percentComplete: 0, audioData: new Uint8Array() };
}
const baseGetAudioSegmentProgress: object = {
mimeType: "",
message: "",
percentComplete: 0,
};
export const GetAudioSegmentProgress = {
encode(
message: GetAudioSegmentProgress,
writer: _m0.Writer = _m0.Writer.create()
): _m0.Writer {
if (message.mimeType !== "") {
writer.uint32(10).string(message.mimeType);
}
if (message.message !== "") {
writer.uint32(18).string(message.message);
}
if (message.percentComplete !== 0) {
writer.uint32(29).float(message.percentComplete);
}
@ -819,10 +828,19 @@ export const GetAudioSegmentProgress = {
): GetAudioSegmentProgress {
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseGetAudioSegmentProgress();
const message = {
...baseGetAudioSegmentProgress,
} as GetAudioSegmentProgress;
message.audioData = new Uint8Array();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1:
message.mimeType = reader.string();
break;
case 2:
message.message = reader.string();
break;
case 3:
message.percentComplete = reader.float();
break;
@ -838,18 +856,32 @@ export const GetAudioSegmentProgress = {
},
fromJSON(object: any): GetAudioSegmentProgress {
return {
percentComplete: isSet(object.percentComplete)
const message = {
...baseGetAudioSegmentProgress,
} as GetAudioSegmentProgress;
message.mimeType =
object.mimeType !== undefined && object.mimeType !== null
? String(object.mimeType)
: "";
message.message =
object.message !== undefined && object.message !== null
? String(object.message)
: "";
message.percentComplete =
object.percentComplete !== undefined && object.percentComplete !== null
? Number(object.percentComplete)
: 0,
audioData: isSet(object.audioData)
: 0;
message.audioData =
object.audioData !== undefined && object.audioData !== null
? bytesFromBase64(object.audioData)
: new Uint8Array(),
};
: new Uint8Array();
return message;
},
toJSON(message: GetAudioSegmentProgress): unknown {
const obj: any = {};
message.mimeType !== undefined && (obj.mimeType = message.mimeType);
message.message !== undefined && (obj.message = message.message);
message.percentComplete !== undefined &&
(obj.percentComplete = message.percentComplete);
message.audioData !== undefined &&
@ -862,16 +894,18 @@ export const GetAudioSegmentProgress = {
fromPartial<I extends Exact<DeepPartial<GetAudioSegmentProgress>, I>>(
object: I
): GetAudioSegmentProgress {
const message = createBaseGetAudioSegmentProgress();
const message = {
...baseGetAudioSegmentProgress,
} as GetAudioSegmentProgress;
message.mimeType = object.mimeType ?? "";
message.message = object.message ?? "";
message.percentComplete = object.percentComplete ?? 0;
message.audioData = object.audioData ?? new Uint8Array();
return message;
},
};
function createBaseGetVideoRequest(): GetVideoRequest {
return { id: "" };
}
const baseGetVideoRequest: object = { id: "" };
export const GetVideoRequest = {
encode(
@ -887,7 +921,7 @@ export const GetVideoRequest = {
decode(input: _m0.Reader | Uint8Array, length?: number): GetVideoRequest {
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseGetVideoRequest();
const message = { ...baseGetVideoRequest } as GetVideoRequest;
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
@ -903,9 +937,10 @@ export const GetVideoRequest = {
},
fromJSON(object: any): GetVideoRequest {
return {
id: isSet(object.id) ? String(object.id) : "",
};
const message = { ...baseGetVideoRequest } as GetVideoRequest;
message.id =
object.id !== undefined && object.id !== null ? String(object.id) : "";
return message;
},
toJSON(message: GetVideoRequest): unknown {
@ -917,15 +952,13 @@ export const GetVideoRequest = {
fromPartial<I extends Exact<DeepPartial<GetVideoRequest>, I>>(
object: I
): GetVideoRequest {
const message = createBaseGetVideoRequest();
const message = { ...baseGetVideoRequest } as GetVideoRequest;
message.id = object.id ?? "";
return message;
},
};
function createBaseGetVideoProgress(): GetVideoProgress {
return { percentComplete: 0, url: "" };
}
const baseGetVideoProgress: object = { percentComplete: 0, url: "" };
export const GetVideoProgress = {
encode(
@ -944,7 +977,7 @@ export const GetVideoProgress = {
decode(input: _m0.Reader | Uint8Array, length?: number): GetVideoProgress {
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseGetVideoProgress();
const message = { ...baseGetVideoProgress } as GetVideoProgress;
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
@ -963,12 +996,14 @@ export const GetVideoProgress = {
},
fromJSON(object: any): GetVideoProgress {
return {
percentComplete: isSet(object.percentComplete)
const message = { ...baseGetVideoProgress } as GetVideoProgress;
message.percentComplete =
object.percentComplete !== undefined && object.percentComplete !== null
? Number(object.percentComplete)
: 0,
url: isSet(object.url) ? String(object.url) : "",
};
: 0;
message.url =
object.url !== undefined && object.url !== null ? String(object.url) : "";
return message;
},
toJSON(message: GetVideoProgress): unknown {
@ -982,16 +1017,14 @@ export const GetVideoProgress = {
fromPartial<I extends Exact<DeepPartial<GetVideoProgress>, I>>(
object: I
): GetVideoProgress {
const message = createBaseGetVideoProgress();
const message = { ...baseGetVideoProgress } as GetVideoProgress;
message.percentComplete = object.percentComplete ?? 0;
message.url = object.url ?? "";
return message;
},
};
function createBaseGetVideoThumbnailRequest(): GetVideoThumbnailRequest {
return { id: "" };
}
const baseGetVideoThumbnailRequest: object = { id: "" };
export const GetVideoThumbnailRequest = {
encode(
@ -1010,7 +1043,9 @@ export const GetVideoThumbnailRequest = {
): GetVideoThumbnailRequest {
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseGetVideoThumbnailRequest();
const message = {
...baseGetVideoThumbnailRequest,
} as GetVideoThumbnailRequest;
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
@ -1026,9 +1061,12 @@ export const GetVideoThumbnailRequest = {
},
fromJSON(object: any): GetVideoThumbnailRequest {
return {
id: isSet(object.id) ? String(object.id) : "",
};
const message = {
...baseGetVideoThumbnailRequest,
} as GetVideoThumbnailRequest;
message.id =
object.id !== undefined && object.id !== null ? String(object.id) : "";
return message;
},
toJSON(message: GetVideoThumbnailRequest): unknown {
@ -1040,15 +1078,15 @@ export const GetVideoThumbnailRequest = {
fromPartial<I extends Exact<DeepPartial<GetVideoThumbnailRequest>, I>>(
object: I
): GetVideoThumbnailRequest {
const message = createBaseGetVideoThumbnailRequest();
const message = {
...baseGetVideoThumbnailRequest,
} as GetVideoThumbnailRequest;
message.id = object.id ?? "";
return message;
},
};
function createBaseGetVideoThumbnailResponse(): GetVideoThumbnailResponse {
return { image: new Uint8Array(), width: 0, height: 0 };
}
const baseGetVideoThumbnailResponse: object = { width: 0, height: 0 };
export const GetVideoThumbnailResponse = {
encode(
@ -1073,7 +1111,10 @@ export const GetVideoThumbnailResponse = {
): GetVideoThumbnailResponse {
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseGetVideoThumbnailResponse();
const message = {
...baseGetVideoThumbnailResponse,
} as GetVideoThumbnailResponse;
message.image = new Uint8Array();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
@ -1095,13 +1136,22 @@ export const GetVideoThumbnailResponse = {
},
fromJSON(object: any): GetVideoThumbnailResponse {
return {
image: isSet(object.image)
const message = {
...baseGetVideoThumbnailResponse,
} as GetVideoThumbnailResponse;
message.image =
object.image !== undefined && object.image !== null
? bytesFromBase64(object.image)
: new Uint8Array(),
width: isSet(object.width) ? Number(object.width) : 0,
height: isSet(object.height) ? Number(object.height) : 0,
};
: new Uint8Array();
message.width =
object.width !== undefined && object.width !== null
? Number(object.width)
: 0;
message.height =
object.height !== undefined && object.height !== null
? Number(object.height)
: 0;
return message;
},
toJSON(message: GetVideoThumbnailResponse): unknown {
@ -1110,15 +1160,17 @@ export const GetVideoThumbnailResponse = {
(obj.image = base64FromBytes(
message.image !== undefined ? message.image : new Uint8Array()
));
message.width !== undefined && (obj.width = Math.round(message.width));
message.height !== undefined && (obj.height = Math.round(message.height));
message.width !== undefined && (obj.width = message.width);
message.height !== undefined && (obj.height = message.height);
return obj;
},
fromPartial<I extends Exact<DeepPartial<GetVideoThumbnailResponse>, I>>(
object: I
): GetVideoThumbnailResponse {
const message = createBaseGetVideoThumbnailResponse();
const message = {
...baseGetVideoThumbnailResponse,
} as GetVideoThumbnailResponse;
message.image = object.image ?? new Uint8Array();
message.width = object.width ?? 0;
message.height = object.height ?? 0;
@ -1560,7 +1612,3 @@ if (_m0.util.Long !== Long) {
_m0.util.Long = Long as any;
_m0.configure();
}
function isSet(value: any): boolean {
return value !== null && value !== undefined;
}

View File

@ -13,13 +13,7 @@
var jspb = require('google-protobuf');
var goog = jspb;
var global = (function() {
if (this) { return this; }
if (typeof window !== 'undefined') { return window; }
if (typeof global !== 'undefined') { return global; }
if (typeof self !== 'undefined') { return self; }
return Function('return this')();
}.call(null));
var global = Function('return this')();
var google_protobuf_duration_pb = require('google-protobuf/google/protobuf/duration_pb.js');
goog.object.extend(proto, google_protobuf_duration_pb);
@ -322,9 +316,6 @@ proto.media_set.MediaSet.toObject = function(includeInstance, msg) {
var f, obj = {
id: jspb.Message.getFieldWithDefault(msg, 1, ""),
youtubeId: jspb.Message.getFieldWithDefault(msg, 2, ""),
title: jspb.Message.getFieldWithDefault(msg, 12, ""),
description: jspb.Message.getFieldWithDefault(msg, 13, ""),
author: jspb.Message.getFieldWithDefault(msg, 14, ""),
audioChannels: jspb.Message.getFieldWithDefault(msg, 3, 0),
audioApproxFrames: jspb.Message.getFieldWithDefault(msg, 4, 0),
audioFrames: jspb.Message.getFieldWithDefault(msg, 5, 0),
@ -378,18 +369,6 @@ proto.media_set.MediaSet.deserializeBinaryFromReader = function(msg, reader) {
var value = /** @type {string} */ (reader.readString());
msg.setYoutubeId(value);
break;
case 12:
var value = /** @type {string} */ (reader.readString());
msg.setTitle(value);
break;
case 13:
var value = /** @type {string} */ (reader.readString());
msg.setDescription(value);
break;
case 14:
var value = /** @type {string} */ (reader.readString());
msg.setAuthor(value);
break;
case 3:
var value = /** @type {number} */ (reader.readInt32());
msg.setAudioChannels(value);
@ -470,27 +449,6 @@ proto.media_set.MediaSet.serializeBinaryToWriter = function(message, writer) {
f
);
}
f = message.getTitle();
if (f.length > 0) {
writer.writeString(
12,
f
);
}
f = message.getDescription();
if (f.length > 0) {
writer.writeString(
13,
f
);
}
f = message.getAuthor();
if (f.length > 0) {
writer.writeString(
14,
f
);
}
f = message.getAudioChannels();
if (f !== 0) {
writer.writeInt32(
@ -594,60 +552,6 @@ proto.media_set.MediaSet.prototype.setYoutubeId = function(value) {
};
/**
* optional string title = 12;
* @return {string}
*/
proto.media_set.MediaSet.prototype.getTitle = function() {
return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 12, ""));
};
/**
* @param {string} value
* @return {!proto.media_set.MediaSet} returns this
*/
proto.media_set.MediaSet.prototype.setTitle = function(value) {
return jspb.Message.setProto3StringField(this, 12, value);
};
/**
* optional string description = 13;
* @return {string}
*/
proto.media_set.MediaSet.prototype.getDescription = function() {
return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 13, ""));
};
/**
* @param {string} value
* @return {!proto.media_set.MediaSet} returns this
*/
proto.media_set.MediaSet.prototype.setDescription = function(value) {
return jspb.Message.setProto3StringField(this, 13, value);
};
/**
* optional string author = 14;
* @return {string}
*/
proto.media_set.MediaSet.prototype.getAuthor = function() {
return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 14, ""));
};
/**
* @param {string} value
* @return {!proto.media_set.MediaSet} returns this
*/
proto.media_set.MediaSet.prototype.setAuthor = function(value) {
return jspb.Message.setProto3StringField(this, 14, value);
};
/**
* optional int32 audio_channels = 3;
* @return {number}
@ -1160,8 +1064,7 @@ proto.media_set.GetPeaksProgress.toObject = function(includeInstance, msg) {
var f, obj = {
peaksList: (f = jspb.Message.getRepeatedField(msg, 1)) == null ? undefined : f,
percentComplete: jspb.Message.getFloatingPointFieldWithDefault(msg, 2, 0.0),
url: jspb.Message.getFieldWithDefault(msg, 3, ""),
audioFrames: jspb.Message.getFieldWithDefault(msg, 4, 0)
url: jspb.Message.getFieldWithDefault(msg, 3, "")
};
if (includeInstance) {
@ -1212,10 +1115,6 @@ proto.media_set.GetPeaksProgress.deserializeBinaryFromReader = function(msg, rea
var value = /** @type {string} */ (reader.readString());
msg.setUrl(value);
break;
case 4:
var value = /** @type {number} */ (reader.readInt64());
msg.setAudioFrames(value);
break;
default:
reader.skipField();
break;
@ -1266,13 +1165,6 @@ proto.media_set.GetPeaksProgress.serializeBinaryToWriter = function(message, wri
f
);
}
f = message.getAudioFrames();
if (f !== 0) {
writer.writeInt64(
4,
f
);
}
};
@ -1349,24 +1241,6 @@ proto.media_set.GetPeaksProgress.prototype.setUrl = function(value) {
};
/**
* optional int64 audio_frames = 4;
* @return {number}
*/
proto.media_set.GetPeaksProgress.prototype.getAudioFrames = function() {
return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 4, 0));
};
/**
* @param {number} value
* @return {!proto.media_set.GetPeaksProgress} returns this
*/
proto.media_set.GetPeaksProgress.prototype.setAudioFrames = function(value) {
return jspb.Message.setProto3IntField(this, 4, value);
};
@ -1997,6 +1871,8 @@ proto.media_set.GetAudioSegmentProgress.prototype.toObject = function(opt_includ
*/
proto.media_set.GetAudioSegmentProgress.toObject = function(includeInstance, msg) {
var f, obj = {
mimeType: jspb.Message.getFieldWithDefault(msg, 1, ""),
message: jspb.Message.getFieldWithDefault(msg, 2, ""),
percentComplete: jspb.Message.getFloatingPointFieldWithDefault(msg, 3, 0.0),
audioData: msg.getAudioData_asB64()
};
@ -2035,6 +1911,14 @@ proto.media_set.GetAudioSegmentProgress.deserializeBinaryFromReader = function(m
}
var field = reader.getFieldNumber();
switch (field) {
case 1:
var value = /** @type {string} */ (reader.readString());
msg.setMimeType(value);
break;
case 2:
var value = /** @type {string} */ (reader.readString());
msg.setMessage(value);
break;
case 3:
var value = /** @type {number} */ (reader.readFloat());
msg.setPercentComplete(value);
@ -2072,6 +1956,20 @@ proto.media_set.GetAudioSegmentProgress.prototype.serializeBinary = function() {
*/
proto.media_set.GetAudioSegmentProgress.serializeBinaryToWriter = function(message, writer) {
var f = undefined;
f = message.getMimeType();
if (f.length > 0) {
writer.writeString(
1,
f
);
}
f = message.getMessage();
if (f.length > 0) {
writer.writeString(
2,
f
);
}
f = message.getPercentComplete();
if (f !== 0.0) {
writer.writeFloat(
@ -2089,6 +1987,42 @@ proto.media_set.GetAudioSegmentProgress.serializeBinaryToWriter = function(messa
};
/**
* optional string mime_type = 1;
* @return {string}
*/
proto.media_set.GetAudioSegmentProgress.prototype.getMimeType = function() {
return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 1, ""));
};
/**
* @param {string} value
* @return {!proto.media_set.GetAudioSegmentProgress} returns this
*/
proto.media_set.GetAudioSegmentProgress.prototype.setMimeType = function(value) {
return jspb.Message.setProto3StringField(this, 1, value);
};
/**
* optional string message = 2;
* @return {string}
*/
proto.media_set.GetAudioSegmentProgress.prototype.getMessage = function() {
return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 2, ""));
};
/**
* @param {string} value
* @return {!proto.media_set.GetAudioSegmentProgress} returns this
*/
proto.media_set.GetAudioSegmentProgress.prototype.setMessage = function(value) {
return jspb.Message.setProto3StringField(this, 2, value);
};
/**
* optional float percent_complete = 3;
* @return {number}

View File

@ -1,15 +0,0 @@
import constrainNumeric from './constrainNumeric';
describe('constrainNumeric', () => {
it('constrains the value when it is less than 0', () => {
expect(constrainNumeric(-1, 10)).toEqual(0);
});
it('constrains the value when it is greater than max', () => {
expect(constrainNumeric(11, 10)).toEqual(10);
});
it('does not constrain an acceptable value', () => {
expect(constrainNumeric(3, 10)).toEqual(3);
});
});

View File

@ -1,11 +0,0 @@
function constrainNumeric(x: number, max: number): number {
if (x < 0) {
return 0;
}
if (x > max) {
return max;
}
return x;
}
export default constrainNumeric;

View File

@ -1,18 +0,0 @@
import frameToWaveformCanvasX from './frameToWaveformCanvasX';
describe('frameToWaveformCanvasX', () => {
it('returns null when the frame is before the viewport', () => {
const x = frameToWaveformCanvasX(100, { start: 200, end: 300 }, 2000);
expect(x).toBeNull();
});
it('returns null when the frame is after the viewport', () => {
const x = frameToWaveformCanvasX(400, { start: 200, end: 300 }, 2000);
expect(x).toBeNull();
});
it('returns the expected coordinate when the frame is inside the viewport', () => {
const x = frameToWaveformCanvasX(251, { start: 200, end: 300 }, 2000);
expect(x).toEqual(1020);
});
});

View File

@ -1,16 +0,0 @@
import { FrameRange } from '../AppState';
function frameToWaveformCanvasX(
frame: number,
viewport: FrameRange,
canvasWidth: number
): number | null {
if (frame < viewport.start || frame > viewport.end) {
return null;
}
const pixelsPerFrame = canvasWidth / (viewport.end - viewport.start);
return (frame - viewport.start) * pixelsPerFrame;
}
export default frameToWaveformCanvasX;

View File

@ -1,29 +0,0 @@
import framesToDuration from './framesToDuration';
describe('framesToDuration', () => {
it('returns the expected result for 0 frames at 44100hz', () => {
expect(framesToDuration(0, 44100)).toEqual({ seconds: 0, nanos: 0 });
});
it('returns the expected result for 44100 frames at 44100hz', () => {
expect(framesToDuration(44100, 44100)).toEqual({ seconds: 1, nanos: 0 });
});
it('returns the expected result for 88200 frames at 44100hz', () => {
expect(framesToDuration(88200, 44100)).toEqual({ seconds: 2, nanos: 0 });
});
it('returns the expected result for 88201 frames at 44100hz', () => {
expect(framesToDuration(88201, 44100)).toEqual({
seconds: 2,
nanos: 22675,
});
});
it('returns the expected result for 110250 frames at 44100hz', () => {
expect(framesToDuration(110250, 44100)).toEqual({
seconds: 2,
nanos: 500_000_000,
});
});
});

View File

@ -1,11 +0,0 @@
import { Duration } from '../generated/google/protobuf/duration';
function framesToDuration(frames: number, sampleRate: number): Duration {
const secs = Math.floor(frames / sampleRate);
const nanos = Math.floor(
((frames % sampleRate) / sampleRate) * 1_000_000_000
);
return { seconds: secs, nanos: nanos };
}
export default framesToDuration;

View File

@ -1,13 +0,0 @@
import millisFromDuration from "./millisFromDuration";
import { Duration } from '../generated/google/protobuf/duration';
describe('millisFromDuration', () => {
it('returns 0 if duration is not passed', () => {
expect(millisFromDuration()).toEqual(0);
});
it('correctly returns the ms when the duration has both seconds and nanos', () => {
const duration: Duration = { seconds: 34, nanos: 549_875_992 };
expect(millisFromDuration(duration)).toEqual(34_549);
});
});

View File

@ -1,10 +0,0 @@
import { Duration } from '../generated/google/protobuf/duration';
function millisFromDuration(dur?: Duration): number {
if (dur == undefined) {
return 0;
}
return Math.floor(dur.seconds * 1000.0 + dur.nanos / 1000.0 / 1000.0);
}
export default millisFromDuration;

View File

@ -1,49 +0,0 @@
import toHHMMSS from './toHHMMSS';
import { Duration } from '../generated/google/protobuf/duration';
describe('toHHMMSS', () => {
it('renders correctly for 0ms', () => {
const duration: Duration = { seconds: 0, nanos: 0 };
expect(toHHMMSS(duration)).toEqual('00:00');
});
it('renders correctly for 500ms', () => {
const duration: Duration = { seconds: 0, nanos: 500_000_000 };
expect(toHHMMSS(duration)).toEqual('00:00');
});
it('renders correctly for 2700ms', () => {
const duration: Duration = { seconds: 27, nanos: 0 };
expect(toHHMMSS(duration)).toEqual('00:27');
});
it('renders correctly for 61s', () => {
const duration: Duration = { seconds: 61, nanos: 0 };
expect(toHHMMSS(duration)).toEqual('01:01');
});
it('renders correctly for 1200s', () => {
const duration: Duration = { seconds: 1200, nanos: 0 };
expect(toHHMMSS(duration)).toEqual('20:00');
});
it('renders correctly for 1201s', () => {
const duration: Duration = { seconds: 1201, nanos: 0 };
expect(toHHMMSS(duration)).toEqual('20:01');
});
it('renders correctly for 1h', () => {
const duration: Duration = { seconds: 3600, nanos: 0 };
expect(toHHMMSS(duration)).toEqual('01:00:00');
});
it('renders correctly for 1h1m1s', () => {
const duration: Duration = { seconds: 3661, nanos: 0 };
expect(toHHMMSS(duration)).toEqual('01:01:01');
});
it('renders correctly for 24h1s', () => {
const duration: Duration = { seconds: 86401, nanos: 0 };
expect(toHHMMSS(duration)).toEqual('24:00:01');
});
});

View File

@ -1,17 +0,0 @@
import { Duration } from '../generated/google/protobuf/duration';
import millisFromDuration from './millisFromDuration';
function toHHMMSS(dur: Duration): string {
const millis = millisFromDuration(dur);
let secs = Math.floor(millis / 1_000);
const hrs = Math.floor(secs / 3600);
const mins = Math.floor(secs / 60) % 60;
secs = secs % 60;
return [hrs, mins, secs]
.map((v) => (v < 10 ? '0' + v : v))
.filter((v, i) => v != '00' || i > 0)
.join(':');
}
export default toHHMMSS;

View File

@ -1,273 +0,0 @@
import {
zoomViewportIn,
zoomViewportOut,
canZoomViewportIn,
canZoomViewportOut,
} from './zoom';
// zf is the zoom factor.
const zf = 2;
const emptySelection = { start: 0, end: 0 };
describe('zoomViewportIn', () => {
describe('when viewport start and end is equal', () => {
it('returns the same viewport', () => {
const newViewport = zoomViewportIn(
{ start: 100, end: 100 },
500,
emptySelection,
0,
zf
);
expect(newViewport).toEqual({ start: 100, end: 100 });
});
});
describe('with nothing selected', () => {
it('centres the zoom on the playback position if possible', () => {
const newViewport = zoomViewportIn(
{ start: 100_000, end: 200_000 },
500_000,
emptySelection,
50_000,
zf
);
expect(newViewport).toEqual({ start: 25_000, end: 75_000 });
});
it('offsets the new viewport if it overlaps the viewport minimum', () => {
const newViewport = zoomViewportIn(
{ start: 100_000, end: 200_000 },
500_000,
emptySelection,
0,
zf
);
expect(newViewport).toEqual({ start: 0, end: 50_000 });
});
it('offsets the new viewport if it overlaps the viewport maximum', () => {
const newViewport = zoomViewportIn(
{ start: 100_000, end: 200_000 },
500_000,
emptySelection,
490_000,
zf
);
expect(newViewport).toEqual({ start: 450_000, end: 500_000 });
});
});
describe('with an active selection', () => {
it('centres the new viewport on the selection if possible', () => {
const newViewport = zoomViewportIn(
{ start: 100_000, end: 200_000 },
500_000,
{ start: 120_000, end: 140_000 },
0,
zf
);
expect(newViewport).toEqual({ start: 105_000, end: 155_000 });
});
it('offsets the new viewport if it overlaps the viewport minimum', () => {
const newViewport = zoomViewportIn(
{ start: 100_000, end: 200_000 },
500_000,
{ start: 10_000, end: 20_000 },
0,
zf
);
expect(newViewport).toEqual({ start: 0, end: 50_000 });
});
it('offsets the new viewport if it overlaps the viewport maximum', () => {
const newViewport = zoomViewportIn(
{ start: 100_000, end: 200_000 },
500_000,
{ start: 480_000, end: 490_000 },
0,
zf
);
expect(newViewport).toEqual({ start: 450_000, end: 500_000 });
});
describe('when zooming beyond the selection', () => {
it('disallows the zoom', () => {
const newViewport = zoomViewportIn(
{ start: 100_000, end: 200_000 },
500_000,
{ start: 110_000, end: 190_000 },
0,
zf
);
expect(newViewport).toEqual({ start: 100_000, end: 200_000 });
});
});
});
});
describe('zoomViewportOut', () => {
describe('when viewport start and end is equal', () => {
it('returns the same viewport', () => {
const newViewport = zoomViewportOut(
{ start: 100, end: 100 },
500,
emptySelection,
0,
zf
);
expect(newViewport).toEqual({ start: 100, end: 100 });
});
});
describe('with nothing selected', () => {
it('centres the zoom on the playback position if possible', () => {
const newViewport = zoomViewportOut(
{ start: 190_000, end: 210_000 },
500_000,
emptySelection,
170_000,
zf
);
expect(newViewport).toEqual({ start: 150_000, end: 190_000 });
});
it('offsets the new viewport if it overlaps the viewport minimum', () => {
const newViewport = zoomViewportOut(
{ start: 190_000, end: 210_000 },
500_000,
emptySelection,
10_000,
zf
);
expect(newViewport).toEqual({ start: 0, end: 40_000 });
});
it('offsets the new viewport if it overlaps the viewport maximum', () => {
const newViewport = zoomViewportOut(
{ start: 190_000, end: 210_000 },
500_000,
emptySelection,
485_000,
zf
);
expect(newViewport).toEqual({ start: 460_000, end: 500_000 });
});
it('refuses to zoom out beyond the available limits', () => {
const newViewport = zoomViewportOut(
{ start: 10_000, end: 490_000 },
500_000,
emptySelection,
200_000,
zf
);
expect(newViewport).toEqual({ start: 0, end: 500_000 });
});
});
describe('with an active selection', () => {
it('centres the new viewport on the selection if possible', () => {
const newViewport = zoomViewportOut(
{ start: 150_000, end: 170_000 },
500_000,
{ start: 120_000, end: 140_000 },
0,
zf
);
expect(newViewport).toEqual({ start: 110_000, end: 150_000 });
});
it('offsets the new viewport if it overlaps the viewport minimum', () => {
const newViewport = zoomViewportOut(
{ start: 190_000, end: 210_000 },
500_000,
{ start: 10_000, end: 20_000 },
10_000,
zf
);
expect(newViewport).toEqual({ start: 0, end: 40_000 });
});
it('offsets the new viewport if it overlaps the viewport minimum', () => {
const newViewport = zoomViewportOut(
{ start: 190_000, end: 210_000 },
500_000,
{ start: 495_000, end: 500_000 },
0,
zf
);
expect(newViewport).toEqual({ start: 460_000, end: 500_000 });
});
it('refuses to zoom out beyond the available limits', () => {
const newViewport = zoomViewportOut(
{ start: 10_000, end: 490_000 },
500_000,
{ start: 20_000, end: 480_000 },
0,
zf
);
expect(newViewport).toEqual({ start: 0, end: 500_000 });
});
});
});
describe('canZoomViewportIn', () => {
it('does now allow zooming when the viewport is inactive', () => {
const result = canZoomViewportIn(
{ start: 0, end: 0 },
{ start: 0, end: 0 },
zf
);
expect(result).toBeFalsy();
});
it('does not allow zooming past the selection', () => {
const result = canZoomViewportIn(
{ start: 1000, end: 2000 },
{ start: 1100, end: 1900 },
zf
);
expect(result).toBeFalsy();
});
it('allows zooming', () => {
const result = canZoomViewportIn(
{ start: 1000, end: 2000 },
{ start: 0, end: 100 },
zf
);
expect(result).toBeTruthy();
});
});
describe('canZoomViewportOut', () => {
it('does now allow zooming when the viewport is inactive', () => {
const result = canZoomViewportOut({ start: 0, end: 0 }, 1000);
expect(result).toBeFalsy();
});
it('does now allow zooming when already at maximum zoom', () => {
const result = canZoomViewportOut({ start: 0, end: 1000 }, 1000);
expect(result).toBeFalsy();
});
it('allows zooming', () => {
const result = canZoomViewportOut({ start: 1000, end: 2000 }, 5000);
expect(result).toBeTruthy();
});
});

View File

@ -1,104 +0,0 @@
import { Frames } from '../App';
export function zoomViewportIn(
viewport: Frames,
numFrames: number,
selection: Frames,
position: number,
factor: number
): Frames {
if (!canZoomViewportIn(viewport, selection, factor)) {
return viewport;
}
return zoom(
Math.round((viewport.end - viewport.start) / factor),
viewport,
numFrames,
selection,
position
);
}
export function zoomViewportOut(
viewport: Frames,
numFrames: number,
selection: Frames,
position: number,
factor: number
): Frames {
if (!canZoomViewportOut(viewport, numFrames)) {
return viewport;
}
return zoom(
Math.round((viewport.end - viewport.start) * factor),
viewport,
numFrames,
selection,
position
);
}
function zoom(
newWidth: number,
viewport: Frames,
numFrames: number,
selection: Frames,
position: number
): Frames {
let newStart;
if (selection.start != selection.end) {
const selectionWidth = selection.end - selection.start;
// disallow zooming beyond the selection:
if (newWidth < selectionWidth) {
return viewport;
}
const selectionMidpoint = selection.end - selectionWidth / 2;
newStart = selectionMidpoint - Math.round(newWidth / 2);
} else {
newStart = position - newWidth / 2;
}
if (newStart < 0) {
newStart = 0;
}
let newEnd = newStart + newWidth;
if (newEnd > numFrames) {
newEnd = numFrames;
newStart = Math.max(0, newEnd - newWidth);
}
return { start: newStart, end: newEnd };
}
export function canZoomViewportIn(
viewport: Frames,
selection: Frames,
factor: number
): boolean {
if (viewport.start == viewport.end) {
return false;
}
if (selection.start != selection.end) {
const newWidth = Math.round((viewport.end - viewport.start) / factor);
return newWidth > selection.end - selection.start;
}
return true;
}
export function canZoomViewportOut(
viewport: Frames,
numFrames: number
): boolean {
if (viewport.start == viewport.end) {
return false;
}
return viewport.start > 0 || viewport.end < numFrames;
}

View File

@ -1,7 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',

9
frontend/src/types.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
interface Frames {
start: number;
end: number;
}
interface VideoPosition {
currentTime: number;
percent: number;
}

View File

@ -1,7 +0,0 @@
module.exports = {
content: ['./src/**/*.{js,jsx,ts,tsx}'],
theme: {
extend: {},
},
plugins: [],
};

File diff suppressed because it is too large Load Diff

View File

@ -10,9 +10,6 @@ import "google/protobuf/duration.proto";
message MediaSet {
string id = 1;
string youtube_id = 2;
string title = 12;
string description = 13;
string author = 14;
int32 audio_channels = 3;
int64 audio_approx_frames = 4;
@ -39,7 +36,6 @@ message GetPeaksProgress {
repeated int32 peaks = 1;
float percent_complete = 2;
string url = 3;
int64 audio_frames = 4;
}
message GetPeaksForSegmentRequest {