Compare commits
67 Commits
feature/yo
...
main
Author | SHA1 | Date |
---|---|---|
Rob Watson | 36bd92608a | |
Rob Watson | dbcc5a0cf6 | |
Rob Watson | e434ad9a84 | |
Rob Watson | e40e794721 | |
Rob Watson | 691099da3a | |
Rob Watson | 7eb53417ac | |
Rob Watson | 26b51b8c93 | |
Rob Watson | f2d7d1f5bb | |
Rob Watson | 669afcf6d9 | |
Rob Watson | 29129afe90 | |
Rob Watson | a0bb48fb69 | |
Rob Watson | a8ba36a0e1 | |
Rob Watson | 54e9bc0d2c | |
Rob Watson | 6dde29cdcf | |
Rob Watson | a9ea462b41 | |
Rob Watson | 3dcc1edc62 | |
Rob Watson | b64f0b4daa | |
Rob Watson | 9f76d2764f | |
Rob Watson | a855d589f3 | |
Rob Watson | 6ba19b3e01 | |
Rob Watson | bff15098e6 | |
Rob Watson | cf90100c5f | |
Rob Watson | 698b97e904 | |
Rob Watson | 404c11909b | |
Rob Watson | 5a1ebb7c3a | |
Rob Watson | 48c84a7efa | |
Rob Watson | 5af8f0c319 | |
Rob Watson | 4f443af8fa | |
Rob Watson | 9ae4335b19 | |
Rob Watson | a4e9ebca3b | |
Rob Watson | f386e12f72 | |
Rob Watson | bb3366ac9a | |
Rob Watson | 41fe0ce2b1 | |
Rob Watson | aa80c9eb7e | |
Rob Watson | 9d90ed51e6 | |
Rob Watson | a33057651d | |
Rob Watson | ec3ac8996d | |
Rob Watson | d988a99f78 | |
Rob Watson | 58bbc06e6f | |
Rob Watson | fbbb2e2fda | |
Rob Watson | d136e00c59 | |
Rob Watson | aa4d235c0c | |
Rob Watson | d8173cdace | |
Rob Watson | ed964cb58f | |
Rob Watson | f33fa149fc | |
Rob Watson | 35b62f1e59 | |
Rob Watson | aabd0f3252 | |
Rob Watson | 5e27c3db9a | |
Rob Watson | b0ccf17527 | |
Rob Watson | af0674eb11 | |
Rob Watson | c7d5541379 | |
Rob Watson | 8a26b75127 | |
Rob Watson | 2377477188 | |
Rob Watson | 8e9a4cf8c3 | |
Rob Watson | 04601bab2e | |
Rob Watson | 06fce9af95 | |
Rob Watson | 5a4ee4e34f | |
Rob Watson | 33ee9645e7 | |
Rob Watson | 6cb462f769 | |
Rob Watson | 932648a44b | |
Rob Watson | 12e6e73976 | |
Rob Watson | 66c65694ae | |
Rob Watson | 176a1cd8c1 | |
Rob Watson | a063f85eca | |
Michael Evans | 959f5f0a2d | |
Michael Evans | 335efb23e1 | |
Michael Evans | 22dd92f339 |
|
@ -1,17 +1,16 @@
|
|||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
type: kubernetes
|
||||
name: default
|
||||
|
||||
steps:
|
||||
- name: backend
|
||||
image: golang:1.17
|
||||
- name: backend-go1.19
|
||||
image: golang:1.19
|
||||
commands:
|
||||
- cd backend/
|
||||
- go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||
- go build ./...
|
||||
- go vet ./...
|
||||
- staticcheck ./...
|
||||
# - go run honnef.co/go/tools/cmd/staticcheck@latest ./...
|
||||
- go test -bench=. -benchmem -cover ./...
|
||||
|
||||
- name: frontend
|
||||
|
|
|
@ -11,7 +11,7 @@ ENV REACT_APP_API_URL=$API_URL
|
|||
RUN yarn install
|
||||
RUN yarn build
|
||||
|
||||
FROM golang:1.17.3-alpine3.14 as go-builder
|
||||
FROM golang:1.18beta1-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 ASSETS_HTTP_ROOT "/app/assets"
|
||||
ENV CLIPPER_ASSETS_HTTP_ROOT "/app/assets"
|
||||
|
||||
ENTRYPOINT ["/bin/clipper"]
|
||||
|
|
|
@ -1,30 +1,39 @@
|
|||
ENV=development # or production
|
||||
CLIPPER_ENV=development # or production
|
||||
|
||||
BIND_ADDR=localhost:8888
|
||||
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=
|
||||
|
||||
# PostgreSQL connection string.
|
||||
DATABASE_URL=
|
||||
CLIPPER_DATABASE_URL=
|
||||
|
||||
# Optional. If set, files in this location will be served over HTTP at /.
|
||||
# Mostly useful for deployment.
|
||||
ASSETS_HTTP_ROOT=
|
||||
CLIPPER_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.
|
||||
FILE_STORE=filesystem
|
||||
|
||||
CLIPPER_FILE_STORE=filesystem
|
||||
|
||||
# The base URL used for serving file store assets.
|
||||
# Example: http://localhost:8888
|
||||
FILE_STORE_HTTP_BASE_URL=
|
||||
CLIPPER_FILE_STORE_HTTP_BASE_URL=
|
||||
|
||||
# The root directory for the file system store.
|
||||
FILE_STORE_HTTP_ROOT=data/
|
||||
CLIPPER_FILE_STORE_HTTP_ROOT=data/
|
||||
|
||||
# AWS credentials, required for the S3 store.
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_REGION=
|
||||
S3_BUCKET=
|
||||
CLIPPER_S3_BUCKET=
|
||||
|
||||
# The number of concurrent FFMPEG processes that will be permitted.
|
||||
# Defaults to runtime.NumCPU():
|
||||
CLIPPER_FFMPEG_WORKER_POOL_SIZE=
|
||||
|
|
|
@ -21,6 +21,7 @@ import (
|
|||
const (
|
||||
defaultTimeout = 600 * time.Second
|
||||
defaultURLExpiry = time.Hour
|
||||
maximumWorkerQueueSize = 32
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
@ -54,12 +55,17 @@ 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,
|
||||
}))
|
||||
}
|
||||
|
|
|
@ -3,7 +3,11 @@ package config
|
|||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Environment int
|
||||
|
@ -16,10 +20,12 @@ const (
|
|||
type FileStore int
|
||||
|
||||
const (
|
||||
FileSystemStore = iota
|
||||
FileSystemStore FileStore = iota
|
||||
S3Store
|
||||
)
|
||||
|
||||
const DefaultBindAddr = "localhost:8888"
|
||||
|
||||
type Config struct {
|
||||
Environment Environment
|
||||
BindAddr string
|
||||
|
@ -28,58 +34,73 @@ type Config struct {
|
|||
DatabaseURL string
|
||||
FileStore FileStore
|
||||
FileStoreHTTPRoot string
|
||||
FileStoreHTTPBaseURL string
|
||||
FileStoreHTTPBaseURL *url.URL
|
||||
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) {
|
||||
envString := os.Getenv("ENV")
|
||||
envVarName := envPrefix("ENV")
|
||||
envString := os.Getenv(envVarName)
|
||||
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 ENV value: %s", envString)
|
||||
return Config{}, fmt.Errorf("invalid %s value: %s", envVarName, envString)
|
||||
}
|
||||
|
||||
bindAddr := os.Getenv("BIND_ADDR")
|
||||
bindAddr := getenvPrefix("BIND_ADDR")
|
||||
if bindAddr == "" {
|
||||
bindAddr = "localhost:8888"
|
||||
bindAddr = DefaultBindAddr
|
||||
}
|
||||
|
||||
tlsCertFile := os.Getenv("TLS_CERT_FILE")
|
||||
tlsKeyFile := os.Getenv("TLS_KEY_FILE")
|
||||
tlsCertFileName := envPrefix("TLS_CERT_FILE")
|
||||
tlsKeyFileName := envPrefix("TLS_KEY_FILE")
|
||||
tlsCertFile := os.Getenv(tlsCertFileName)
|
||||
tlsKeyFile := os.Getenv(tlsKeyFileName)
|
||||
if (tlsCertFile == "" && tlsKeyFile != "") || (tlsCertFile != "" && tlsKeyFile == "") {
|
||||
return Config{}, errors.New("both TLS_CERT_FILE and TLS_KEY_FILE must be set")
|
||||
return Config{}, fmt.Errorf("both %s and %s must be set", tlsCertFileName, tlsKeyFileName)
|
||||
}
|
||||
|
||||
databaseURL := os.Getenv("DATABASE_URL")
|
||||
databaseURLName := envPrefix("DATABASE_URL")
|
||||
databaseURL := os.Getenv(databaseURLName)
|
||||
if databaseURL == "" {
|
||||
return Config{}, errors.New("DATABASE_URL not set")
|
||||
return Config{}, fmt.Errorf("%s not set", databaseURLName)
|
||||
}
|
||||
|
||||
fileStoreString := os.Getenv("FILE_STORE")
|
||||
fileStoreName := envPrefix("FILE_STORE")
|
||||
fileStoreString := os.Getenv(fileStoreName)
|
||||
var fileStore FileStore
|
||||
switch os.Getenv("FILE_STORE") {
|
||||
switch getenvPrefix("FILE_STORE") {
|
||||
case "s3":
|
||||
fileStore = S3Store
|
||||
case "filesystem", "":
|
||||
fileStore = FileSystemStore
|
||||
default:
|
||||
return Config{}, fmt.Errorf("invalid FILE_STORE value: %s", fileStoreString)
|
||||
return Config{}, fmt.Errorf("invalid %s value: %s", fileStoreName, fileStoreString)
|
||||
}
|
||||
|
||||
fileStoreHTTPBaseURL := os.Getenv("FILE_STORE_HTTP_BASE_URL")
|
||||
if fileStoreHTTPBaseURL == "" {
|
||||
fileStoreHTTPBaseURL = "/"
|
||||
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)
|
||||
}
|
||||
|
||||
var awsAccessKeyID, awsSecretAccessKey, awsRegion, s3Bucket, fileStoreHTTPRoot string
|
||||
|
@ -99,17 +120,33 @@ func NewFromEnv() (Config, error) {
|
|||
return Config{}, errors.New("AWS_REGION not set")
|
||||
}
|
||||
|
||||
s3Bucket = os.Getenv("S3_BUCKET")
|
||||
s3Bucket = getenvPrefix("S3_BUCKET")
|
||||
if s3Bucket == "" {
|
||||
return Config{}, errors.New("S3_BUCKET not set")
|
||||
}
|
||||
} else {
|
||||
if fileStoreHTTPRoot = os.Getenv("FILE_STORE_HTTP_ROOT"); fileStoreHTTPRoot == "" {
|
||||
if fileStoreHTTPRoot = getenvPrefix("FILE_STORE_HTTP_ROOT"); fileStoreHTTPRoot == "" {
|
||||
return Config{}, errors.New("FILE_STORE_HTTP_ROOT not set")
|
||||
}
|
||||
}
|
||||
|
||||
assetsHTTPRoot := os.Getenv("ASSETS_HTTP_ROOT")
|
||||
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, ",")
|
||||
}
|
||||
|
||||
return Config{
|
||||
Environment: env,
|
||||
|
@ -125,5 +162,7 @@ func NewFromEnv() (Config, error) {
|
|||
AssetsHTTPRoot: assetsHTTPRoot,
|
||||
FileStoreHTTPRoot: fileStoreHTTPRoot,
|
||||
FileStoreHTTPBaseURL: fileStoreHTTPBaseURL,
|
||||
FFmpegWorkerPoolSize: ffmpegWorkerPoolSize,
|
||||
CORSAllowedOrigins: corsAllowedOrigins,
|
||||
}, nil
|
||||
}
|
||||
|
|
|
@ -0,0 +1,294 @@
|
|||
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)
|
||||
})
|
||||
}
|
|
@ -4,12 +4,20 @@ 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 {
|
||||
|
@ -21,15 +29,12 @@ 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 string) (*FileSystemStore, error) {
|
||||
url, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing URL: %v", err)
|
||||
}
|
||||
func NewFileSystemStore(rootPath string, baseURL *url.URL) (*FileSystemStore, error) {
|
||||
url := *baseURL
|
||||
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.
|
||||
|
|
|
@ -3,6 +3,7 @@ package filestore_test
|
|||
import (
|
||||
"context"
|
||||
"io/ioutil"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
@ -14,7 +15,9 @@ import (
|
|||
)
|
||||
|
||||
func TestFileStoreGetObject(t *testing.T) {
|
||||
store, err := filestore.NewFileSystemStore("testdata/", "/")
|
||||
baseURL, err := url.Parse("/")
|
||||
require.NoError(t, err)
|
||||
store, err := filestore.NewFileSystemStore("testdata/", baseURL)
|
||||
require.NoError(t, err)
|
||||
reader, err := store.GetObject(context.Background(), "file.txt")
|
||||
require.NoError(t, err)
|
||||
|
@ -60,7 +63,9 @@ func TestFileStoreGetObjectWithRange(t *testing.T) {
|
|||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
store, err := filestore.NewFileSystemStore("testdata/", "/")
|
||||
baseURL, err := url.Parse("/")
|
||||
require.NoError(t, err)
|
||||
store, err := filestore.NewFileSystemStore("testdata/", baseURL)
|
||||
require.NoError(t, err)
|
||||
reader, err := store.GetObjectWithRange(context.Background(), "file.txt", tc.start, tc.end)
|
||||
|
||||
|
@ -113,7 +118,9 @@ func TestFileStoreGetURL(t *testing.T) {
|
|||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
store, err := filestore.NewFileSystemStore("testdata/", tc.baseURL)
|
||||
baseURL, err := url.Parse(tc.baseURL)
|
||||
require.NoError(t, err)
|
||||
store, err := filestore.NewFileSystemStore("testdata/", baseURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
url, err := store.GetURL(context.Background(), tc.key)
|
||||
|
@ -149,7 +156,9 @@ func TestFileStorePutObject(t *testing.T) {
|
|||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
store, err := filestore.NewFileSystemStore(rootPath, "/")
|
||||
baseURL, err := url.Parse("/")
|
||||
require.NoError(t, err)
|
||||
store, err := filestore.NewFileSystemStore(rootPath, baseURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
n, err := store.PutObject(context.Background(), tc.key, strings.NewReader(tc.content), "text/plain")
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
// 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
|
||||
}
|
|
@ -0,0 +1,153 @@
|
|||
// 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
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.27.1
|
||||
// protoc v3.17.3
|
||||
// protoc v3.19.1
|
||||
// source: media_set.proto
|
||||
|
||||
package media_set
|
||||
|
@ -74,6 +74,9 @@ 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"`
|
||||
|
@ -131,6 +134,27 @@ 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
|
||||
|
@ -304,6 +328,7 @@ 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() {
|
||||
|
@ -359,6 +384,13 @@ 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
|
||||
|
@ -553,8 +585,6 @@ 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"`
|
||||
}
|
||||
|
@ -591,20 +621,6 @@ 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
|
||||
|
@ -837,11 +853,16 @@ 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, 0xcd, 0x03, 0x0a,
|
||||
0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x9d, 0x04, 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, 0x25, 0x0a, 0x0e, 0x61, 0x75, 0x64, 0x69,
|
||||
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, 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,
|
||||
|
@ -873,96 +894,95 @@ 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, 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,
|
||||
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,
|
||||
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, 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,
|
||||
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, 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, 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,
|
||||
}
|
||||
|
||||
var (
|
||||
|
|
|
@ -36,4 +36,7 @@ type MediaSet struct {
|
|||
VideoContentLength int64
|
||||
AudioEncodedS3Key sql.NullString
|
||||
AudioEncodedS3UploadedAt sql.NullTime
|
||||
Title string
|
||||
Description string
|
||||
Author string
|
||||
}
|
||||
|
|
|
@ -11,13 +11,16 @@ import (
|
|||
)
|
||||
|
||||
const createMediaSet = `-- name: CreateMediaSet :one
|
||||
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
|
||||
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
|
||||
`
|
||||
|
||||
type CreateMediaSetParams struct {
|
||||
YoutubeID string
|
||||
Title string
|
||||
Description string
|
||||
Author string
|
||||
AudioYoutubeItag int32
|
||||
AudioChannels int32
|
||||
AudioFramesApprox int64
|
||||
|
@ -33,6 +36,9 @@ 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,
|
||||
|
@ -72,12 +78,15 @@ 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 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, title, description, author FROM media_sets WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetMediaSet(ctx context.Context, id uuid.UUID) (MediaSet, error) {
|
||||
|
@ -110,12 +119,15 @@ 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 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, title, description, author FROM media_sets WHERE youtube_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetMediaSetByYoutubeID(ctx context.Context, youtubeID string) (MediaSet, error) {
|
||||
|
@ -148,6 +160,9 @@ func (q *Queries) GetMediaSetByYoutubeID(ctx context.Context, youtubeID string)
|
|||
&i.VideoContentLength,
|
||||
&i.AudioEncodedS3Key,
|
||||
&i.AudioEncodedS3UploadedAt,
|
||||
&i.Title,
|
||||
&i.Description,
|
||||
&i.Author,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
@ -156,7 +171,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
|
||||
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
|
||||
`
|
||||
|
||||
type SetEncodedAudioUploadedParams struct {
|
||||
|
@ -194,6 +209,9 @@ func (q *Queries) SetEncodedAudioUploaded(ctx context.Context, arg SetEncodedAud
|
|||
&i.VideoContentLength,
|
||||
&i.AudioEncodedS3Key,
|
||||
&i.AudioEncodedS3UploadedAt,
|
||||
&i.Title,
|
||||
&i.Description,
|
||||
&i.Author,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
@ -202,7 +220,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
|
||||
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
|
||||
`
|
||||
|
||||
type SetRawAudioUploadedParams struct {
|
||||
|
@ -241,6 +259,9 @@ func (q *Queries) SetRawAudioUploaded(ctx context.Context, arg SetRawAudioUpload
|
|||
&i.VideoContentLength,
|
||||
&i.AudioEncodedS3Key,
|
||||
&i.AudioEncodedS3UploadedAt,
|
||||
&i.Title,
|
||||
&i.Description,
|
||||
&i.Author,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
@ -249,7 +270,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
|
||||
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
|
||||
`
|
||||
|
||||
type SetVideoThumbnailUploadedParams struct {
|
||||
|
@ -296,6 +317,9 @@ func (q *Queries) SetVideoThumbnailUploaded(ctx context.Context, arg SetVideoThu
|
|||
&i.VideoContentLength,
|
||||
&i.AudioEncodedS3Key,
|
||||
&i.AudioEncodedS3UploadedAt,
|
||||
&i.Title,
|
||||
&i.Description,
|
||||
&i.Author,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
@ -304,7 +328,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
|
||||
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
|
||||
`
|
||||
|
||||
type SetVideoUploadedParams struct {
|
||||
|
@ -342,6 +366,9 @@ func (q *Queries) SetVideoUploaded(ctx context.Context, arg SetVideoUploadedPara
|
|||
&i.VideoContentLength,
|
||||
&i.AudioEncodedS3Key,
|
||||
&i.AudioEncodedS3UploadedAt,
|
||||
&i.Title,
|
||||
&i.Description,
|
||||
&i.Author,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
|
@ -1,61 +1,69 @@
|
|||
module git.netflux.io/rob/clipper
|
||||
|
||||
go 1.17
|
||||
go 1.19
|
||||
|
||||
require (
|
||||
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/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/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.10.1
|
||||
github.com/jackc/pgx/v4 v4.14.0
|
||||
github.com/kkdai/youtube/v2 v2.7.4
|
||||
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/stretchr/testify v1.7.0
|
||||
go.uber.org/zap v1.19.1
|
||||
google.golang.org/grpc v1.42.0
|
||||
go.uber.org/zap v1.21.0
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
|
||||
google.golang.org/grpc v1.44.0
|
||||
google.golang.org/protobuf v1.27.1
|
||||
)
|
||||
|
||||
require (
|
||||
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/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/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.9.0 // indirect
|
||||
github.com/jackc/puddle v1.2.0 // indirect
|
||||
github.com/klauspost/compress v1.13.6 // 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/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.0 // indirect
|
||||
github.com/stretchr/objx v0.2.0 // indirect
|
||||
github.com/rs/cors v1.8.2 // indirect
|
||||
github.com/stretchr/objx v0.3.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-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/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/text v0.3.7 // indirect
|
||||
google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1 // indirect
|
||||
google.golang.org/genproto v0.0.0-20220208230804-65c12eb4c068 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
|
||||
nhooyr.io/websocket v1.8.7 // indirect
|
||||
)
|
||||
|
|
518
backend/go.sum
518
backend/go.sum
File diff suppressed because it is too large
Load Diff
|
@ -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 {
|
||||
|
@ -32,16 +32,20 @@ type audioGetter struct {
|
|||
store Store
|
||||
youtube YoutubeClient
|
||||
fileStore FileStore
|
||||
commandFunc CommandFunc
|
||||
workerPool *WorkerPool
|
||||
config config.Config
|
||||
logger *zap.SugaredLogger
|
||||
}
|
||||
|
||||
// newAudioGetter returns a new audioGetter.
|
||||
func newAudioGetter(store Store, youtube YoutubeClient, fileStore FileStore, config config.Config, logger *zap.SugaredLogger) *audioGetter {
|
||||
func newAudioGetter(store Store, youtube YoutubeClient, fileStore FileStore, commandFunc CommandFunc, workerPool *WorkerPool, config config.Config, logger *zap.SugaredLogger) *audioGetter {
|
||||
return &audioGetter{
|
||||
store: store,
|
||||
youtube: youtube,
|
||||
fileStore: fileStore,
|
||||
commandFunc: commandFunc,
|
||||
workerPool: workerPool,
|
||||
config: config,
|
||||
logger: logger,
|
||||
}
|
||||
|
@ -60,7 +64,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: %v", err)
|
||||
return nil, fmt.Errorf("error finding itag: %d", mediaSet.AudioYoutubeItag)
|
||||
}
|
||||
|
||||
stream, _, err := g.youtube.GetStreamContext(ctx, video, format)
|
||||
|
@ -77,7 +81,13 @@ func (g *audioGetter) GetAudio(ctx context.Context, mediaSet store.MediaSet, num
|
|||
audioGetter: g,
|
||||
getPeaksProgressReader: audioProgressReader,
|
||||
}
|
||||
go s.getAudio(ctx, stream, mediaSet)
|
||||
|
||||
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)
|
||||
}
|
||||
}()
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
@ -88,96 +98,109 @@ type audioGetterState struct {
|
|||
*getPeaksProgressReader
|
||||
}
|
||||
|
||||
func (s *audioGetterState) getAudio(ctx context.Context, r io.ReadCloser, mediaSet store.MediaSet) {
|
||||
func (s *audioGetterState) getAudio(ctx context.Context, r io.ReadCloser, mediaSet store.MediaSet) error {
|
||||
streamWithProgress := newLogProgressReader(r, "audio", mediaSet.AudioContentLength, s.logger)
|
||||
pr, pw := io.Pipe()
|
||||
teeReader := io.TeeReader(streamWithProgress, pw)
|
||||
|
||||
var stdErr bytes.Buffer
|
||||
cmd := exec.CommandContext(ctx, "ffmpeg", "-hide_banner", "-loglevel", "error", "-i", "-", "-f", rawAudioFormat, "-ar", strconv.Itoa(rawAudioSampleRate), "-acodec", rawAudioCodec, "-")
|
||||
cmd.Stdin = teeReader
|
||||
cmd := s.commandFunc(ctx, "ffmpeg", "-hide_banner", "-loglevel", "error", "-i", "-", "-f", rawAudioFormat, "-ar", strconv.Itoa(rawAudioSampleRate), "-acodec", rawAudioCodec, "-")
|
||||
cmd.Stderr = &stdErr
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
|
||||
// ffmpegWriter accepts encoded audio and pipes it to FFmpeg.
|
||||
ffmpegWriter, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
s.CloseWithError(fmt.Errorf("error getting stdout: %v", err))
|
||||
return
|
||||
return fmt.Errorf("error getting stdin: %v", err)
|
||||
}
|
||||
if err = cmd.Start(); err != nil {
|
||||
s.CloseWithError(fmt.Errorf("error starting command: %v, output: %s", err, stdErr.String()))
|
||||
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)
|
||||
}
|
||||
|
||||
var presignedAudioURL string
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
|
||||
// Upload the encoded audio.
|
||||
// TODO: fix error shadowing in these two goroutines.
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
g.Go(func() error {
|
||||
// TODO: use mediaSet func to fetch key
|
||||
key := fmt.Sprintf("media_sets/%s/audio.opus", mediaSet.ID)
|
||||
|
||||
_, encErr := s.fileStore.PutObject(ctx, key, pr, "audio/opus")
|
||||
_, encErr := s.fileStore.PutObject(ctx, key, uploadReader, "audio/opus")
|
||||
if encErr != nil {
|
||||
s.CloseWithError(fmt.Errorf("error uploading encoded audio: %v", encErr))
|
||||
return
|
||||
return fmt.Errorf("error uploading encoded audio: %v", encErr)
|
||||
}
|
||||
|
||||
presignedAudioURL, err = s.fileStore.GetURL(ctx, key)
|
||||
if err != nil {
|
||||
s.CloseWithError(fmt.Errorf("error generating presigned URL: %v", err))
|
||||
presignedAudioURL, encErr = s.fileStore.GetURL(ctx, key)
|
||||
if encErr != nil {
|
||||
return fmt.Errorf("error generating presigned URL: %v", encErr)
|
||||
}
|
||||
|
||||
if _, err = s.store.SetEncodedAudioUploaded(ctx, store.SetEncodedAudioUploadedParams{
|
||||
if _, encErr = s.store.SetEncodedAudioUploaded(ctx, store.SetEncodedAudioUploadedParams{
|
||||
ID: mediaSet.ID,
|
||||
AudioEncodedS3Key: sqlString(key),
|
||||
}); err != nil {
|
||||
s.CloseWithError(fmt.Errorf("error setting encoded audio uploaded: %v", err))
|
||||
}); encErr != nil {
|
||||
return fmt.Errorf("error setting encoded audio uploaded: %v", encErr)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
// Upload the raw audio.
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
g.Go(func() error {
|
||||
// TODO: use mediaSet func to fetch key
|
||||
key := fmt.Sprintf("media_sets/%s/audio.raw", mediaSet.ID)
|
||||
|
||||
teeReader := io.TeeReader(stdout, s)
|
||||
bytesUploaded, rawErr := s.fileStore.PutObject(ctx, key, teeReader, rawAudioMimeType)
|
||||
bytesUploaded, rawErr := s.fileStore.PutObject(ctx, key, ffmpegReader, rawAudioMimeType)
|
||||
if rawErr != nil {
|
||||
s.CloseWithError(fmt.Errorf("error uploading raw audio: %v", rawErr))
|
||||
return
|
||||
return fmt.Errorf("error uploading raw audio: %v", rawErr)
|
||||
}
|
||||
|
||||
if _, err = s.store.SetRawAudioUploaded(ctx, store.SetRawAudioUploadedParams{
|
||||
if _, rawErr = s.store.SetRawAudioUploaded(ctx, store.SetRawAudioUploadedParams{
|
||||
ID: mediaSet.ID,
|
||||
AudioRawS3Key: sqlString(key),
|
||||
AudioFrames: sqlInt64(bytesUploaded / SizeOfInt16 / int64(mediaSet.AudioChannels)),
|
||||
}); err != nil {
|
||||
s.CloseWithError(fmt.Errorf("error setting raw audio uploaded: %v", err))
|
||||
}
|
||||
}()
|
||||
|
||||
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
|
||||
}); rawErr != nil {
|
||||
return fmt.Errorf("error setting raw audio uploaded: %v", rawErr)
|
||||
}
|
||||
|
||||
// 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()
|
||||
return nil
|
||||
})
|
||||
|
||||
// Wait for the uploaders to complete.
|
||||
wg.Wait()
|
||||
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 := g.Wait(); err != nil {
|
||||
return fmt.Errorf("error uploading: %v", err)
|
||||
}
|
||||
|
||||
if err := cmd.Wait(); err != nil {
|
||||
return fmt.Errorf("error waiting for command: %v, output: %s", err, stdErr.String())
|
||||
}
|
||||
|
||||
// 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
|
||||
|
@ -229,7 +252,12 @@ 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}, io.EOF
|
||||
return GetPeaksProgress{
|
||||
Peaks: w.currPeaks,
|
||||
PercentComplete: w.percentComplete(),
|
||||
URL: w.url,
|
||||
AudioFrames: w.framesProcessed,
|
||||
}, io.EOF
|
||||
}
|
||||
return progress, nil
|
||||
case err := <-w.errorChan:
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
package media_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
@ -13,16 +15,186 @@ 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 TestGetPeaksFromFileStore(t *testing.T) {
|
||||
const inFixturePath = "testdata/tone-44100-stereo-int16-30000ms.raw"
|
||||
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
|
||||
)
|
||||
|
||||
ctx := context.Background()
|
||||
wp := media.NewTestWorkerPool()
|
||||
logger := zap.NewNop().Sugar()
|
||||
mediaSetID := uuid.New()
|
||||
mediaSet := store.MediaSet{
|
||||
|
@ -38,7 +210,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, config.Config{}, logger)
|
||||
service := media.NewMediaSetService(&mockStore, nil, nil, nil, wp, config.Config{}, logger)
|
||||
_, err := service.GetPeaks(ctx, mediaSetID, 10)
|
||||
assert.EqualError(t, err, "error getting media set: db went boom")
|
||||
})
|
||||
|
@ -51,7 +223,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, config.Config{}, logger)
|
||||
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, wp, config.Config{}, logger)
|
||||
_, err := service.GetPeaks(ctx, mediaSetID, 10)
|
||||
require.EqualError(t, err, "error getting object from file store: boom")
|
||||
})
|
||||
|
@ -62,12 +234,12 @@ func TestGetPeaksFromFileStore(t *testing.T) {
|
|||
defer mockStore.AssertExpectations(t)
|
||||
|
||||
var fileStore mocks.FileStore
|
||||
reader := fixtureReader(t, inFixturePath, 5_292_000)
|
||||
reader := fixtureReader(t, inFixturePath, inFixtureLen)
|
||||
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, config.Config{}, logger)
|
||||
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, wp, config.Config{}, logger)
|
||||
stream, err := service.GetPeaks(ctx, mediaSetID, 10)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
@ -89,21 +261,30 @@ func TestGetPeaksFromFileStore(t *testing.T) {
|
|||
defer mockStore.AssertExpectations(t)
|
||||
|
||||
var fileStore mocks.FileStore
|
||||
reader := fixtureReader(t, inFixturePath, 5_292_000)
|
||||
url := "https://www.example.com/foo"
|
||||
reader := fixtureReader(t, inFixturePath, inFixtureLen)
|
||||
fileStore.On("GetObject", mock.Anything, "raw audio key").Return(reader, nil)
|
||||
fileStore.On("GetURL", mock.Anything, "encoded audio key").Return("https://www.example.com/foo", nil)
|
||||
fileStore.On("GetURL", mock.Anything, "encoded audio key").Return(url, nil)
|
||||
defer fileStore.AssertExpectations(t)
|
||||
|
||||
numBins := 10
|
||||
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, config.Config{}, logger)
|
||||
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, wp, config.Config{}, logger)
|
||||
stream, err := service.GetPeaks(ctx, mediaSetID, numBins)
|
||||
require.NoError(t, err)
|
||||
|
||||
assertConsumeStream(t, numBins, url, 1_323_000, stream)
|
||||
})
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
@ -116,6 +297,7 @@ func TestGetPeaksFromFileStore(t *testing.T) {
|
|||
assert.GreaterOrEqual(t, progress.PercentComplete, lastPercentComplete)
|
||||
lastPercentComplete = progress.PercentComplete
|
||||
lastURL = progress.URL
|
||||
lastAudioFrames = progress.AudioFrames
|
||||
|
||||
if err == io.EOF {
|
||||
break
|
||||
|
@ -130,7 +312,7 @@ func TestGetPeaksFromFileStore(t *testing.T) {
|
|||
|
||||
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)
|
||||
})
|
||||
assert.Equal(t, expBins, count)
|
||||
assert.Equal(t, expURL, lastURL)
|
||||
assert.Equal(t, expAudioFrames, lastAudioFrames)
|
||||
}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
package media
|
||||
|
||||
//go:generate mockery --recursive --name AudioSegmentStream --output ../generated/mocks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
|
@ -42,14 +44,21 @@ type AudioSegmentProgress struct {
|
|||
Data []byte
|
||||
}
|
||||
|
||||
// AudioSegmentStream is a stream of AudioSegmentProgress structs.
|
||||
type AudioSegmentStream struct {
|
||||
// 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 {
|
||||
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,
|
||||
|
@ -57,12 +66,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
|
||||
}
|
||||
|
||||
|
@ -70,23 +79,25 @@ 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, rawAudio io.ReadCloser, channels int32, bytesExpected int64, outFormat AudioFormat) *audioSegmentGetter {
|
||||
func newAudioSegmentGetter(commandFunc CommandFunc, workerPool *WorkerPool, 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),
|
||||
},
|
||||
|
@ -120,7 +131,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 {
|
||||
|
@ -137,6 +148,7 @@ 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
|
||||
|
@ -144,12 +156,18 @@ func (s *audioSegmentGetter) getAudioSegment(ctx context.Context) {
|
|||
cmd.Stdout = s
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
s.stream.closeWithError(fmt.Errorf("error starting command: %v, output: %s", err, stdErr.String()))
|
||||
return
|
||||
return fmt.Errorf("error starting command: %v, output: %s", err, stdErr.String())
|
||||
}
|
||||
|
||||
if err := cmd.Wait(); err != nil {
|
||||
s.stream.closeWithError(fmt.Errorf("error waiting for ffmpeg: %v, output: %s", err, stdErr.String()))
|
||||
return fmt.Errorf("error waiting for ffmpeg: %v, output: %s", err, stdErr.String())
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
s.stream.closeWithError(err)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -4,12 +4,7 @@ import (
|
|||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.netflux.io/rob/clipper/config"
|
||||
|
@ -24,87 +19,15 @@ 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) {
|
||||
mediaSetID := uuid.MustParse("4c440241-cca9-436f-adb0-be074588cf2b")
|
||||
const inFixturePath = "testdata/tone-44100-stereo-int16-30000ms.raw"
|
||||
mediaSetID := uuid.MustParse("4c440241-cca9-436f-adb0-be074588cf2b")
|
||||
wp := media.NewTestWorkerPool()
|
||||
|
||||
t.Run("invalid range", func(t *testing.T) {
|
||||
var mockStore mocks.Store
|
||||
var fileStore mocks.FileStore
|
||||
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, config.Config{}, zap.NewNop().Sugar())
|
||||
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, wp, config.Config{}, zap.NewNop().Sugar())
|
||||
|
||||
stream, err := service.GetAudioSegment(context.Background(), mediaSetID, 1, 0, media.AudioFormatMP3)
|
||||
require.Nil(t, stream)
|
||||
|
@ -115,7 +38,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, config.Config{}, zap.NewNop().Sugar())
|
||||
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, wp, config.Config{}, zap.NewNop().Sugar())
|
||||
|
||||
stream, err := service.GetAudioSegment(context.Background(), mediaSetID, 0, 1, media.AudioFormatMP3)
|
||||
require.Nil(t, stream)
|
||||
|
@ -132,7 +55,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, config.Config{}, zap.NewNop().Sugar())
|
||||
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, wp, config.Config{}, zap.NewNop().Sugar())
|
||||
|
||||
stream, err := service.GetAudioSegment(context.Background(), mediaSetID, 0, 1, media.AudioFormatMP3)
|
||||
require.Nil(t, stream)
|
||||
|
@ -150,7 +73,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, config.Config{}, zap.NewNop().Sugar())
|
||||
service := media.NewMediaSetService(&mockStore, nil, &fileStore, cmd, wp, config.Config{}, zap.NewNop().Sugar())
|
||||
|
||||
stream, err := service.GetAudioSegment(context.Background(), mediaSetID, 0, 1, media.AudioFormatMP3)
|
||||
require.NoError(t, err)
|
||||
|
@ -236,7 +159,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, config.Config{}, zap.NewNop().Sugar())
|
||||
service := media.NewMediaSetService(&mockStore, nil, &fileStore, cmd, wp, config.Config{}, zap.NewNop().Sugar())
|
||||
|
||||
stream, err := service.GetAudioSegment(ctx, mediaSetID, tc.inStartFrame, tc.inEndFrame, tc.audioFormat)
|
||||
require.NoError(t, err)
|
||||
|
|
|
@ -30,7 +30,7 @@ type videoGetter struct {
|
|||
type videoGetterState struct {
|
||||
*videoGetter
|
||||
|
||||
r io.Reader
|
||||
r io.ReadCloser
|
||||
count, exp int64
|
||||
mediaSetID uuid.UUID
|
||||
key, contentType string
|
||||
|
@ -45,12 +45,14 @@ 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 error = io.EOF, otherwise a deadlock or other resource
|
||||
// method called until err == io.EOF, otherwise a deadlock or other resource
|
||||
// leakage is likely.
|
||||
func (g *videoGetter) GetVideo(ctx context.Context, r io.Reader, exp int64, mediaSetID uuid.UUID, key, contentType string) (GetVideoProgressReader, error) {
|
||||
//
|
||||
// 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) {
|
||||
s := &videoGetterState{
|
||||
videoGetter: g,
|
||||
r: newLogProgressReader(r, "video", exp, g.logger),
|
||||
r: r,
|
||||
exp: exp,
|
||||
mediaSetID: mediaSetID,
|
||||
key: key,
|
||||
|
@ -75,7 +77,8 @@ func (s *videoGetterState) Write(p []byte) (int, error) {
|
|||
}
|
||||
|
||||
func (s *videoGetterState) getVideo(ctx context.Context) {
|
||||
teeReader := io.TeeReader(s.r, s)
|
||||
logReader := newLogProgressReader(s.r, "video", s.exp, s.logger)
|
||||
teeReader := io.TeeReader(logReader, s)
|
||||
|
||||
_, err := s.fileStore.PutObject(ctx, s.key, teeReader, s.contentType)
|
||||
if err != nil {
|
||||
|
@ -83,9 +86,15 @@ 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{
|
||||
|
@ -95,6 +104,7 @@ 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)
|
||||
|
@ -115,10 +125,10 @@ func (s *videoGetterState) Next() (GetVideoProgress, error) {
|
|||
}
|
||||
}
|
||||
|
||||
type videoGetterDownloaded string
|
||||
type videoGetterFromFileStore string
|
||||
|
||||
// Next() implements GetVideoProgressReader.
|
||||
func (s *videoGetterDownloaded) Next() (GetVideoProgress, error) {
|
||||
func (s *videoGetterFromFileStore) Next() (GetVideoProgress, error) {
|
||||
return GetVideoProgress{
|
||||
PercentComplete: 100,
|
||||
URL: string(*s),
|
||||
|
|
|
@ -0,0 +1,276 @@
|
|||
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)
|
||||
})
|
||||
}
|
|
@ -9,6 +9,7 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.netflux.io/rob/clipper/config"
|
||||
|
@ -37,16 +38,18 @@ 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, config config.Config, logger *zap.SugaredLogger) *MediaSetService {
|
||||
func NewMediaSetService(store Store, youtubeClient YoutubeClient, fileStore FileStore, commandFunc CommandFunc, workerPool *WorkerPool, config config.Config, logger *zap.SugaredLogger) *MediaSetService {
|
||||
return &MediaSetService{
|
||||
store: store,
|
||||
youtube: youtubeClient,
|
||||
fileStore: fileStore,
|
||||
commandFunc: commandFunc,
|
||||
workerPool: workerPool,
|
||||
config: config,
|
||||
logger: logger,
|
||||
}
|
||||
|
@ -98,6 +101,9 @@ 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,
|
||||
|
@ -117,6 +123,9 @@ 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,
|
||||
}, nil
|
||||
|
@ -135,6 +144,9 @@ 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,
|
||||
Audio: Audio{
|
||||
YoutubeItag: int(mediaSet.AudioYoutubeItag),
|
||||
ContentLength: mediaSet.AudioContentLength,
|
||||
|
@ -214,11 +226,12 @@ func (s *MediaSetService) GetVideo(ctx context.Context, id uuid.UUID) (GetVideoP
|
|||
}
|
||||
|
||||
if mediaSet.VideoS3UploadedAt.Valid {
|
||||
url, err := s.fileStore.GetURL(ctx, mediaSet.VideoS3Key.String)
|
||||
var url string
|
||||
url, err = s.fileStore.GetURL(ctx, mediaSet.VideoS3Key.String)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error generating presigned URL: %v", err)
|
||||
}
|
||||
videoGetter := videoGetterDownloaded(url)
|
||||
videoGetter := videoGetterFromFileStore(url)
|
||||
return &videoGetter, nil
|
||||
}
|
||||
|
||||
|
@ -271,7 +284,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.config, s.logger)
|
||||
audioGetter := newAudioGetter(s.store, s.youtube, s.fileStore, s.commandFunc, s.workerPool, s.config, s.logger)
|
||||
return audioGetter.GetAudio(ctx, mediaSet, numBins)
|
||||
}
|
||||
|
||||
|
@ -354,7 +367,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 {
|
||||
if startFrame < 0 || endFrame < 0 || numBins <= 0 || startFrame == endFrame {
|
||||
s.logger.With("startFrame", startFrame, "endFrame", endFrame, "numBins", numBins).Error("invalid arguments")
|
||||
return nil, errors.New("invalid arguments")
|
||||
}
|
||||
|
@ -385,44 +398,54 @@ func (s *MediaSetService) GetPeaksForSegment(ctx context.Context, id uuid.UUID,
|
|||
sampleBuf := make([]int16, readBufSizeBytes/SizeOfInt16)
|
||||
bytesExpected := (endFrame - startFrame) * int64(channels) * SizeOfInt16
|
||||
|
||||
var (
|
||||
bytesRead int64
|
||||
closing bool
|
||||
currPeakIndex int
|
||||
currFrame int64
|
||||
)
|
||||
var bytesRead int64
|
||||
var closing bool
|
||||
|
||||
for bin := 0; bin < numBins; bin++ {
|
||||
framesRemaining := framesPerBin
|
||||
if bin == numBins-1 {
|
||||
framesRemaining += totalFrames % int64(numBins)
|
||||
}
|
||||
|
||||
for {
|
||||
n, err := modReader.Read(readBuf)
|
||||
// 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
|
||||
}
|
||||
|
||||
n, err := modReader.Read(readBuf[:bytesToRead])
|
||||
if err == io.EOF {
|
||||
closing = true
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf("read error: %v", err)
|
||||
}
|
||||
|
||||
bytesRead += int64(n)
|
||||
samples := sampleBuf[:n/SizeOfInt16]
|
||||
|
||||
if err := binary.Read(bytes.NewReader(readBuf[:n]), binary.LittleEndian, samples); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
for i := 0; i < len(samples); i += channels {
|
||||
pi := bin * channels
|
||||
for i := 0; i < len(ss); i += channels {
|
||||
for j := 0; j < channels; j++ {
|
||||
samp := sampleBuf[i+j]
|
||||
if samp < 0 {
|
||||
samp = -samp
|
||||
s := ss[i+j]
|
||||
if s < 0 {
|
||||
s = -s
|
||||
}
|
||||
if s > peaks[pi+j] {
|
||||
peaks[pi+j] = s
|
||||
}
|
||||
if samp > peaks[currPeakIndex+j] {
|
||||
peaks[currPeakIndex+j] = samp
|
||||
}
|
||||
}
|
||||
|
||||
if currFrame == framesPerBin {
|
||||
currFrame = 0
|
||||
currPeakIndex += channels
|
||||
} else {
|
||||
currFrame++
|
||||
framesRemaining -= int64(n) / int64(channels) / SizeOfInt16
|
||||
bytesRead += int64(n)
|
||||
|
||||
if closing || framesRemaining == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -438,7 +461,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")
|
||||
}
|
||||
|
@ -458,7 +481,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, rawAudio, mediaSet.AudioChannels, endByte-startByte, outFormat)
|
||||
g := newAudioSegmentGetter(s.commandFunc, s.workerPool, rawAudio, mediaSet.AudioChannels, endByte-startByte, outFormat)
|
||||
go g.getAudioSegment(ctx)
|
||||
|
||||
return g.stream, nil
|
||||
|
|
|
@ -4,9 +4,9 @@ import (
|
|||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"testing"
|
||||
|
||||
"git.netflux.io/rob/clipper/config"
|
||||
|
@ -20,11 +20,31 @@ 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
|
||||
fixtureLen int64
|
||||
fixtureReadErrBytes int64
|
||||
fixtureReadErr error
|
||||
fixtureMaxRead int64
|
||||
startFrame, endFrame int64
|
||||
channels int32
|
||||
numBins int
|
||||
|
@ -32,9 +52,17 @@ func TestPeaksForSegment(t *testing.T) {
|
|||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "entire fixture, stereo, 1 bin",
|
||||
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",
|
||||
fixturePath: "testdata/tone-44100-stereo-int16.raw",
|
||||
fixtureLen: 176400,
|
||||
startFrame: 0,
|
||||
endFrame: 44100,
|
||||
channels: 2,
|
||||
|
@ -42,9 +70,8 @@ func TestPeaksForSegment(t *testing.T) {
|
|||
wantPeaks: []int16{32747, 32747},
|
||||
},
|
||||
{
|
||||
name: "entire fixture, stereo, 4 bins",
|
||||
name: "OK, entire fixture, stereo, 4 bins",
|
||||
fixturePath: "testdata/tone-44100-stereo-int16.raw",
|
||||
fixtureLen: 176400,
|
||||
startFrame: 0,
|
||||
endFrame: 44100,
|
||||
channels: 2,
|
||||
|
@ -52,9 +79,8 @@ func TestPeaksForSegment(t *testing.T) {
|
|||
wantPeaks: []int16{8173, 8177, 16366, 16370, 24557, 24555, 32747, 32747},
|
||||
},
|
||||
{
|
||||
name: "entire fixture, stereo, 16 bins",
|
||||
name: "OK, entire fixture, stereo, 16 bins",
|
||||
fixturePath: "testdata/tone-44100-stereo-int16.raw",
|
||||
fixtureLen: 176400,
|
||||
startFrame: 0,
|
||||
endFrame: 44100,
|
||||
channels: 2,
|
||||
|
@ -62,9 +88,8 @@ 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: "entire fixture, mono, 1 bin",
|
||||
name: "OK, entire fixture, mono, 1 bin",
|
||||
fixturePath: "testdata/tone-44100-mono-int16.raw",
|
||||
fixtureLen: 88200,
|
||||
startFrame: 0,
|
||||
endFrame: 44100,
|
||||
channels: 1,
|
||||
|
@ -72,14 +97,34 @@ func TestPeaksForSegment(t *testing.T) {
|
|||
wantPeaks: []int16{32748},
|
||||
},
|
||||
{
|
||||
name: "entire fixture, mono, 32 bins",
|
||||
name: "OK, entire fixture, mono, 32 bins",
|
||||
fixturePath: "testdata/tone-44100-mono-int16.raw",
|
||||
fixtureLen: 88200,
|
||||
startFrame: 0,
|
||||
endFrame: 44100,
|
||||
channels: 1,
|
||||
numBins: 32,
|
||||
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},
|
||||
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",
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -88,11 +133,18 @@ 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
|
||||
}
|
||||
|
||||
audioFile, err := os.Open(tc.fixturePath)
|
||||
fixture, err := os.Open(tc.fixturePath)
|
||||
require.NoError(t, err)
|
||||
defer audioFile.Close()
|
||||
audioData := io.NopCloser(io.LimitReader(audioFile, int64(expectedBytes)))
|
||||
defer fixture.Close()
|
||||
sr := segmentReader{
|
||||
r: io.LimitReader(fixture, int64(expectedBytes)),
|
||||
err: tc.fixtureReadErr,
|
||||
errBytes: tc.fixtureReadErrBytes,
|
||||
}
|
||||
|
||||
mediaSet := store.MediaSet{
|
||||
ID: uuid.New(),
|
||||
|
@ -103,19 +155,20 @@ 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(audioData, nil)
|
||||
Return(&sr, nil)
|
||||
|
||||
service := media.NewMediaSetService(store, nil, fileStore, exec.CommandContext, config.Config{}, zap.NewNop().Sugar())
|
||||
service := media.NewMediaSetService(store, nil, fileStore, nil, media.NewTestWorkerPool(), config.Config{}, zap.NewNop().Sugar())
|
||||
peaks, err := service.GetPeaksForSegment(context.Background(), mediaSet.ID, tc.startFrame, tc.endFrame, tc.numBins)
|
||||
|
||||
if tc.wantErr == "" {
|
||||
assert.NoError(t, err)
|
||||
defer store.AssertExpectations(t)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.wantPeaks, peaks)
|
||||
} else {
|
||||
assert.EqualError(t, err, tc.wantErr)
|
||||
|
@ -130,7 +183,6 @@ func BenchmarkGetPeaksForSegment(b *testing.B) {
|
|||
endFrame = 1323000
|
||||
channels = 2
|
||||
fixturePath = "testdata/tone-44100-stereo-int16-30000ms.raw"
|
||||
fixtureLen = 5292000
|
||||
numBins = 2000
|
||||
)
|
||||
|
||||
|
@ -154,7 +206,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, exec.CommandContext, config.Config{}, zap.NewNop().Sugar())
|
||||
service := media.NewMediaSetService(store, nil, fileStore, nil, media.NewTestWorkerPool(), config.Config{}, zap.NewNop().Sugar())
|
||||
b.StartTimer()
|
||||
|
||||
_, err = service.GetPeaksForSegment(context.Background(), mediaSetID, startFrame, endFrame, numBins)
|
||||
|
|
|
@ -0,0 +1,103 @@
|
|||
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
|
||||
}
|
|
@ -24,6 +24,7 @@ type MediaSet struct {
|
|||
Video Video
|
||||
ID uuid.UUID
|
||||
YoutubeID string
|
||||
Title, Description, Author string
|
||||
}
|
||||
|
||||
// Audio contains the metadata for the audio part of the media set.
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
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")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
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)
|
||||
}
|
|
@ -0,0 +1,218 @@
|
|||
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
|
||||
}
|
|
@ -0,0 +1,180 @@
|
|||
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(¶ms, 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
|
||||
}
|
|
@ -0,0 +1,254 @@
|
|||
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
|
||||
}
|
|
@ -2,9 +2,7 @@ package server
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
@ -21,7 +19,6 @@ import (
|
|||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -41,6 +38,15 @@ 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
|
||||
|
@ -68,260 +74,55 @@ type Options struct {
|
|||
Store media.Store
|
||||
YoutubeClient media.YoutubeClient
|
||||
FileStore media.FileStore
|
||||
WorkerPool *media.WorkerPool
|
||||
Logger *zap.Logger
|
||||
}
|
||||
|
||||
// mediaSetServiceController implements gRPC controller for MediaSetService
|
||||
type mediaSetServiceController struct {
|
||||
pbmediaset.UnimplementedMediaSetServiceServer
|
||||
|
||||
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(
|
||||
conf := options.Config
|
||||
|
||||
mediaSetService := media.NewMediaSetService(
|
||||
options.Store,
|
||||
options.YoutubeClient,
|
||||
options.FileStore,
|
||||
exec.CommandContext,
|
||||
options.Config,
|
||||
options.WorkerPool,
|
||||
conf,
|
||||
options.Logger.Sugar().Named("mediaSetService"),
|
||||
)
|
||||
|
||||
grpcServer, err := buildGRPCServer(options.Config, options.Logger)
|
||||
grpcServer, err := buildGRPCServer(conf, options.Logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error building server: %v", err)
|
||||
}
|
||||
|
||||
mediaSetController := &mediaSetServiceController{mediaSetService: fetchMediaSetService, logger: options.Logger.Sugar().Named("controller")}
|
||||
mediaSetController := &mediaSetServiceController{mediaSetService: mediaSetService, logger: options.Logger.Sugar().Named("controller")}
|
||||
pbmediaset.RegisterMediaSetServiceServer(grpcServer, mediaSetController)
|
||||
|
||||
// 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))
|
||||
// TODO: convert CORSAllowedOrigins to a map[string]struct{}
|
||||
originChecker := func(origin string) bool {
|
||||
for _, s := range conf.CORSAllowedOrigins {
|
||||
if origin == s {
|
||||
return true
|
||||
}
|
||||
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))
|
||||
}
|
||||
return false
|
||||
}
|
||||
grpcHandler := grpcweb.WrapServer(grpcServer, grpcweb.WithOriginFunc(originChecker))
|
||||
httpHandler := newHTTPHandler(grpcHandler, mediaSetService, conf, options.Logger.Sugar().Named("httpHandler"))
|
||||
|
||||
httpServer := http.Server{
|
||||
Addr: options.Config.BindAddr,
|
||||
Addr: conf.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)
|
||||
}),
|
||||
Handler: httpHandler,
|
||||
}
|
||||
|
||||
log := options.Logger.Sugar()
|
||||
log.Infof("Listening at %s", options.Config.BindAddr)
|
||||
|
||||
if options.Config.TLSCertFile != "" && options.Config.TLSKeyFile != "" {
|
||||
return httpServer.ListenAndServeTLS(options.Config.TLSCertFile, options.Config.TLSKeyFile)
|
||||
if conf.TLSCertFile != "" && conf.TLSKeyFile != "" {
|
||||
return httpServer.ListenAndServeTLS(conf.TLSCertFile, conf.TLSKeyFile)
|
||||
}
|
||||
|
||||
return httpServer.ListenAndServe()
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
css
|
|
@ -0,0 +1 @@
|
|||
foo
|
|
@ -0,0 +1 @@
|
|||
index
|
|
@ -0,0 +1 @@
|
|||
bar
|
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE media_sets DROP COLUMN title;
|
||||
ALTER TABLE media_sets DROP COLUMN description;
|
||||
ALTER TABLE media_sets DROP COLUMN author;
|
|
@ -0,0 +1,5 @@
|
|||
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);
|
|
@ -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, 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())
|
||||
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 *;
|
||||
|
||||
-- name: SetRawAudioUploaded :one
|
||||
|
|
|
@ -16,6 +16,11 @@ 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.
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
"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",
|
||||
|
@ -14,14 +15,15 @@
|
|||
"google-protobuf": "^3.19.0",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-scripts": "4.0.3",
|
||||
"react-scripts": "5.0.0",
|
||||
"typescript": "^4.1.2",
|
||||
"web-vitals": "^1.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "echo 'no tests yet' # react-scripts test",
|
||||
"test": "react-scripts test --watchAll=false",
|
||||
"test:watch": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
|
@ -46,11 +48,14 @@
|
|||
"@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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"short_name": "Clipper",
|
||||
"name": "Clipper",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
|
@ -20,6 +20,7 @@
|
|||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"orientation": "landscape",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
body {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
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();
|
||||
});
|
|
@ -1,28 +1,33 @@
|
|||
import {
|
||||
MediaSet,
|
||||
GrpcWebImpl,
|
||||
MediaSetServiceClientImpl,
|
||||
GetVideoProgress,
|
||||
GetPeaksProgress,
|
||||
} from './generated/media_set';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useEffect, useCallback, useReducer } from 'react';
|
||||
import { State, stateReducer, zoomFactor, PlayState } from './AppState';
|
||||
import { AudioFormat } from './generated/media_set';
|
||||
import { VideoPreview } from './VideoPreview';
|
||||
import { Overview, CanvasLogicalWidth } from './Overview';
|
||||
import { Waveform } from './Waveform';
|
||||
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 './App.css';
|
||||
import { Duration } from './generated/google/protobuf/duration';
|
||||
import { firstValueFrom, from, Observable } from 'rxjs';
|
||||
import { first, map } from 'rxjs/operators';
|
||||
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;
|
||||
const thumbnailHeight = 100;
|
||||
|
||||
const initialViewportCanvasPixels = 100;
|
||||
const thumbnailWidth = 177; // height 100
|
||||
|
||||
const apiURL = process.env.REACT_APP_API_URL || 'http://localhost:8888';
|
||||
|
||||
|
@ -37,22 +42,34 @@ export interface VideoPosition {
|
|||
percent: number;
|
||||
}
|
||||
|
||||
const video = document.createElement('video');
|
||||
const audio = document.createElement('audio');
|
||||
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,
|
||||
};
|
||||
|
||||
function App(): 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 [state, dispatch] = useReducer(stateReducer, { ...initialState });
|
||||
|
||||
// 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;
|
||||
const {
|
||||
mediaSet,
|
||||
waveformPeaks,
|
||||
overviewPeaks,
|
||||
selection,
|
||||
selectionCanvas,
|
||||
viewport,
|
||||
viewportCanvas,
|
||||
position,
|
||||
playState,
|
||||
} = state;
|
||||
|
||||
// effects
|
||||
|
||||
|
@ -70,110 +87,78 @@ function App(): JSX.Element {
|
|||
const mediaSet = await service.Get({ youtubeId: videoID });
|
||||
|
||||
console.log('got media set:', mediaSet);
|
||||
setMediaSet(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 });
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const updatePlayerPositionIntevalMillis = 30;
|
||||
|
||||
// setup player on first page load only:
|
||||
// load waveform peaks on MediaSet change
|
||||
useEffect(() => {
|
||||
(async function () {
|
||||
const { mediaSet, viewport } = state;
|
||||
|
||||
if (mediaSet == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalID = setInterval(() => {
|
||||
const currTime = audio.currentTime;
|
||||
if (currTime == positionRef.current.currentTime) {
|
||||
if (viewport.start >= viewport.end) {
|
||||
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();
|
||||
}
|
||||
const service = new MediaSetServiceClientImpl(newRPC());
|
||||
const segment = await service.GetPeaksForSegment({
|
||||
id: mediaSet.id,
|
||||
numBins: CanvasWidth,
|
||||
startFrame: viewport.start,
|
||||
endFrame: viewport.end,
|
||||
});
|
||||
|
||||
// update the current position
|
||||
setPosition({ currentTime: audio.currentTime, percent: percent });
|
||||
}, updatePlayerPositionIntevalMillis);
|
||||
console.log('got segment', segment);
|
||||
|
||||
return () => clearInterval(intervalID);
|
||||
}, [mediaSet, selection]);
|
||||
const peaks: Observable<number[]> = from(segment.peaks).pipe(
|
||||
bufferCount(mediaSet.audioChannels)
|
||||
);
|
||||
dispatch({ type: 'waveformpeaksloaded', peaks: peaks });
|
||||
})();
|
||||
}, [viewport, mediaSet]);
|
||||
|
||||
// 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);
|
||||
|
@ -181,81 +166,64 @@ function App(): JSX.Element {
|
|||
|
||||
// handlers
|
||||
|
||||
const handleKeyPress = useCallback(
|
||||
(evt: KeyboardEvent) => {
|
||||
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();
|
||||
};
|
||||
|
||||
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) {
|
||||
const handleClip = () => {
|
||||
if (!window.showSaveFilePicker) {
|
||||
downloadClipHTTP();
|
||||
return;
|
||||
}
|
||||
|
||||
setPositionFromFrame(newViewport.start);
|
||||
},
|
||||
[mediaSet, audio, video, selection]
|
||||
);
|
||||
downloadClipFileSystemAccessAPI();
|
||||
};
|
||||
|
||||
// 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(() => {
|
||||
const downloadClipHTTP = () => {
|
||||
(async function () {
|
||||
console.debug('clip', selection);
|
||||
|
||||
if (mediaSet == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: support File System Access API fallback
|
||||
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();
|
||||
|
||||
|
@ -274,49 +242,34 @@ function App(): JSX.Element {
|
|||
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]);
|
||||
|
||||
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);
|
||||
const marginClass = 'mx-[88px]'; // offsetPixels
|
||||
|
||||
if (mediaSet == null) {
|
||||
// TODO: improve
|
||||
|
@ -325,65 +278,131 @@ function App(): JSX.Element {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="App">
|
||||
<div style={containerStyles}>
|
||||
<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
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
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}
|
||||
/>
|
||||
|
||||
<Overview
|
||||
<div className="w-full bg-gray-600 h-6"></div>
|
||||
|
||||
<div className={`relative grow-0 h-16`}>
|
||||
<WaveformCanvas
|
||||
peaks={overviewPeaks}
|
||||
mediaSet={mediaSet}
|
||||
offsetPixels={offsetPixels}
|
||||
height={80}
|
||||
viewport={viewport}
|
||||
position={position}
|
||||
onSelectionChange={handleOverviewSelectionChange}
|
||||
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>
|
||||
|
||||
<Waveform
|
||||
mediaSet={mediaSet}
|
||||
position={position}
|
||||
viewport={viewport}
|
||||
offsetPixels={offsetPixels}
|
||||
onSelectionChange={handleWaveformSelectionChange}
|
||||
<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={video.currentTime}
|
||||
position={position.currentTime}
|
||||
duration={mediaSet.audioFrames / mediaSet.audioSampleRate}
|
||||
offsetPixels={offsetPixels}
|
||||
onPositionChanged={(position: number) => {
|
||||
video.currentTime = position;
|
||||
audio.currentTime = position;
|
||||
onPositionChanged={(currentTime: number) => {
|
||||
dispatch({ type: 'skip', currentTime });
|
||||
}}
|
||||
/>
|
||||
|
||||
<VideoPreview
|
||||
<Player
|
||||
mediaSet={mediaSet}
|
||||
video={video}
|
||||
position={position}
|
||||
duration={millisFromDuration(mediaSet.videoDuration)}
|
||||
height={thumbnailHeight}
|
||||
playState={playState}
|
||||
audioSrc={state.audioSrc}
|
||||
videoSrc={state.videoSrc}
|
||||
currentTime={state.currentTime}
|
||||
onPositionChanged={(currentTime) =>
|
||||
dispatch({ type: 'positionchanged', currentTime: currentTime })
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
|
@ -391,13 +410,6 @@ function App(): JSX.Element {
|
|||
|
||||
export default App;
|
||||
|
||||
function millisFromDuration(dur?: Duration): number {
|
||||
if (dur == undefined) {
|
||||
return 0;
|
||||
}
|
||||
return Math.floor(dur.seconds * 1000.0 + dur.nanos / 1000.0 / 1000.0);
|
||||
}
|
||||
|
||||
export function newRPC(): GrpcWebImpl {
|
||||
return new GrpcWebImpl(apiURL, {});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,392 @@
|
|||
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);
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,424 @@
|
|||
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 };
|
||||
}
|
|
@ -1,34 +1,86 @@
|
|||
import React from 'react';
|
||||
import { PlayState } from './AppState';
|
||||
import {
|
||||
CloudDownloadIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
ZoomInIcon,
|
||||
ZoomOutIcon,
|
||||
} from '@heroicons/react/solid';
|
||||
|
||||
interface Props {
|
||||
onPlay: () => void;
|
||||
onPause: () => void;
|
||||
playState: PlayState;
|
||||
zoomInEnabled: boolean;
|
||||
zoomOutEnabled: boolean;
|
||||
onTogglePlay: () => void;
|
||||
onClip: () => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
downloadClipEnabled: boolean;
|
||||
}
|
||||
|
||||
const ControlBar: React.FC<Props> = React.memo((props: Props) => {
|
||||
const styles = { width: '100%', flexGrow: 0 };
|
||||
const buttonStyles = {
|
||||
cursor: 'pointer',
|
||||
background: 'black',
|
||||
outline: 'none',
|
||||
border: 'none',
|
||||
color: 'green',
|
||||
display: 'inline-block',
|
||||
margin: '0 2px',
|
||||
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();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={styles}>
|
||||
<button style={buttonStyles} onClick={props.onPlay}>
|
||||
Play
|
||||
<div className="relative grow-0 w-full py-2 space-x-2">
|
||||
<button
|
||||
className={buttonStyle}
|
||||
onClick={(evt) => filterMouseEvent(evt, props.onTogglePlay)}
|
||||
>
|
||||
{playPauseComponent}
|
||||
</button>
|
||||
<button style={buttonStyles} onClick={props.onPause}>
|
||||
Pause
|
||||
<button
|
||||
className={props.zoomInEnabled ? buttonStyle : disabledButtonStyle}
|
||||
onClick={(evt) => filterMouseEvent(evt, props.onZoomIn)}
|
||||
>
|
||||
<ZoomInIcon className={iconStyle} />
|
||||
</button>
|
||||
<button style={buttonStyles} onClick={props.onClip}>
|
||||
Clip
|
||||
<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>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
@ -1,85 +1,112 @@
|
|||
import { useState, useEffect, useRef, useCallback, MouseEvent } from 'react';
|
||||
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';
|
||||
|
||||
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: Selection;
|
||||
onSelectionChange: (selection: Selection) => void;
|
||||
selection: CanvasRange;
|
||||
onSelectionChange: (selectionState: SelectionChangeEvent) => void;
|
||||
}
|
||||
|
||||
enum Mode {
|
||||
Normal,
|
||||
Selecting,
|
||||
Dragging,
|
||||
ResizingStart,
|
||||
ResizingEnd,
|
||||
export interface SelectionChangeEvent {
|
||||
selection: CanvasRange;
|
||||
mode: SelectionMode;
|
||||
prevMode: SelectionMode;
|
||||
}
|
||||
|
||||
enum HoverState {
|
||||
Normal,
|
||||
OverSelectionStart,
|
||||
OverSelectionEnd,
|
||||
OverSelection,
|
||||
}
|
||||
const emptySelection: CanvasRange = { x1: 0, x2: 0 };
|
||||
|
||||
export enum EmptySelectionAction {
|
||||
SelectNothing,
|
||||
SelectPrevious,
|
||||
}
|
||||
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 interface Selection {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
const emptySelection: Selection = { start: 0, end: 0 };
|
||||
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 const HudCanvas: React.FC<Props> = ({
|
||||
width,
|
||||
height,
|
||||
zIndex,
|
||||
emptySelectionAction,
|
||||
styles: {
|
||||
borderLineWidth,
|
||||
borderStrokeStyle,
|
||||
positionLineWidth,
|
||||
positionStrokeStyle,
|
||||
hoverPositionStrokeStyle,
|
||||
},
|
||||
position,
|
||||
selection,
|
||||
selection: selection,
|
||||
onSelectionChange,
|
||||
}: Props) => {
|
||||
// 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);
|
||||
|
||||
const [state, dispatch] = useReducer(stateReducer, {
|
||||
...initialState,
|
||||
width,
|
||||
selection,
|
||||
emptySelectionAction,
|
||||
});
|
||||
|
||||
// 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);
|
||||
};
|
||||
}, [mode, newSelection]);
|
||||
}, [state]);
|
||||
|
||||
// trigger onSelectionChange callback.
|
||||
useEffect(() => {
|
||||
if (!state.shouldPublish) {
|
||||
return;
|
||||
}
|
||||
onSelectionChange({
|
||||
selection: state.selection,
|
||||
mode: state.mode,
|
||||
prevMode: state.prevMode,
|
||||
});
|
||||
}, [state]);
|
||||
|
||||
// draw the overview HUD
|
||||
useEffect(() => {
|
||||
|
@ -98,32 +125,38 @@ export const HudCanvas: React.FC<Props> = ({
|
|||
|
||||
// draw selection
|
||||
|
||||
let currentSelection: Selection;
|
||||
if (
|
||||
mode == Mode.Selecting ||
|
||||
mode == Mode.Dragging ||
|
||||
mode == Mode.ResizingStart ||
|
||||
mode == Mode.ResizingEnd
|
||||
) {
|
||||
currentSelection = newSelection;
|
||||
} else {
|
||||
currentSelection = selection;
|
||||
}
|
||||
const currentSelection = state.selection;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = borderStrokeStyle;
|
||||
ctx.lineWidth = borderLineWidth;
|
||||
const alpha = hoverState == HoverState.OverSelection ? '0.15' : '0.13';
|
||||
const alpha =
|
||||
state.hoverState == HoverState.OverSelection ? '0.15' : '0.13';
|
||||
ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`;
|
||||
ctx.rect(
|
||||
currentSelection.start,
|
||||
currentSelection.x1,
|
||||
borderLineWidth,
|
||||
currentSelection.end - currentSelection.start,
|
||||
currentSelection.x2 - currentSelection.x1,
|
||||
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) {
|
||||
|
@ -134,188 +167,41 @@ export const HudCanvas: React.FC<Props> = ({
|
|||
ctx.strokeStyle = positionStrokeStyle;
|
||||
ctx.lineWidth = positionLineWidth;
|
||||
ctx.moveTo(position, 0);
|
||||
ctx.lineWidth = 4;
|
||||
ctx.lineTo(position, canvas.height - 4);
|
||||
ctx.lineWidth = position == 0 ? 8 : 4;
|
||||
ctx.lineTo(position, canvas.height);
|
||||
ctx.stroke();
|
||||
});
|
||||
}, [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;
|
||||
};
|
||||
}, [state, position]);
|
||||
|
||||
const handleMouseDown = (evt: MouseEvent<HTMLCanvasElement>) => {
|
||||
if (mode != Mode.Normal) {
|
||||
if (state.mode != SelectionMode.Normal) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
dispatch({ x: getCanvasX(evt), type: 'mousedown' });
|
||||
};
|
||||
|
||||
const handleMouseMove = (evt: MouseEvent<HTMLCanvasElement>) => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
dispatch({ x: getCanvasX(evt), type: 'mousemove' });
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (mode == Mode.Normal) {
|
||||
if (state.mode == SelectionMode.Normal) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMode(Mode.Normal);
|
||||
setCursor('auto');
|
||||
|
||||
if (newSelection.start == newSelection.end) {
|
||||
handleEmptySelectionAction();
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectionChange({ ...newSelection });
|
||||
dispatch({ x: state.hoverX, type: 'mouseup' });
|
||||
};
|
||||
|
||||
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 handleMouseLeave = () => {
|
||||
dispatch({ x: state.hoverX, type: 'mouseleave' });
|
||||
};
|
||||
|
||||
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}
|
||||
|
|
|
@ -0,0 +1,294 @@
|
|||
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);
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,266 @@
|
|||
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;
|
||||
};
|
|
@ -1,115 +0,0 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { MediaSet } from './generated/media_set';
|
||||
import { Frames, VideoPosition } from './App';
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,155 @@
|
|||
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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -24,7 +24,7 @@ export const SeekBar: React.FC<Props> = ({
|
|||
onPositionChanged,
|
||||
}: Props) => {
|
||||
const [mode, setMode] = useState(Mode.Normal);
|
||||
const [cursor, setCursor] = useState('auto');
|
||||
const [cursor, setCursor] = useState('cursor-auto');
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
// render canvas
|
||||
|
@ -45,12 +45,11 @@ export const SeekBar: React.FC<Props> = ({
|
|||
canvas.width = canvas.height * (canvas.clientWidth / canvas.clientHeight);
|
||||
|
||||
// background
|
||||
ctx.fillStyle = '#444444';
|
||||
ctx.fillStyle = 'transparent';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// seek bar
|
||||
const pixelRatio = canvas.width / canvas.clientWidth;
|
||||
const offset = offsetPixels * pixelRatio;
|
||||
const offset = offsetCanvas(canvas);
|
||||
const width = canvas.width - offset * 2;
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.fillRect(offset, InnerMargin, width, canvas.height - InnerMargin * 2);
|
||||
|
@ -68,23 +67,33 @@ export const SeekBar: React.FC<Props> = ({
|
|||
|
||||
// helpers
|
||||
|
||||
const emitPositionEvent = (evt: MouseEvent<HTMLCanvasElement>) => {
|
||||
const canvas = evt.currentTarget;
|
||||
const { x } = mouseEventToCanvasPoint(evt);
|
||||
const emitPositionEvent = (x: number, canvas: HTMLCanvasElement) => {
|
||||
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(evt);
|
||||
emitPositionEvent(x, canvas);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
|
@ -94,18 +103,35 @@ export const SeekBar: React.FC<Props> = ({
|
|||
};
|
||||
|
||||
const handleMouseMove = (evt: MouseEvent<HTMLCanvasElement>) => {
|
||||
const { y } = mouseEventToCanvasPoint(evt);
|
||||
const canvas = evt.currentTarget;
|
||||
const offset = offsetCanvas(canvas);
|
||||
|
||||
const coords = mouseEventToCanvasPoint(evt);
|
||||
const { y } = coords;
|
||||
let { x } = coords;
|
||||
|
||||
// TODO: improve mouse detection around knob.
|
||||
if (y > InnerMargin && y < LogicalHeight - InnerMargin) {
|
||||
setCursor('pointer');
|
||||
if (
|
||||
x >= offset &&
|
||||
x < canvas.width - offset &&
|
||||
y > InnerMargin &&
|
||||
y < LogicalHeight - InnerMargin
|
||||
) {
|
||||
setCursor('cursor-pointer');
|
||||
} else {
|
||||
setCursor('auto');
|
||||
setCursor('cursor-auto');
|
||||
}
|
||||
|
||||
if (x < offset) {
|
||||
x = offset;
|
||||
}
|
||||
if (x > canvas.width - offset) {
|
||||
x = canvas.width - offset;
|
||||
}
|
||||
|
||||
if (mode == Mode.Normal) return;
|
||||
|
||||
emitPositionEvent(evt);
|
||||
emitPositionEvent(x, canvas);
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
|
@ -116,17 +142,10 @@ export const SeekBar: React.FC<Props> = ({
|
|||
|
||||
// render component
|
||||
|
||||
const styles = {
|
||||
width: '100%',
|
||||
height: '30px',
|
||||
margin: '0 auto',
|
||||
cursor: cursor,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<canvas
|
||||
style={styles}
|
||||
className={`w-full bg-gray-700 h-10 mx-0 my-auto ${cursor}`}
|
||||
ref={canvasRef}
|
||||
width={LogicalWidth}
|
||||
height={LogicalHeight}
|
||||
|
|
|
@ -1,106 +0,0 @@
|
|||
import { MediaSet, MediaSetServiceClientImpl } from './generated/media_set';
|
||||
import { newRPC, VideoPosition } 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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,161 +0,0 @@
|
|||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Frames, VideoPosition, 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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -10,18 +10,10 @@ 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);
|
||||
|
||||
|
@ -71,21 +63,13 @@ 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>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -82,7 +82,9 @@ export interface Duration {
|
|||
nanos: number;
|
||||
}
|
||||
|
||||
const baseDuration: object = { seconds: 0, nanos: 0 };
|
||||
function createBaseDuration(): Duration {
|
||||
return { seconds: 0, nanos: 0 };
|
||||
}
|
||||
|
||||
export const Duration = {
|
||||
encode(
|
||||
|
@ -101,7 +103,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 = { ...baseDuration } as Duration;
|
||||
const message = createBaseDuration();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
|
@ -120,27 +122,22 @@ export const Duration = {
|
|||
},
|
||||
|
||||
fromJSON(object: any): Duration {
|
||||
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;
|
||||
return {
|
||||
seconds: isSet(object.seconds) ? Number(object.seconds) : 0,
|
||||
nanos: isSet(object.nanos) ? Number(object.nanos) : 0,
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: Duration): unknown {
|
||||
const obj: any = {};
|
||||
message.seconds !== undefined && (obj.seconds = message.seconds);
|
||||
message.nanos !== undefined && (obj.nanos = message.nanos);
|
||||
message.seconds !== undefined &&
|
||||
(obj.seconds = Math.round(message.seconds));
|
||||
message.nanos !== undefined && (obj.nanos = Math.round(message.nanos));
|
||||
return obj;
|
||||
},
|
||||
|
||||
fromPartial<I extends Exact<DeepPartial<Duration>, I>>(object: I): Duration {
|
||||
const message = { ...baseDuration } as Duration;
|
||||
const message = createBaseDuration();
|
||||
message.seconds = object.seconds ?? 0;
|
||||
message.nanos = object.nanos ?? 0;
|
||||
return message;
|
||||
|
@ -196,3 +193,7 @@ if (_m0.util.Long !== Long) {
|
|||
_m0.util.Long = Long as any;
|
||||
_m0.configure();
|
||||
}
|
||||
|
||||
function isSet(value: any): boolean {
|
||||
return value !== null && value !== undefined;
|
||||
}
|
||||
|
|
|
@ -44,6 +44,9 @@ 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;
|
||||
|
@ -68,6 +71,7 @@ export interface GetPeaksProgress {
|
|||
peaks: number[];
|
||||
percentComplete: number;
|
||||
url: string;
|
||||
audioFrames: number;
|
||||
}
|
||||
|
||||
export interface GetPeaksForSegmentRequest {
|
||||
|
@ -89,8 +93,6 @@ export interface GetAudioSegmentRequest {
|
|||
}
|
||||
|
||||
export interface GetAudioSegmentProgress {
|
||||
mimeType: string;
|
||||
message: string;
|
||||
percentComplete: number;
|
||||
audioData: Uint8Array;
|
||||
}
|
||||
|
@ -114,18 +116,24 @@ export interface GetVideoThumbnailResponse {
|
|||
height: number;
|
||||
}
|
||||
|
||||
const baseMediaSet: object = {
|
||||
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: "",
|
||||
};
|
||||
}
|
||||
|
||||
export const MediaSet = {
|
||||
encode(
|
||||
|
@ -138,6 +146,15 @@ 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);
|
||||
}
|
||||
|
@ -171,7 +188,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 = { ...baseMediaSet } as MediaSet;
|
||||
const message = createBaseMediaSet();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
|
@ -181,6 +198,15 @@ 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;
|
||||
|
@ -217,67 +243,58 @@ export const MediaSet = {
|
|||
},
|
||||
|
||||
fromJSON(object: any): MediaSet {
|
||||
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
|
||||
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)
|
||||
? Number(object.audioChannels)
|
||||
: 0;
|
||||
message.audioApproxFrames =
|
||||
object.audioApproxFrames !== undefined &&
|
||||
object.audioApproxFrames !== null
|
||||
: 0,
|
||||
audioApproxFrames: isSet(object.audioApproxFrames)
|
||||
? Number(object.audioApproxFrames)
|
||||
: 0;
|
||||
message.audioFrames =
|
||||
object.audioFrames !== undefined && object.audioFrames !== null
|
||||
? Number(object.audioFrames)
|
||||
: 0;
|
||||
message.audioSampleRate =
|
||||
object.audioSampleRate !== undefined && object.audioSampleRate !== null
|
||||
: 0,
|
||||
audioFrames: isSet(object.audioFrames) ? Number(object.audioFrames) : 0,
|
||||
audioSampleRate: isSet(object.audioSampleRate)
|
||||
? Number(object.audioSampleRate)
|
||||
: 0;
|
||||
message.audioYoutubeItag =
|
||||
object.audioYoutubeItag !== undefined && object.audioYoutubeItag !== null
|
||||
: 0,
|
||||
audioYoutubeItag: isSet(object.audioYoutubeItag)
|
||||
? Number(object.audioYoutubeItag)
|
||||
: 0;
|
||||
message.audioMimeType =
|
||||
object.audioMimeType !== undefined && object.audioMimeType !== null
|
||||
: 0,
|
||||
audioMimeType: isSet(object.audioMimeType)
|
||||
? String(object.audioMimeType)
|
||||
: "";
|
||||
message.videoDuration =
|
||||
object.videoDuration !== undefined && object.videoDuration !== null
|
||||
: "",
|
||||
videoDuration: isSet(object.videoDuration)
|
||||
? Duration.fromJSON(object.videoDuration)
|
||||
: undefined;
|
||||
message.videoYoutubeItag =
|
||||
object.videoYoutubeItag !== undefined && object.videoYoutubeItag !== null
|
||||
: undefined,
|
||||
videoYoutubeItag: isSet(object.videoYoutubeItag)
|
||||
? Number(object.videoYoutubeItag)
|
||||
: 0;
|
||||
message.videoMimeType =
|
||||
object.videoMimeType !== undefined && object.videoMimeType !== null
|
||||
: 0,
|
||||
videoMimeType: isSet(object.videoMimeType)
|
||||
? 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 = message.audioChannels);
|
||||
(obj.audioChannels = Math.round(message.audioChannels));
|
||||
message.audioApproxFrames !== undefined &&
|
||||
(obj.audioApproxFrames = message.audioApproxFrames);
|
||||
(obj.audioApproxFrames = Math.round(message.audioApproxFrames));
|
||||
message.audioFrames !== undefined &&
|
||||
(obj.audioFrames = message.audioFrames);
|
||||
(obj.audioFrames = Math.round(message.audioFrames));
|
||||
message.audioSampleRate !== undefined &&
|
||||
(obj.audioSampleRate = message.audioSampleRate);
|
||||
(obj.audioSampleRate = Math.round(message.audioSampleRate));
|
||||
message.audioYoutubeItag !== undefined &&
|
||||
(obj.audioYoutubeItag = message.audioYoutubeItag);
|
||||
(obj.audioYoutubeItag = Math.round(message.audioYoutubeItag));
|
||||
message.audioMimeType !== undefined &&
|
||||
(obj.audioMimeType = message.audioMimeType);
|
||||
message.videoDuration !== undefined &&
|
||||
|
@ -285,16 +302,19 @@ export const MediaSet = {
|
|||
? Duration.toJSON(message.videoDuration)
|
||||
: undefined);
|
||||
message.videoYoutubeItag !== undefined &&
|
||||
(obj.videoYoutubeItag = message.videoYoutubeItag);
|
||||
(obj.videoYoutubeItag = Math.round(message.videoYoutubeItag));
|
||||
message.videoMimeType !== undefined &&
|
||||
(obj.videoMimeType = message.videoMimeType);
|
||||
return obj;
|
||||
},
|
||||
|
||||
fromPartial<I extends Exact<DeepPartial<MediaSet>, I>>(object: I): MediaSet {
|
||||
const message = { ...baseMediaSet } as MediaSet;
|
||||
const message = createBaseMediaSet();
|
||||
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;
|
||||
|
@ -311,7 +331,9 @@ export const MediaSet = {
|
|||
},
|
||||
};
|
||||
|
||||
const baseGetRequest: object = { youtubeId: "" };
|
||||
function createBaseGetRequest(): GetRequest {
|
||||
return { youtubeId: "" };
|
||||
}
|
||||
|
||||
export const GetRequest = {
|
||||
encode(
|
||||
|
@ -327,7 +349,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 = { ...baseGetRequest } as GetRequest;
|
||||
const message = createBaseGetRequest();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
|
@ -343,12 +365,9 @@ export const GetRequest = {
|
|||
},
|
||||
|
||||
fromJSON(object: any): GetRequest {
|
||||
const message = { ...baseGetRequest } as GetRequest;
|
||||
message.youtubeId =
|
||||
object.youtubeId !== undefined && object.youtubeId !== null
|
||||
? String(object.youtubeId)
|
||||
: "";
|
||||
return message;
|
||||
return {
|
||||
youtubeId: isSet(object.youtubeId) ? String(object.youtubeId) : "",
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: GetRequest): unknown {
|
||||
|
@ -360,13 +379,15 @@ export const GetRequest = {
|
|||
fromPartial<I extends Exact<DeepPartial<GetRequest>, I>>(
|
||||
object: I
|
||||
): GetRequest {
|
||||
const message = { ...baseGetRequest } as GetRequest;
|
||||
const message = createBaseGetRequest();
|
||||
message.youtubeId = object.youtubeId ?? "";
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
const baseGetPeaksRequest: object = { id: "", numBins: 0 };
|
||||
function createBaseGetPeaksRequest(): GetPeaksRequest {
|
||||
return { id: "", numBins: 0 };
|
||||
}
|
||||
|
||||
export const GetPeaksRequest = {
|
||||
encode(
|
||||
|
@ -385,7 +406,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 = { ...baseGetPeaksRequest } as GetPeaksRequest;
|
||||
const message = createBaseGetPeaksRequest();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
|
@ -404,34 +425,33 @@ export const GetPeaksRequest = {
|
|||
},
|
||||
|
||||
fromJSON(object: any): GetPeaksRequest {
|
||||
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;
|
||||
return {
|
||||
id: isSet(object.id) ? String(object.id) : "",
|
||||
numBins: isSet(object.numBins) ? Number(object.numBins) : 0,
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: GetPeaksRequest): unknown {
|
||||
const obj: any = {};
|
||||
message.id !== undefined && (obj.id = message.id);
|
||||
message.numBins !== undefined && (obj.numBins = message.numBins);
|
||||
message.numBins !== undefined &&
|
||||
(obj.numBins = Math.round(message.numBins));
|
||||
return obj;
|
||||
},
|
||||
|
||||
fromPartial<I extends Exact<DeepPartial<GetPeaksRequest>, I>>(
|
||||
object: I
|
||||
): GetPeaksRequest {
|
||||
const message = { ...baseGetPeaksRequest } as GetPeaksRequest;
|
||||
const message = createBaseGetPeaksRequest();
|
||||
message.id = object.id ?? "";
|
||||
message.numBins = object.numBins ?? 0;
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
const baseGetPeaksProgress: object = { peaks: 0, percentComplete: 0, url: "" };
|
||||
function createBaseGetPeaksProgress(): GetPeaksProgress {
|
||||
return { peaks: [], percentComplete: 0, url: "", audioFrames: 0 };
|
||||
}
|
||||
|
||||
export const GetPeaksProgress = {
|
||||
encode(
|
||||
|
@ -449,14 +469,16 @@ 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 = { ...baseGetPeaksProgress } as GetPeaksProgress;
|
||||
message.peaks = [];
|
||||
const message = createBaseGetPeaksProgress();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
|
@ -476,6 +498,9 @@ 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;
|
||||
|
@ -485,47 +510,48 @@ export const GetPeaksProgress = {
|
|||
},
|
||||
|
||||
fromJSON(object: any): GetPeaksProgress {
|
||||
const message = { ...baseGetPeaksProgress } as GetPeaksProgress;
|
||||
message.peaks = (object.peaks ?? []).map((e: any) => Number(e));
|
||||
message.percentComplete =
|
||||
object.percentComplete !== undefined && object.percentComplete !== null
|
||||
return {
|
||||
peaks: Array.isArray(object?.peaks)
|
||||
? object.peaks.map((e: any) => Number(e))
|
||||
: [],
|
||||
percentComplete: isSet(object.percentComplete)
|
||||
? Number(object.percentComplete)
|
||||
: 0;
|
||||
message.url =
|
||||
object.url !== undefined && object.url !== null ? String(object.url) : "";
|
||||
return message;
|
||||
: 0,
|
||||
url: isSet(object.url) ? String(object.url) : "",
|
||||
audioFrames: isSet(object.audioFrames) ? Number(object.audioFrames) : 0,
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: GetPeaksProgress): unknown {
|
||||
const obj: any = {};
|
||||
if (message.peaks) {
|
||||
obj.peaks = message.peaks.map((e) => e);
|
||||
obj.peaks = message.peaks.map((e) => Math.round(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 = { ...baseGetPeaksProgress } as GetPeaksProgress;
|
||||
const message = createBaseGetPeaksProgress();
|
||||
message.peaks = object.peaks?.map((e) => e) || [];
|
||||
message.percentComplete = object.percentComplete ?? 0;
|
||||
message.url = object.url ?? "";
|
||||
message.audioFrames = object.audioFrames ?? 0;
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
const baseGetPeaksForSegmentRequest: object = {
|
||||
id: "",
|
||||
numBins: 0,
|
||||
startFrame: 0,
|
||||
endFrame: 0,
|
||||
};
|
||||
function createBaseGetPeaksForSegmentRequest(): GetPeaksForSegmentRequest {
|
||||
return { id: "", numBins: 0, startFrame: 0, endFrame: 0 };
|
||||
}
|
||||
|
||||
export const GetPeaksForSegmentRequest = {
|
||||
encode(
|
||||
|
@ -553,9 +579,7 @@ 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 = {
|
||||
...baseGetPeaksForSegmentRequest,
|
||||
} as GetPeaksForSegmentRequest;
|
||||
const message = createBaseGetPeaksForSegmentRequest();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
|
@ -580,41 +604,30 @@ export const GetPeaksForSegmentRequest = {
|
|||
},
|
||||
|
||||
fromJSON(object: any): GetPeaksForSegmentRequest {
|
||||
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;
|
||||
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,
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: GetPeaksForSegmentRequest): unknown {
|
||||
const obj: any = {};
|
||||
message.id !== undefined && (obj.id = message.id);
|
||||
message.numBins !== undefined && (obj.numBins = message.numBins);
|
||||
message.startFrame !== undefined && (obj.startFrame = message.startFrame);
|
||||
message.endFrame !== undefined && (obj.endFrame = message.endFrame);
|
||||
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));
|
||||
return obj;
|
||||
},
|
||||
|
||||
fromPartial<I extends Exact<DeepPartial<GetPeaksForSegmentRequest>, I>>(
|
||||
object: I
|
||||
): GetPeaksForSegmentRequest {
|
||||
const message = {
|
||||
...baseGetPeaksForSegmentRequest,
|
||||
} as GetPeaksForSegmentRequest;
|
||||
const message = createBaseGetPeaksForSegmentRequest();
|
||||
message.id = object.id ?? "";
|
||||
message.numBins = object.numBins ?? 0;
|
||||
message.startFrame = object.startFrame ?? 0;
|
||||
|
@ -623,7 +636,9 @@ export const GetPeaksForSegmentRequest = {
|
|||
},
|
||||
};
|
||||
|
||||
const baseGetPeaksForSegmentResponse: object = { peaks: 0 };
|
||||
function createBaseGetPeaksForSegmentResponse(): GetPeaksForSegmentResponse {
|
||||
return { peaks: [] };
|
||||
}
|
||||
|
||||
export const GetPeaksForSegmentResponse = {
|
||||
encode(
|
||||
|
@ -644,10 +659,7 @@ 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 = {
|
||||
...baseGetPeaksForSegmentResponse,
|
||||
} as GetPeaksForSegmentResponse;
|
||||
message.peaks = [];
|
||||
const message = createBaseGetPeaksForSegmentResponse();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
|
@ -670,17 +682,17 @@ export const GetPeaksForSegmentResponse = {
|
|||
},
|
||||
|
||||
fromJSON(object: any): GetPeaksForSegmentResponse {
|
||||
const message = {
|
||||
...baseGetPeaksForSegmentResponse,
|
||||
} as GetPeaksForSegmentResponse;
|
||||
message.peaks = (object.peaks ?? []).map((e: any) => Number(e));
|
||||
return message;
|
||||
return {
|
||||
peaks: Array.isArray(object?.peaks)
|
||||
? object.peaks.map((e: any) => Number(e))
|
||||
: [],
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: GetPeaksForSegmentResponse): unknown {
|
||||
const obj: any = {};
|
||||
if (message.peaks) {
|
||||
obj.peaks = message.peaks.map((e) => e);
|
||||
obj.peaks = message.peaks.map((e) => Math.round(e));
|
||||
} else {
|
||||
obj.peaks = [];
|
||||
}
|
||||
|
@ -690,20 +702,15 @@ export const GetPeaksForSegmentResponse = {
|
|||
fromPartial<I extends Exact<DeepPartial<GetPeaksForSegmentResponse>, I>>(
|
||||
object: I
|
||||
): GetPeaksForSegmentResponse {
|
||||
const message = {
|
||||
...baseGetPeaksForSegmentResponse,
|
||||
} as GetPeaksForSegmentResponse;
|
||||
const message = createBaseGetPeaksForSegmentResponse();
|
||||
message.peaks = object.peaks?.map((e) => e) || [];
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
const baseGetAudioSegmentRequest: object = {
|
||||
id: "",
|
||||
startFrame: 0,
|
||||
endFrame: 0,
|
||||
format: 0,
|
||||
};
|
||||
function createBaseGetAudioSegmentRequest(): GetAudioSegmentRequest {
|
||||
return { id: "", startFrame: 0, endFrame: 0, format: 0 };
|
||||
}
|
||||
|
||||
export const GetAudioSegmentRequest = {
|
||||
encode(
|
||||
|
@ -731,7 +738,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 = { ...baseGetAudioSegmentRequest } as GetAudioSegmentRequest;
|
||||
const message = createBaseGetAudioSegmentRequest();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
|
@ -756,29 +763,21 @@ export const GetAudioSegmentRequest = {
|
|||
},
|
||||
|
||||
fromJSON(object: any): GetAudioSegmentRequest {
|
||||
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;
|
||||
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,
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: GetAudioSegmentRequest): unknown {
|
||||
const obj: any = {};
|
||||
message.id !== undefined && (obj.id = message.id);
|
||||
message.startFrame !== undefined && (obj.startFrame = message.startFrame);
|
||||
message.endFrame !== undefined && (obj.endFrame = message.endFrame);
|
||||
message.startFrame !== undefined &&
|
||||
(obj.startFrame = Math.round(message.startFrame));
|
||||
message.endFrame !== undefined &&
|
||||
(obj.endFrame = Math.round(message.endFrame));
|
||||
message.format !== undefined &&
|
||||
(obj.format = audioFormatToJSON(message.format));
|
||||
return obj;
|
||||
|
@ -787,7 +786,7 @@ export const GetAudioSegmentRequest = {
|
|||
fromPartial<I extends Exact<DeepPartial<GetAudioSegmentRequest>, I>>(
|
||||
object: I
|
||||
): GetAudioSegmentRequest {
|
||||
const message = { ...baseGetAudioSegmentRequest } as GetAudioSegmentRequest;
|
||||
const message = createBaseGetAudioSegmentRequest();
|
||||
message.id = object.id ?? "";
|
||||
message.startFrame = object.startFrame ?? 0;
|
||||
message.endFrame = object.endFrame ?? 0;
|
||||
|
@ -796,23 +795,15 @@ export const GetAudioSegmentRequest = {
|
|||
},
|
||||
};
|
||||
|
||||
const baseGetAudioSegmentProgress: object = {
|
||||
mimeType: "",
|
||||
message: "",
|
||||
percentComplete: 0,
|
||||
};
|
||||
function createBaseGetAudioSegmentProgress(): GetAudioSegmentProgress {
|
||||
return { percentComplete: 0, audioData: new Uint8Array() };
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
@ -828,19 +819,10 @@ 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 = {
|
||||
...baseGetAudioSegmentProgress,
|
||||
} as GetAudioSegmentProgress;
|
||||
message.audioData = new Uint8Array();
|
||||
const message = createBaseGetAudioSegmentProgress();
|
||||
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;
|
||||
|
@ -856,32 +838,18 @@ export const GetAudioSegmentProgress = {
|
|||
},
|
||||
|
||||
fromJSON(object: any): GetAudioSegmentProgress {
|
||||
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
|
||||
return {
|
||||
percentComplete: isSet(object.percentComplete)
|
||||
? Number(object.percentComplete)
|
||||
: 0;
|
||||
message.audioData =
|
||||
object.audioData !== undefined && object.audioData !== null
|
||||
: 0,
|
||||
audioData: isSet(object.audioData)
|
||||
? bytesFromBase64(object.audioData)
|
||||
: new Uint8Array();
|
||||
return message;
|
||||
: new Uint8Array(),
|
||||
};
|
||||
},
|
||||
|
||||
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 &&
|
||||
|
@ -894,18 +862,16 @@ export const GetAudioSegmentProgress = {
|
|||
fromPartial<I extends Exact<DeepPartial<GetAudioSegmentProgress>, I>>(
|
||||
object: I
|
||||
): GetAudioSegmentProgress {
|
||||
const message = {
|
||||
...baseGetAudioSegmentProgress,
|
||||
} as GetAudioSegmentProgress;
|
||||
message.mimeType = object.mimeType ?? "";
|
||||
message.message = object.message ?? "";
|
||||
const message = createBaseGetAudioSegmentProgress();
|
||||
message.percentComplete = object.percentComplete ?? 0;
|
||||
message.audioData = object.audioData ?? new Uint8Array();
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
const baseGetVideoRequest: object = { id: "" };
|
||||
function createBaseGetVideoRequest(): GetVideoRequest {
|
||||
return { id: "" };
|
||||
}
|
||||
|
||||
export const GetVideoRequest = {
|
||||
encode(
|
||||
|
@ -921,7 +887,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 = { ...baseGetVideoRequest } as GetVideoRequest;
|
||||
const message = createBaseGetVideoRequest();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
|
@ -937,10 +903,9 @@ export const GetVideoRequest = {
|
|||
},
|
||||
|
||||
fromJSON(object: any): GetVideoRequest {
|
||||
const message = { ...baseGetVideoRequest } as GetVideoRequest;
|
||||
message.id =
|
||||
object.id !== undefined && object.id !== null ? String(object.id) : "";
|
||||
return message;
|
||||
return {
|
||||
id: isSet(object.id) ? String(object.id) : "",
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: GetVideoRequest): unknown {
|
||||
|
@ -952,13 +917,15 @@ export const GetVideoRequest = {
|
|||
fromPartial<I extends Exact<DeepPartial<GetVideoRequest>, I>>(
|
||||
object: I
|
||||
): GetVideoRequest {
|
||||
const message = { ...baseGetVideoRequest } as GetVideoRequest;
|
||||
const message = createBaseGetVideoRequest();
|
||||
message.id = object.id ?? "";
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
const baseGetVideoProgress: object = { percentComplete: 0, url: "" };
|
||||
function createBaseGetVideoProgress(): GetVideoProgress {
|
||||
return { percentComplete: 0, url: "" };
|
||||
}
|
||||
|
||||
export const GetVideoProgress = {
|
||||
encode(
|
||||
|
@ -977,7 +944,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 = { ...baseGetVideoProgress } as GetVideoProgress;
|
||||
const message = createBaseGetVideoProgress();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
|
@ -996,14 +963,12 @@ export const GetVideoProgress = {
|
|||
},
|
||||
|
||||
fromJSON(object: any): GetVideoProgress {
|
||||
const message = { ...baseGetVideoProgress } as GetVideoProgress;
|
||||
message.percentComplete =
|
||||
object.percentComplete !== undefined && object.percentComplete !== null
|
||||
return {
|
||||
percentComplete: isSet(object.percentComplete)
|
||||
? Number(object.percentComplete)
|
||||
: 0;
|
||||
message.url =
|
||||
object.url !== undefined && object.url !== null ? String(object.url) : "";
|
||||
return message;
|
||||
: 0,
|
||||
url: isSet(object.url) ? String(object.url) : "",
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: GetVideoProgress): unknown {
|
||||
|
@ -1017,14 +982,16 @@ export const GetVideoProgress = {
|
|||
fromPartial<I extends Exact<DeepPartial<GetVideoProgress>, I>>(
|
||||
object: I
|
||||
): GetVideoProgress {
|
||||
const message = { ...baseGetVideoProgress } as GetVideoProgress;
|
||||
const message = createBaseGetVideoProgress();
|
||||
message.percentComplete = object.percentComplete ?? 0;
|
||||
message.url = object.url ?? "";
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
const baseGetVideoThumbnailRequest: object = { id: "" };
|
||||
function createBaseGetVideoThumbnailRequest(): GetVideoThumbnailRequest {
|
||||
return { id: "" };
|
||||
}
|
||||
|
||||
export const GetVideoThumbnailRequest = {
|
||||
encode(
|
||||
|
@ -1043,9 +1010,7 @@ 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 = {
|
||||
...baseGetVideoThumbnailRequest,
|
||||
} as GetVideoThumbnailRequest;
|
||||
const message = createBaseGetVideoThumbnailRequest();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
|
@ -1061,12 +1026,9 @@ export const GetVideoThumbnailRequest = {
|
|||
},
|
||||
|
||||
fromJSON(object: any): GetVideoThumbnailRequest {
|
||||
const message = {
|
||||
...baseGetVideoThumbnailRequest,
|
||||
} as GetVideoThumbnailRequest;
|
||||
message.id =
|
||||
object.id !== undefined && object.id !== null ? String(object.id) : "";
|
||||
return message;
|
||||
return {
|
||||
id: isSet(object.id) ? String(object.id) : "",
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: GetVideoThumbnailRequest): unknown {
|
||||
|
@ -1078,15 +1040,15 @@ export const GetVideoThumbnailRequest = {
|
|||
fromPartial<I extends Exact<DeepPartial<GetVideoThumbnailRequest>, I>>(
|
||||
object: I
|
||||
): GetVideoThumbnailRequest {
|
||||
const message = {
|
||||
...baseGetVideoThumbnailRequest,
|
||||
} as GetVideoThumbnailRequest;
|
||||
const message = createBaseGetVideoThumbnailRequest();
|
||||
message.id = object.id ?? "";
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
const baseGetVideoThumbnailResponse: object = { width: 0, height: 0 };
|
||||
function createBaseGetVideoThumbnailResponse(): GetVideoThumbnailResponse {
|
||||
return { image: new Uint8Array(), width: 0, height: 0 };
|
||||
}
|
||||
|
||||
export const GetVideoThumbnailResponse = {
|
||||
encode(
|
||||
|
@ -1111,10 +1073,7 @@ 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 = {
|
||||
...baseGetVideoThumbnailResponse,
|
||||
} as GetVideoThumbnailResponse;
|
||||
message.image = new Uint8Array();
|
||||
const message = createBaseGetVideoThumbnailResponse();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
|
@ -1136,22 +1095,13 @@ export const GetVideoThumbnailResponse = {
|
|||
},
|
||||
|
||||
fromJSON(object: any): GetVideoThumbnailResponse {
|
||||
const message = {
|
||||
...baseGetVideoThumbnailResponse,
|
||||
} as GetVideoThumbnailResponse;
|
||||
message.image =
|
||||
object.image !== undefined && object.image !== null
|
||||
return {
|
||||
image: isSet(object.image)
|
||||
? bytesFromBase64(object.image)
|
||||
: 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;
|
||||
: new Uint8Array(),
|
||||
width: isSet(object.width) ? Number(object.width) : 0,
|
||||
height: isSet(object.height) ? Number(object.height) : 0,
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: GetVideoThumbnailResponse): unknown {
|
||||
|
@ -1160,17 +1110,15 @@ export const GetVideoThumbnailResponse = {
|
|||
(obj.image = base64FromBytes(
|
||||
message.image !== undefined ? message.image : new Uint8Array()
|
||||
));
|
||||
message.width !== undefined && (obj.width = message.width);
|
||||
message.height !== undefined && (obj.height = message.height);
|
||||
message.width !== undefined && (obj.width = Math.round(message.width));
|
||||
message.height !== undefined && (obj.height = Math.round(message.height));
|
||||
return obj;
|
||||
},
|
||||
|
||||
fromPartial<I extends Exact<DeepPartial<GetVideoThumbnailResponse>, I>>(
|
||||
object: I
|
||||
): GetVideoThumbnailResponse {
|
||||
const message = {
|
||||
...baseGetVideoThumbnailResponse,
|
||||
} as GetVideoThumbnailResponse;
|
||||
const message = createBaseGetVideoThumbnailResponse();
|
||||
message.image = object.image ?? new Uint8Array();
|
||||
message.width = object.width ?? 0;
|
||||
message.height = object.height ?? 0;
|
||||
|
@ -1612,3 +1560,7 @@ if (_m0.util.Long !== Long) {
|
|||
_m0.util.Long = Long as any;
|
||||
_m0.configure();
|
||||
}
|
||||
|
||||
function isSet(value: any): boolean {
|
||||
return value !== null && value !== undefined;
|
||||
}
|
||||
|
|
|
@ -13,7 +13,13 @@
|
|||
|
||||
var jspb = require('google-protobuf');
|
||||
var goog = jspb;
|
||||
var global = Function('return this')();
|
||||
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 google_protobuf_duration_pb = require('google-protobuf/google/protobuf/duration_pb.js');
|
||||
goog.object.extend(proto, google_protobuf_duration_pb);
|
||||
|
@ -316,6 +322,9 @@ 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),
|
||||
|
@ -369,6 +378,18 @@ 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);
|
||||
|
@ -449,6 +470,27 @@ 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(
|
||||
|
@ -552,6 +594,60 @@ 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}
|
||||
|
@ -1064,7 +1160,8 @@ 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, "")
|
||||
url: jspb.Message.getFieldWithDefault(msg, 3, ""),
|
||||
audioFrames: jspb.Message.getFieldWithDefault(msg, 4, 0)
|
||||
};
|
||||
|
||||
if (includeInstance) {
|
||||
|
@ -1115,6 +1212,10 @@ 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;
|
||||
|
@ -1165,6 +1266,13 @@ proto.media_set.GetPeaksProgress.serializeBinaryToWriter = function(message, wri
|
|||
f
|
||||
);
|
||||
}
|
||||
f = message.getAudioFrames();
|
||||
if (f !== 0) {
|
||||
writer.writeInt64(
|
||||
4,
|
||||
f
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
@ -1241,6 +1349,24 @@ 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);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
@ -1871,8 +1997,6 @@ 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()
|
||||
};
|
||||
|
@ -1911,14 +2035,6 @@ 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);
|
||||
|
@ -1956,20 +2072,6 @@ 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(
|
||||
|
@ -1987,42 +2089,6 @@ 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}
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
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);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,11 @@
|
|||
function constrainNumeric(x: number, max: number): number {
|
||||
if (x < 0) {
|
||||
return 0;
|
||||
}
|
||||
if (x > max) {
|
||||
return max;
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
export default constrainNumeric;
|
|
@ -0,0 +1,18 @@
|
|||
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);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,16 @@
|
|||
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;
|
|
@ -0,0 +1,29 @@
|
|||
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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,11 @@
|
|||
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;
|
|
@ -0,0 +1,13 @@
|
|||
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);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,10 @@
|
|||
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;
|
|
@ -0,0 +1,49 @@
|
|||
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');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,17 @@
|
|||
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;
|
|
@ -0,0 +1,273 @@
|
|||
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();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,104 @@
|
|||
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;
|
||||
}
|
|
@ -1,3 +1,7 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
module.exports = {
|
||||
content: ['./src/**/*.{js,jsx,ts,tsx}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
10002
frontend/yarn.lock
10002
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
|
@ -10,6 +10,9 @@ 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;
|
||||
|
@ -36,6 +39,7 @@ message GetPeaksProgress {
|
|||
repeated int32 peaks = 1;
|
||||
float percent_complete = 2;
|
||||
string url = 3;
|
||||
int64 audio_frames = 4;
|
||||
}
|
||||
|
||||
message GetPeaksForSegmentRequest {
|
||||
|
|
Loading…
Reference in New Issue