Compare commits
1 Commits
main
...
feature/yo
Author | SHA1 | Date |
---|---|---|
Michael Evans | 374137256e |
11
.drone.yml
11
.drone.yml
|
@ -1,16 +1,17 @@
|
||||||
---
|
---
|
||||||
kind: pipeline
|
kind: pipeline
|
||||||
type: kubernetes
|
type: docker
|
||||||
name: default
|
name: default
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: backend-go1.19
|
- name: backend
|
||||||
image: golang:1.19
|
image: golang:1.17
|
||||||
commands:
|
commands:
|
||||||
- cd backend/
|
- cd backend/
|
||||||
|
- go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||||
- go build ./...
|
- go build ./...
|
||||||
- go vet ./...
|
- go vet ./...
|
||||||
# - go run honnef.co/go/tools/cmd/staticcheck@latest ./...
|
- staticcheck ./...
|
||||||
- go test -bench=. -benchmem -cover ./...
|
- go test -bench=. -benchmem -cover ./...
|
||||||
|
|
||||||
- name: frontend
|
- name: frontend
|
||||||
|
@ -19,4 +20,4 @@ steps:
|
||||||
- cd frontend
|
- cd frontend
|
||||||
- yarn
|
- yarn
|
||||||
- yarn build
|
- yarn build
|
||||||
- yarn test
|
- yarn test
|
|
@ -11,7 +11,7 @@ ENV REACT_APP_API_URL=$API_URL
|
||||||
RUN yarn install
|
RUN yarn install
|
||||||
RUN yarn build
|
RUN yarn build
|
||||||
|
|
||||||
FROM golang:1.18beta1-alpine3.14 as go-builder
|
FROM golang:1.17.3-alpine3.14 as go-builder
|
||||||
ENV GOPATH ""
|
ENV GOPATH ""
|
||||||
|
|
||||||
RUN go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
|
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=go-builder /root/go/bin/migrate /bin/migrate
|
||||||
COPY --from=node-builder /app/build /app/assets
|
COPY --from=node-builder /app/build /app/assets
|
||||||
|
|
||||||
ENV CLIPPER_ASSETS_HTTP_ROOT "/app/assets"
|
ENV ASSETS_HTTP_ROOT "/app/assets"
|
||||||
|
|
||||||
ENTRYPOINT ["/bin/clipper"]
|
ENTRYPOINT ["/bin/clipper"]
|
||||||
|
|
|
@ -1,39 +1,30 @@
|
||||||
CLIPPER_ENV=development # or production
|
ENV=development # or production
|
||||||
|
|
||||||
CLIPPER_BIND_ADDR=localhost:8888
|
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.
|
# PostgreSQL connection string.
|
||||||
CLIPPER_DATABASE_URL=
|
DATABASE_URL=
|
||||||
|
|
||||||
# Optional. If set, files in this location will be served over HTTP at /.
|
# Optional. If set, files in this location will be served over HTTP at /.
|
||||||
# Mostly useful for deployment.
|
# Mostly useful for deployment.
|
||||||
CLIPPER_ASSETS_HTTP_ROOT=
|
ASSETS_HTTP_ROOT=
|
||||||
|
|
||||||
# Set the store type - either s3 or filesystem. Defaults to filesystem. The S3
|
# Set the store type - either s3 or filesystem. Defaults to filesystem. The S3
|
||||||
# store is recommended for production usage.
|
# store is recommended for production usage.
|
||||||
#
|
#
|
||||||
# NOTE: Enabling the file system store will disable serving assets over HTTP.
|
# NOTE: Enabling the file system store will disable serving assets over HTTP.
|
||||||
CLIPPER_FILE_STORE=filesystem
|
FILE_STORE=filesystem
|
||||||
|
|
||||||
|
|
||||||
# The base URL used for serving file store assets.
|
# The base URL used for serving file store assets.
|
||||||
# Example: http://localhost:8888
|
# Example: http://localhost:8888
|
||||||
CLIPPER_FILE_STORE_HTTP_BASE_URL=
|
FILE_STORE_HTTP_BASE_URL=
|
||||||
|
|
||||||
# The root directory for the file system store.
|
# The root directory for the file system store.
|
||||||
CLIPPER_FILE_STORE_HTTP_ROOT=data/
|
FILE_STORE_HTTP_ROOT=data/
|
||||||
|
|
||||||
# AWS credentials, required for the S3 store.
|
# AWS credentials, required for the S3 store.
|
||||||
AWS_ACCESS_KEY_ID=
|
AWS_ACCESS_KEY_ID=
|
||||||
AWS_SECRET_ACCESS_KEY=
|
AWS_SECRET_ACCESS_KEY=
|
||||||
AWS_REGION=
|
AWS_REGION=
|
||||||
CLIPPER_S3_BUCKET=
|
S3_BUCKET=
|
||||||
|
|
||||||
# The number of concurrent FFMPEG processes that will be permitted.
|
|
||||||
# Defaults to runtime.NumCPU():
|
|
||||||
CLIPPER_FFMPEG_WORKER_POOL_SIZE=
|
|
||||||
|
|
|
@ -19,9 +19,8 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultTimeout = 600 * time.Second
|
defaultTimeout = 600 * time.Second
|
||||||
defaultURLExpiry = time.Hour
|
defaultURLExpiry = time.Hour
|
||||||
maximumWorkerQueueSize = 32
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
@ -55,17 +54,12 @@ func main() {
|
||||||
log.Fatal(err)
|
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{
|
log.Fatal(server.Start(server.Options{
|
||||||
Config: config,
|
Config: config,
|
||||||
Timeout: defaultTimeout,
|
Timeout: defaultTimeout,
|
||||||
Store: store,
|
Store: store,
|
||||||
YoutubeClient: &youtubeClient,
|
YoutubeClient: &youtubeClient,
|
||||||
FileStore: fileStore,
|
FileStore: fileStore,
|
||||||
WorkerPool: wp,
|
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,11 +3,7 @@ package config
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Environment int
|
type Environment int
|
||||||
|
@ -20,12 +16,10 @@ const (
|
||||||
type FileStore int
|
type FileStore int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
FileSystemStore FileStore = iota
|
FileSystemStore = iota
|
||||||
S3Store
|
S3Store
|
||||||
)
|
)
|
||||||
|
|
||||||
const DefaultBindAddr = "localhost:8888"
|
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Environment Environment
|
Environment Environment
|
||||||
BindAddr string
|
BindAddr string
|
||||||
|
@ -34,73 +28,58 @@ type Config struct {
|
||||||
DatabaseURL string
|
DatabaseURL string
|
||||||
FileStore FileStore
|
FileStore FileStore
|
||||||
FileStoreHTTPRoot string
|
FileStoreHTTPRoot string
|
||||||
FileStoreHTTPBaseURL *url.URL
|
FileStoreHTTPBaseURL string
|
||||||
AWSAccessKeyID string
|
AWSAccessKeyID string
|
||||||
AWSSecretAccessKey string
|
AWSSecretAccessKey string
|
||||||
AWSRegion string
|
AWSRegion string
|
||||||
S3Bucket string
|
S3Bucket string
|
||||||
AssetsHTTPRoot 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) {
|
func NewFromEnv() (Config, error) {
|
||||||
envVarName := envPrefix("ENV")
|
envString := os.Getenv("ENV")
|
||||||
envString := os.Getenv(envVarName)
|
|
||||||
var env Environment
|
var env Environment
|
||||||
switch envString {
|
switch envString {
|
||||||
case "production":
|
case "production":
|
||||||
env = EnvProduction
|
env = EnvProduction
|
||||||
case "development", "":
|
case "development":
|
||||||
env = EnvDevelopment
|
env = EnvDevelopment
|
||||||
|
case "":
|
||||||
|
return Config{}, errors.New("ENV not set")
|
||||||
default:
|
default:
|
||||||
return Config{}, fmt.Errorf("invalid %s value: %s", envVarName, envString)
|
return Config{}, fmt.Errorf("invalid ENV value: %s", envString)
|
||||||
}
|
}
|
||||||
|
|
||||||
bindAddr := getenvPrefix("BIND_ADDR")
|
bindAddr := os.Getenv("BIND_ADDR")
|
||||||
if bindAddr == "" {
|
if bindAddr == "" {
|
||||||
bindAddr = DefaultBindAddr
|
bindAddr = "localhost:8888"
|
||||||
}
|
}
|
||||||
|
|
||||||
tlsCertFileName := envPrefix("TLS_CERT_FILE")
|
tlsCertFile := os.Getenv("TLS_CERT_FILE")
|
||||||
tlsKeyFileName := envPrefix("TLS_KEY_FILE")
|
tlsKeyFile := os.Getenv("TLS_KEY_FILE")
|
||||||
tlsCertFile := os.Getenv(tlsCertFileName)
|
|
||||||
tlsKeyFile := os.Getenv(tlsKeyFileName)
|
|
||||||
if (tlsCertFile == "" && tlsKeyFile != "") || (tlsCertFile != "" && tlsKeyFile == "") {
|
if (tlsCertFile == "" && tlsKeyFile != "") || (tlsCertFile != "" && tlsKeyFile == "") {
|
||||||
return Config{}, fmt.Errorf("both %s and %s must be set", tlsCertFileName, tlsKeyFileName)
|
return Config{}, errors.New("both TLS_CERT_FILE and TLS_KEY_FILE must be set")
|
||||||
}
|
}
|
||||||
|
|
||||||
databaseURLName := envPrefix("DATABASE_URL")
|
databaseURL := os.Getenv("DATABASE_URL")
|
||||||
databaseURL := os.Getenv(databaseURLName)
|
|
||||||
if databaseURL == "" {
|
if databaseURL == "" {
|
||||||
return Config{}, fmt.Errorf("%s not set", databaseURLName)
|
return Config{}, errors.New("DATABASE_URL not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
fileStoreName := envPrefix("FILE_STORE")
|
fileStoreString := os.Getenv("FILE_STORE")
|
||||||
fileStoreString := os.Getenv(fileStoreName)
|
|
||||||
var fileStore FileStore
|
var fileStore FileStore
|
||||||
switch getenvPrefix("FILE_STORE") {
|
switch os.Getenv("FILE_STORE") {
|
||||||
case "s3":
|
case "s3":
|
||||||
fileStore = S3Store
|
fileStore = S3Store
|
||||||
case "filesystem", "":
|
case "filesystem", "":
|
||||||
fileStore = FileSystemStore
|
fileStore = FileSystemStore
|
||||||
default:
|
default:
|
||||||
return Config{}, fmt.Errorf("invalid %s value: %s", fileStoreName, fileStoreString)
|
return Config{}, fmt.Errorf("invalid FILE_STORE value: %s", fileStoreString)
|
||||||
}
|
}
|
||||||
|
|
||||||
fileStoreHTTPBaseURLName := envPrefix("FILE_STORE_HTTP_BASE_URL")
|
fileStoreHTTPBaseURL := os.Getenv("FILE_STORE_HTTP_BASE_URL")
|
||||||
fileStoreHTTPBaseURLString := os.Getenv(fileStoreHTTPBaseURLName)
|
if fileStoreHTTPBaseURL == "" {
|
||||||
if !strings.HasSuffix(fileStoreHTTPBaseURLString, "/") {
|
fileStoreHTTPBaseURL = "/"
|
||||||
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
|
var awsAccessKeyID, awsSecretAccessKey, awsRegion, s3Bucket, fileStoreHTTPRoot string
|
||||||
|
@ -120,33 +99,17 @@ func NewFromEnv() (Config, error) {
|
||||||
return Config{}, errors.New("AWS_REGION not set")
|
return Config{}, errors.New("AWS_REGION not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
s3Bucket = getenvPrefix("S3_BUCKET")
|
s3Bucket = os.Getenv("S3_BUCKET")
|
||||||
if s3Bucket == "" {
|
if s3Bucket == "" {
|
||||||
return Config{}, errors.New("S3_BUCKET not set")
|
return Config{}, errors.New("S3_BUCKET not set")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if fileStoreHTTPRoot = getenvPrefix("FILE_STORE_HTTP_ROOT"); fileStoreHTTPRoot == "" {
|
if fileStoreHTTPRoot = os.Getenv("FILE_STORE_HTTP_ROOT"); fileStoreHTTPRoot == "" {
|
||||||
return Config{}, errors.New("FILE_STORE_HTTP_ROOT not set")
|
return Config{}, errors.New("FILE_STORE_HTTP_ROOT not set")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assetsHTTPRoot := getenvPrefix("ASSETS_HTTP_ROOT")
|
assetsHTTPRoot := os.Getenv("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{
|
return Config{
|
||||||
Environment: env,
|
Environment: env,
|
||||||
|
@ -162,7 +125,5 @@ func NewFromEnv() (Config, error) {
|
||||||
AssetsHTTPRoot: assetsHTTPRoot,
|
AssetsHTTPRoot: assetsHTTPRoot,
|
||||||
FileStoreHTTPRoot: fileStoreHTTPRoot,
|
FileStoreHTTPRoot: fileStoreHTTPRoot,
|
||||||
FileStoreHTTPBaseURL: fileStoreHTTPBaseURL,
|
FileStoreHTTPBaseURL: fileStoreHTTPBaseURL,
|
||||||
FFmpegWorkerPoolSize: ffmpegWorkerPoolSize,
|
|
||||||
CORSAllowedOrigins: corsAllowedOrigins,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,294 +0,0 @@
|
||||||
package config_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.netflux.io/rob/clipper/config"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
type env map[string]string
|
|
||||||
|
|
||||||
// clearenv clears prefixed keys from the environment. Currently it does not
|
|
||||||
// clear AWS_* environment variables.
|
|
||||||
func clearenv() {
|
|
||||||
for _, kv := range os.Environ() {
|
|
||||||
split := strings.SplitN(kv, "=", 2)
|
|
||||||
k := split[0]
|
|
||||||
if !strings.HasPrefix(k, config.Prefix) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
os.Unsetenv(k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// setupenv sets up a valid environment, including AWS_* configuration
|
|
||||||
// variables.
|
|
||||||
func setupenv() {
|
|
||||||
e := env{
|
|
||||||
"CLIPPER_ENV": "development",
|
|
||||||
"CLIPPER_DATABASE_URL": "postgresql://localhost:5432/db",
|
|
||||||
"CLIPPER_FILE_STORE_HTTP_ROOT": "/data",
|
|
||||||
"CLIPPER_S3_BUCKET": "bucket",
|
|
||||||
"AWS_ACCESS_KEY_ID": "key",
|
|
||||||
"AWS_SECRET_ACCESS_KEY": "secret",
|
|
||||||
"AWS_REGION": "eu-west-1",
|
|
||||||
}
|
|
||||||
for k, v := range e {
|
|
||||||
os.Setenv(k, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustParseURL(t *testing.T, u string) *url.URL {
|
|
||||||
pu, err := url.Parse(u)
|
|
||||||
require.NoError(t, err)
|
|
||||||
return pu
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewFromEnv(t *testing.T) {
|
|
||||||
t.Run("ENV", func(t *testing.T) {
|
|
||||||
defer clearenv()
|
|
||||||
setupenv()
|
|
||||||
|
|
||||||
os.Setenv("CLIPPER_ENV", "foo")
|
|
||||||
c, err := config.NewFromEnv()
|
|
||||||
assert.EqualError(t, err, "invalid CLIPPER_ENV value: foo")
|
|
||||||
|
|
||||||
os.Setenv("CLIPPER_ENV", "")
|
|
||||||
c, err = config.NewFromEnv()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, config.EnvDevelopment, c.Environment)
|
|
||||||
|
|
||||||
os.Setenv("CLIPPER_ENV", "development")
|
|
||||||
c, err = config.NewFromEnv()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, config.EnvDevelopment, c.Environment)
|
|
||||||
|
|
||||||
os.Setenv("CLIPPER_ENV", "production")
|
|
||||||
c, err = config.NewFromEnv()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, config.EnvProduction, c.Environment)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("BIND_ADDR", func(t *testing.T) {
|
|
||||||
defer clearenv()
|
|
||||||
setupenv()
|
|
||||||
|
|
||||||
os.Setenv("CLIPPER_BIND_ADDR", "")
|
|
||||||
c, err := config.NewFromEnv()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, config.DefaultBindAddr, c.BindAddr)
|
|
||||||
|
|
||||||
os.Setenv("CLIPPER_BIND_ADDR", "example.com:1234")
|
|
||||||
c, err = config.NewFromEnv()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "example.com:1234", c.BindAddr)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("TLS_CERT_FILE and TLS_KEY_FILE", func(t *testing.T) {
|
|
||||||
defer clearenv()
|
|
||||||
setupenv()
|
|
||||||
|
|
||||||
c, err := config.NewFromEnv()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "", c.TLSCertFile)
|
|
||||||
assert.Equal(t, "", c.TLSKeyFile)
|
|
||||||
|
|
||||||
const expErr = "both CLIPPER_TLS_CERT_FILE and CLIPPER_TLS_KEY_FILE must be set"
|
|
||||||
os.Setenv("CLIPPER_TLS_CERT_FILE", "foo")
|
|
||||||
os.Setenv("CLIPPER_TLS_KEY_FILE", "")
|
|
||||||
c, err = config.NewFromEnv()
|
|
||||||
assert.EqualError(t, err, expErr)
|
|
||||||
|
|
||||||
os.Setenv("CLIPPER_TLS_CERT_FILE", "")
|
|
||||||
os.Setenv("CLIPPER_TLS_KEY_FILE", "bar")
|
|
||||||
c, err = config.NewFromEnv()
|
|
||||||
assert.EqualError(t, err, expErr)
|
|
||||||
|
|
||||||
os.Setenv("CLIPPER_TLS_CERT_FILE", "foo")
|
|
||||||
os.Setenv("CLIPPER_TLS_KEY_FILE", "bar")
|
|
||||||
c, err = config.NewFromEnv()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "foo", c.TLSCertFile)
|
|
||||||
assert.Equal(t, "bar", c.TLSKeyFile)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("DATABASE_URL", func(t *testing.T) {
|
|
||||||
defer clearenv()
|
|
||||||
setupenv()
|
|
||||||
|
|
||||||
os.Unsetenv("CLIPPER_DATABASE_URL")
|
|
||||||
_, err := config.NewFromEnv()
|
|
||||||
assert.EqualError(t, err, "CLIPPER_DATABASE_URL not set")
|
|
||||||
|
|
||||||
os.Setenv("CLIPPER_DATABASE_URL", "foo")
|
|
||||||
c, err := config.NewFromEnv()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "foo", c.DatabaseURL)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("FILE_STORE", func(t *testing.T) {
|
|
||||||
defer clearenv()
|
|
||||||
setupenv()
|
|
||||||
|
|
||||||
os.Unsetenv("CLIPPER_FILE_STORE")
|
|
||||||
c, err := config.NewFromEnv()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, config.FileSystemStore, c.FileStore)
|
|
||||||
|
|
||||||
os.Setenv("CLIPPER_FILE_STORE", "foo")
|
|
||||||
c, err = config.NewFromEnv()
|
|
||||||
assert.EqualError(t, err, "invalid CLIPPER_FILE_STORE value: foo")
|
|
||||||
|
|
||||||
os.Setenv("CLIPPER_FILE_STORE", "filesystem")
|
|
||||||
c, err = config.NewFromEnv()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, config.FileSystemStore, c.FileStore)
|
|
||||||
|
|
||||||
os.Setenv("CLIPPER_FILE_STORE", "s3")
|
|
||||||
c, err = config.NewFromEnv()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, config.S3Store, c.FileStore)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("FILE_STORE_HTTP_ROOT", func(t *testing.T) {
|
|
||||||
defer clearenv()
|
|
||||||
setupenv()
|
|
||||||
|
|
||||||
os.Unsetenv("CLIPPER_FILE_STORE_HTTP_ROOT")
|
|
||||||
_, err := config.NewFromEnv()
|
|
||||||
require.EqualError(t, err, "FILE_STORE_HTTP_ROOT not set")
|
|
||||||
|
|
||||||
os.Setenv("CLIPPER_FILE_STORE_HTTP_ROOT", "/foo")
|
|
||||||
c, err := config.NewFromEnv()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "/foo", c.FileStoreHTTPRoot)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("FILE_STORE_HTTP_BASE_URL", func(t *testing.T) {
|
|
||||||
defer clearenv()
|
|
||||||
setupenv()
|
|
||||||
|
|
||||||
os.Setenv("CLIPPER_FILE_STORE_HTTP_BASE_URL", "%%")
|
|
||||||
_, err := config.NewFromEnv()
|
|
||||||
require.EqualError(t, err, "invalid CLIPPER_FILE_STORE_HTTP_BASE_URL: %%/")
|
|
||||||
|
|
||||||
os.Unsetenv("CLIPPER_FILE_STORE_HTTP_BASE_URL")
|
|
||||||
c, err := config.NewFromEnv()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, mustParseURL(t, "/"), c.FileStoreHTTPBaseURL)
|
|
||||||
|
|
||||||
os.Setenv("CLIPPER_FILE_STORE_HTTP_BASE_URL", "/foo")
|
|
||||||
c, err = config.NewFromEnv()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, mustParseURL(t, "/foo/"), c.FileStoreHTTPBaseURL)
|
|
||||||
|
|
||||||
os.Setenv("CLIPPER_FILE_STORE_HTTP_BASE_URL", "/foo/")
|
|
||||||
c, err = config.NewFromEnv()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, mustParseURL(t, "/foo/"), c.FileStoreHTTPBaseURL)
|
|
||||||
|
|
||||||
os.Setenv("CLIPPER_FILE_STORE_HTTP_BASE_URL", "https://www.example.com/foo")
|
|
||||||
c, err = config.NewFromEnv()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, mustParseURL(t, "https://www.example.com/foo/"), c.FileStoreHTTPBaseURL)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("ASSETS_HTTP_ROOT", func(t *testing.T) {
|
|
||||||
defer clearenv()
|
|
||||||
setupenv()
|
|
||||||
|
|
||||||
os.Unsetenv("CLIPPER_ASSETS_HTTP_ROOT")
|
|
||||||
c, err := config.NewFromEnv()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "", c.AssetsHTTPRoot)
|
|
||||||
|
|
||||||
os.Setenv("CLIPPER_ASSETS_HTTP_ROOT", "/bar")
|
|
||||||
c, err = config.NewFromEnv()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "/bar", c.AssetsHTTPRoot)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("FFMPEG_WORKER_POOL_SIZE", func(t *testing.T) {
|
|
||||||
defer clearenv()
|
|
||||||
setupenv()
|
|
||||||
|
|
||||||
os.Setenv("CLIPPER_FFMPEG_WORKER_POOL_SIZE", "nope")
|
|
||||||
c, err := config.NewFromEnv()
|
|
||||||
assert.EqualError(t, err, "invalid CLIPPER_FFMPEG_WORKER_POOL_SIZE value: nope")
|
|
||||||
|
|
||||||
os.Unsetenv("CLIPPER_FFMPEG_WORKER_POOL_SIZE")
|
|
||||||
c, err = config.NewFromEnv()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, runtime.NumCPU(), c.FFmpegWorkerPoolSize)
|
|
||||||
|
|
||||||
os.Setenv("CLIPPER_FFMPEG_WORKER_POOL_SIZE", "10")
|
|
||||||
c, err = config.NewFromEnv()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, 10, c.FFmpegWorkerPoolSize)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("AWS configuration", func(t *testing.T) {
|
|
||||||
defer clearenv()
|
|
||||||
setupenv()
|
|
||||||
os.Setenv("CLIPPER_FILE_STORE", "s3")
|
|
||||||
|
|
||||||
os.Unsetenv("AWS_ACCESS_KEY_ID")
|
|
||||||
_, err := config.NewFromEnv()
|
|
||||||
assert.EqualError(t, err, "AWS_ACCESS_KEY_ID not set")
|
|
||||||
|
|
||||||
os.Setenv("AWS_ACCESS_KEY_ID", "key")
|
|
||||||
os.Unsetenv("AWS_SECRET_ACCESS_KEY")
|
|
||||||
_, err = config.NewFromEnv()
|
|
||||||
assert.EqualError(t, err, "AWS_SECRET_ACCESS_KEY not set")
|
|
||||||
|
|
||||||
os.Setenv("AWS_ACCESS_KEY_ID", "key")
|
|
||||||
os.Setenv("AWS_SECRET_ACCESS_KEY", "secret")
|
|
||||||
os.Unsetenv("AWS_REGION")
|
|
||||||
_, err = config.NewFromEnv()
|
|
||||||
assert.EqualError(t, err, "AWS_REGION not set")
|
|
||||||
|
|
||||||
os.Setenv("AWS_ACCESS_KEY_ID", "key")
|
|
||||||
os.Setenv("AWS_SECRET_ACCESS_KEY", "secret")
|
|
||||||
os.Setenv("AWS_REGION", "eu-west-1")
|
|
||||||
os.Unsetenv("CLIPPER_S3_BUCKET")
|
|
||||||
_, err = config.NewFromEnv()
|
|
||||||
assert.EqualError(t, err, "S3_BUCKET not set")
|
|
||||||
|
|
||||||
os.Setenv("AWS_ACCESS_KEY_ID", "key")
|
|
||||||
os.Setenv("AWS_SECRET_ACCESS_KEY", "secret")
|
|
||||||
os.Setenv("AWS_REGION", "eu-west-1")
|
|
||||||
os.Setenv("CLIPPER_S3_BUCKET", "bucket")
|
|
||||||
c, err := config.NewFromEnv()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "key", c.AWSAccessKeyID)
|
|
||||||
assert.Equal(t, "secret", c.AWSSecretAccessKey)
|
|
||||||
assert.Equal(t, "eu-west-1", c.AWSRegion)
|
|
||||||
assert.Equal(t, "bucket", c.S3Bucket)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("CORS_ALLOWED_ORIGINS", func(t *testing.T) {
|
|
||||||
defer clearenv()
|
|
||||||
setupenv()
|
|
||||||
|
|
||||||
os.Setenv("CLIPPER_CORS_ALLOWED_ORIGINS", "")
|
|
||||||
c, err := config.NewFromEnv()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Nil(t, c.CORSAllowedOrigins)
|
|
||||||
|
|
||||||
os.Setenv("CLIPPER_CORS_ALLOWED_ORIGINS", "*")
|
|
||||||
c, err = config.NewFromEnv()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []string{"*"}, c.CORSAllowedOrigins)
|
|
||||||
|
|
||||||
os.Setenv("CLIPPER_CORS_ALLOWED_ORIGINS", "https://www1.example.com,https://www2.example.com")
|
|
||||||
c, err = config.NewFromEnv()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []string{"https://www1.example.com", "https://www2.example.com"}, c.CORSAllowedOrigins)
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -4,20 +4,12 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"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.
|
// FileSystemStore is a file store that stores files on the local filesystem.
|
||||||
// It is currently intended for usage in a development environment.
|
// It is currently intended for usage in a development environment.
|
||||||
type FileSystemStore struct {
|
type FileSystemStore struct {
|
||||||
|
@ -29,12 +21,15 @@ type FileSystemStore struct {
|
||||||
// which is the storage location on the local file system for stored objects,
|
// 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
|
// and a baseURL which is a URL which should be configured to serve the stored
|
||||||
// files over HTTP.
|
// files over HTTP.
|
||||||
func NewFileSystemStore(rootPath string, baseURL *url.URL) (*FileSystemStore, error) {
|
func NewFileSystemStore(rootPath string, baseURL string) (*FileSystemStore, error) {
|
||||||
url := *baseURL
|
url, err := url.Parse(baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error parsing URL: %v", err)
|
||||||
|
}
|
||||||
if !strings.HasSuffix(url.Path, "/") {
|
if !strings.HasSuffix(url.Path, "/") {
|
||||||
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.
|
// GetObject retrieves an object from the local filesystem.
|
||||||
|
|
|
@ -3,7 +3,6 @@ package filestore_test
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -15,9 +14,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFileStoreGetObject(t *testing.T) {
|
func TestFileStoreGetObject(t *testing.T) {
|
||||||
baseURL, err := url.Parse("/")
|
store, err := filestore.NewFileSystemStore("testdata/", "/")
|
||||||
require.NoError(t, err)
|
|
||||||
store, err := filestore.NewFileSystemStore("testdata/", baseURL)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
reader, err := store.GetObject(context.Background(), "file.txt")
|
reader, err := store.GetObject(context.Background(), "file.txt")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
@ -63,9 +60,7 @@ func TestFileStoreGetObjectWithRange(t *testing.T) {
|
||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
baseURL, err := url.Parse("/")
|
store, err := filestore.NewFileSystemStore("testdata/", "/")
|
||||||
require.NoError(t, err)
|
|
||||||
store, err := filestore.NewFileSystemStore("testdata/", baseURL)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
reader, err := store.GetObjectWithRange(context.Background(), "file.txt", tc.start, tc.end)
|
reader, err := store.GetObjectWithRange(context.Background(), "file.txt", tc.start, tc.end)
|
||||||
|
|
||||||
|
@ -118,9 +113,7 @@ func TestFileStoreGetURL(t *testing.T) {
|
||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
baseURL, err := url.Parse(tc.baseURL)
|
store, err := filestore.NewFileSystemStore("testdata/", tc.baseURL)
|
||||||
require.NoError(t, err)
|
|
||||||
store, err := filestore.NewFileSystemStore("testdata/", baseURL)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
url, err := store.GetURL(context.Background(), tc.key)
|
url, err := store.GetURL(context.Background(), tc.key)
|
||||||
|
@ -156,9 +149,7 @@ func TestFileStorePutObject(t *testing.T) {
|
||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
baseURL, err := url.Parse("/")
|
store, err := filestore.NewFileSystemStore(rootPath, "/")
|
||||||
require.NoError(t, err)
|
|
||||||
store, err := filestore.NewFileSystemStore(rootPath, baseURL)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
n, err := store.PutObject(context.Background(), tc.key, strings.NewReader(tc.content), "text/plain")
|
n, err := store.PutObject(context.Background(), tc.key, strings.NewReader(tc.content), "text/plain")
|
||||||
|
|
|
@ -1,36 +0,0 @@
|
||||||
// Code generated by mockery v2.9.4. DO NOT EDIT.
|
|
||||||
|
|
||||||
package mocks
|
|
||||||
|
|
||||||
import (
|
|
||||||
context "context"
|
|
||||||
|
|
||||||
media "git.netflux.io/rob/clipper/media"
|
|
||||||
mock "github.com/stretchr/testify/mock"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AudioSegmentStream is an autogenerated mock type for the AudioSegmentStream type
|
|
||||||
type AudioSegmentStream struct {
|
|
||||||
mock.Mock
|
|
||||||
}
|
|
||||||
|
|
||||||
// Next provides a mock function with given fields: ctx
|
|
||||||
func (_m *AudioSegmentStream) Next(ctx context.Context) (media.AudioSegmentProgress, error) {
|
|
||||||
ret := _m.Called(ctx)
|
|
||||||
|
|
||||||
var r0 media.AudioSegmentProgress
|
|
||||||
if rf, ok := ret.Get(0).(func(context.Context) media.AudioSegmentProgress); ok {
|
|
||||||
r0 = rf(ctx)
|
|
||||||
} else {
|
|
||||||
r0 = ret.Get(0).(media.AudioSegmentProgress)
|
|
||||||
}
|
|
||||||
|
|
||||||
var r1 error
|
|
||||||
if rf, ok := ret.Get(1).(func(context.Context) error); ok {
|
|
||||||
r1 = rf(ctx)
|
|
||||||
} else {
|
|
||||||
r1 = ret.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return r0, r1
|
|
||||||
}
|
|
|
@ -1,153 +0,0 @@
|
||||||
// Code generated by mockery v2.9.4. DO NOT EDIT.
|
|
||||||
|
|
||||||
package mocks
|
|
||||||
|
|
||||||
import (
|
|
||||||
context "context"
|
|
||||||
|
|
||||||
media "git.netflux.io/rob/clipper/media"
|
|
||||||
mock "github.com/stretchr/testify/mock"
|
|
||||||
|
|
||||||
uuid "github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MediaSetService is an autogenerated mock type for the MediaSetService type
|
|
||||||
type MediaSetService struct {
|
|
||||||
mock.Mock
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get provides a mock function with given fields: _a0, _a1
|
|
||||||
func (_m *MediaSetService) Get(_a0 context.Context, _a1 string) (*media.MediaSet, error) {
|
|
||||||
ret := _m.Called(_a0, _a1)
|
|
||||||
|
|
||||||
var r0 *media.MediaSet
|
|
||||||
if rf, ok := ret.Get(0).(func(context.Context, string) *media.MediaSet); ok {
|
|
||||||
r0 = rf(_a0, _a1)
|
|
||||||
} else {
|
|
||||||
if ret.Get(0) != nil {
|
|
||||||
r0 = ret.Get(0).(*media.MediaSet)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var r1 error
|
|
||||||
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
|
|
||||||
r1 = rf(_a0, _a1)
|
|
||||||
} else {
|
|
||||||
r1 = ret.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return r0, r1
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAudioSegment provides a mock function with given fields: _a0, _a1, _a2, _a3, _a4
|
|
||||||
func (_m *MediaSetService) GetAudioSegment(_a0 context.Context, _a1 uuid.UUID, _a2 int64, _a3 int64, _a4 media.AudioFormat) (media.AudioSegmentStream, error) {
|
|
||||||
ret := _m.Called(_a0, _a1, _a2, _a3, _a4)
|
|
||||||
|
|
||||||
var r0 media.AudioSegmentStream
|
|
||||||
if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID, int64, int64, media.AudioFormat) media.AudioSegmentStream); ok {
|
|
||||||
r0 = rf(_a0, _a1, _a2, _a3, _a4)
|
|
||||||
} else {
|
|
||||||
if ret.Get(0) != nil {
|
|
||||||
r0 = ret.Get(0).(media.AudioSegmentStream)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var r1 error
|
|
||||||
if rf, ok := ret.Get(1).(func(context.Context, uuid.UUID, int64, int64, media.AudioFormat) error); ok {
|
|
||||||
r1 = rf(_a0, _a1, _a2, _a3, _a4)
|
|
||||||
} else {
|
|
||||||
r1 = ret.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return r0, r1
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPeaks provides a mock function with given fields: _a0, _a1, _a2
|
|
||||||
func (_m *MediaSetService) GetPeaks(_a0 context.Context, _a1 uuid.UUID, _a2 int) (media.GetPeaksProgressReader, error) {
|
|
||||||
ret := _m.Called(_a0, _a1, _a2)
|
|
||||||
|
|
||||||
var r0 media.GetPeaksProgressReader
|
|
||||||
if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID, int) media.GetPeaksProgressReader); ok {
|
|
||||||
r0 = rf(_a0, _a1, _a2)
|
|
||||||
} else {
|
|
||||||
if ret.Get(0) != nil {
|
|
||||||
r0 = ret.Get(0).(media.GetPeaksProgressReader)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var r1 error
|
|
||||||
if rf, ok := ret.Get(1).(func(context.Context, uuid.UUID, int) error); ok {
|
|
||||||
r1 = rf(_a0, _a1, _a2)
|
|
||||||
} else {
|
|
||||||
r1 = ret.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return r0, r1
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPeaksForSegment provides a mock function with given fields: _a0, _a1, _a2, _a3, _a4
|
|
||||||
func (_m *MediaSetService) GetPeaksForSegment(_a0 context.Context, _a1 uuid.UUID, _a2 int64, _a3 int64, _a4 int) ([]int16, error) {
|
|
||||||
ret := _m.Called(_a0, _a1, _a2, _a3, _a4)
|
|
||||||
|
|
||||||
var r0 []int16
|
|
||||||
if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID, int64, int64, int) []int16); ok {
|
|
||||||
r0 = rf(_a0, _a1, _a2, _a3, _a4)
|
|
||||||
} else {
|
|
||||||
if ret.Get(0) != nil {
|
|
||||||
r0 = ret.Get(0).([]int16)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var r1 error
|
|
||||||
if rf, ok := ret.Get(1).(func(context.Context, uuid.UUID, int64, int64, int) error); ok {
|
|
||||||
r1 = rf(_a0, _a1, _a2, _a3, _a4)
|
|
||||||
} else {
|
|
||||||
r1 = ret.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return r0, r1
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetVideo provides a mock function with given fields: _a0, _a1
|
|
||||||
func (_m *MediaSetService) GetVideo(_a0 context.Context, _a1 uuid.UUID) (media.GetVideoProgressReader, error) {
|
|
||||||
ret := _m.Called(_a0, _a1)
|
|
||||||
|
|
||||||
var r0 media.GetVideoProgressReader
|
|
||||||
if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) media.GetVideoProgressReader); ok {
|
|
||||||
r0 = rf(_a0, _a1)
|
|
||||||
} else {
|
|
||||||
if ret.Get(0) != nil {
|
|
||||||
r0 = ret.Get(0).(media.GetVideoProgressReader)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var r1 error
|
|
||||||
if rf, ok := ret.Get(1).(func(context.Context, uuid.UUID) error); ok {
|
|
||||||
r1 = rf(_a0, _a1)
|
|
||||||
} else {
|
|
||||||
r1 = ret.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return r0, r1
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetVideoThumbnail provides a mock function with given fields: _a0, _a1
|
|
||||||
func (_m *MediaSetService) GetVideoThumbnail(_a0 context.Context, _a1 uuid.UUID) (media.VideoThumbnail, error) {
|
|
||||||
ret := _m.Called(_a0, _a1)
|
|
||||||
|
|
||||||
var r0 media.VideoThumbnail
|
|
||||||
if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) media.VideoThumbnail); ok {
|
|
||||||
r0 = rf(_a0, _a1)
|
|
||||||
} else {
|
|
||||||
r0 = ret.Get(0).(media.VideoThumbnail)
|
|
||||||
}
|
|
||||||
|
|
||||||
var r1 error
|
|
||||||
if rf, ok := ret.Get(1).(func(context.Context, uuid.UUID) error); ok {
|
|
||||||
r1 = rf(_a0, _a1)
|
|
||||||
} else {
|
|
||||||
r1 = ret.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return r0, r1
|
|
||||||
}
|
|
|
@ -1,7 +1,7 @@
|
||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.27.1
|
// protoc-gen-go v1.27.1
|
||||||
// protoc v3.19.1
|
// protoc v3.17.3
|
||||||
// source: media_set.proto
|
// source: media_set.proto
|
||||||
|
|
||||||
package media_set
|
package media_set
|
||||||
|
@ -74,9 +74,6 @@ type MediaSet struct {
|
||||||
|
|
||||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
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"`
|
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"`
|
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"`
|
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"`
|
AudioFrames int64 `protobuf:"varint,5,opt,name=audio_frames,json=audioFrames,proto3" json:"audio_frames,omitempty"`
|
||||||
|
@ -134,27 +131,6 @@ func (x *MediaSet) GetYoutubeId() string {
|
||||||
return ""
|
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 {
|
func (x *MediaSet) GetAudioChannels() int32 {
|
||||||
if x != nil {
|
if x != nil {
|
||||||
return x.AudioChannels
|
return x.AudioChannels
|
||||||
|
@ -328,7 +304,6 @@ type GetPeaksProgress struct {
|
||||||
Peaks []int32 `protobuf:"varint,1,rep,packed,name=peaks,proto3" json:"peaks,omitempty"`
|
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"`
|
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"`
|
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() {
|
func (x *GetPeaksProgress) Reset() {
|
||||||
|
@ -384,13 +359,6 @@ func (x *GetPeaksProgress) GetUrl() string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *GetPeaksProgress) GetAudioFrames() int64 {
|
|
||||||
if x != nil {
|
|
||||||
return x.AudioFrames
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
type GetPeaksForSegmentRequest struct {
|
type GetPeaksForSegmentRequest struct {
|
||||||
state protoimpl.MessageState
|
state protoimpl.MessageState
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
|
@ -585,6 +553,8 @@ type GetAudioSegmentProgress struct {
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
unknownFields protoimpl.UnknownFields
|
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"`
|
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"`
|
AudioData []byte `protobuf:"bytes,4,opt,name=audio_data,json=audioData,proto3" json:"audio_data,omitempty"`
|
||||||
}
|
}
|
||||||
|
@ -621,6 +591,20 @@ func (*GetAudioSegmentProgress) Descriptor() ([]byte, []int) {
|
||||||
return file_media_set_proto_rawDescGZIP(), []int{7}
|
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 {
|
func (x *GetAudioSegmentProgress) GetPercentComplete() float32 {
|
||||||
if x != nil {
|
if x != nil {
|
||||||
return x.PercentComplete
|
return x.PercentComplete
|
||||||
|
@ -853,16 +837,11 @@ var file_media_set_proto_rawDesc = []byte{
|
||||||
0x0a, 0x0f, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74,
|
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, 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,
|
0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x64, 0x75,
|
||||||
0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x9d, 0x04, 0x0a,
|
0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xcd, 0x03, 0x0a,
|
||||||
0x08, 0x4d, 0x65, 0x64, 0x69, 0x61, 0x53, 0x65, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18,
|
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,
|
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,
|
0x74, 0x75, 0x62, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x79,
|
||||||
0x6f, 0x75, 0x74, 0x75, 0x62, 0x65, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x69, 0x74, 0x6c,
|
0x6f, 0x75, 0x74, 0x75, 0x62, 0x65, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x75, 0x64, 0x69,
|
||||||
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,
|
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,
|
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,
|
0x2e, 0x0a, 0x13, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x5f, 0x61, 0x70, 0x70, 0x72, 0x6f, 0x78, 0x5f,
|
||||||
|
@ -894,95 +873,96 @@ var file_media_set_proto_rawDesc = []byte{
|
||||||
0x50, 0x65, 0x61, 0x6b, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02,
|
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,
|
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, 0x5f, 0x62, 0x69, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07,
|
||||||
0x6e, 0x75, 0x6d, 0x42, 0x69, 0x6e, 0x73, 0x22, 0x88, 0x01, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x50,
|
0x6e, 0x75, 0x6d, 0x42, 0x69, 0x6e, 0x73, 0x22, 0x65, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x50, 0x65,
|
||||||
0x65, 0x61, 0x6b, 0x73, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x12, 0x14, 0x0a, 0x05,
|
0x61, 0x6b, 0x73, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x70,
|
||||||
0x70, 0x65, 0x61, 0x6b, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x05, 0x52, 0x05, 0x70, 0x65, 0x61,
|
0x65, 0x61, 0x6b, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x05, 0x52, 0x05, 0x70, 0x65, 0x61, 0x6b,
|
||||||
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,
|
0x73, 0x12, 0x29, 0x0a, 0x10, 0x70, 0x65, 0x72, 0x63, 0x65, 0x6e, 0x74, 0x5f, 0x63, 0x6f, 0x6d,
|
||||||
0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x02, 0x52, 0x0f, 0x70, 0x65, 0x72,
|
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, 0x1d, 0x0a, 0x0a,
|
0x63, 0x65, 0x6e, 0x74, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x10, 0x0a, 0x03,
|
||||||
0x61, 0x75, 0x64, 0x69, 0x6f, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c,
|
0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x22, 0x84,
|
||||||
0x52, 0x09, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x44, 0x61, 0x74, 0x61, 0x22, 0x21, 0x0a, 0x0f, 0x47,
|
0x01, 0x0a, 0x19, 0x47, 0x65, 0x74, 0x50, 0x65, 0x61, 0x6b, 0x73, 0x46, 0x6f, 0x72, 0x53, 0x65,
|
||||||
0x65, 0x74, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e,
|
0x67, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02,
|
||||||
0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0x4f,
|
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x19, 0x0a, 0x08,
|
||||||
0x0a, 0x10, 0x47, 0x65, 0x74, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65,
|
0x6e, 0x75, 0x6d, 0x5f, 0x62, 0x69, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07,
|
||||||
0x73, 0x73, 0x12, 0x29, 0x0a, 0x10, 0x70, 0x65, 0x72, 0x63, 0x65, 0x6e, 0x74, 0x5f, 0x63, 0x6f,
|
0x6e, 0x75, 0x6d, 0x42, 0x69, 0x6e, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x74, 0x61, 0x72, 0x74,
|
||||||
0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x02, 0x52, 0x0f, 0x70, 0x65,
|
0x5f, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x73, 0x74,
|
||||||
0x72, 0x63, 0x65, 0x6e, 0x74, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x10, 0x0a,
|
0x61, 0x72, 0x74, 0x46, 0x72, 0x61, 0x6d, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x65, 0x6e, 0x64, 0x5f,
|
||||||
0x03, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x22,
|
0x66, 0x72, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x65, 0x6e, 0x64,
|
||||||
0x2a, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x54, 0x68, 0x75, 0x6d, 0x62,
|
0x46, 0x72, 0x61, 0x6d, 0x65, 0x22, 0x32, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x50, 0x65, 0x61, 0x6b,
|
||||||
0x6e, 0x61, 0x69, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69,
|
0x73, 0x46, 0x6f, 0x72, 0x53, 0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f,
|
||||||
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0x5f, 0x0a, 0x19, 0x47,
|
0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x70, 0x65, 0x61, 0x6b, 0x73, 0x18, 0x01, 0x20, 0x03,
|
||||||
0x65, 0x74, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x54, 0x68, 0x75, 0x6d, 0x62, 0x6e, 0x61, 0x69, 0x6c,
|
0x28, 0x05, 0x52, 0x05, 0x70, 0x65, 0x61, 0x6b, 0x73, 0x22, 0x96, 0x01, 0x0a, 0x16, 0x47, 0x65,
|
||||||
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6d, 0x61, 0x67,
|
0x74, 0x41, 0x75, 0x64, 0x69, 0x6f, 0x53, 0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71,
|
||||||
0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x14,
|
0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
||||||
0x0a, 0x05, 0x77, 0x69, 0x64, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x77,
|
0x52, 0x02, 0x69, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x74, 0x61, 0x72, 0x74, 0x5f, 0x66, 0x72,
|
||||||
0x69, 0x64, 0x74, 0x68, 0x12, 0x16, 0x0a, 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x18, 0x03,
|
0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74,
|
||||||
0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x2a, 0x1f, 0x0a, 0x0b,
|
0x46, 0x72, 0x61, 0x6d, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x65, 0x6e, 0x64, 0x5f, 0x66, 0x72, 0x61,
|
||||||
0x41, 0x75, 0x64, 0x69, 0x6f, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x12, 0x07, 0x0a, 0x03, 0x57,
|
0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x65, 0x6e, 0x64, 0x46, 0x72, 0x61,
|
||||||
0x41, 0x56, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x4d, 0x50, 0x33, 0x10, 0x01, 0x32, 0xfd, 0x03,
|
0x6d, 0x65, 0x12, 0x2e, 0x0a, 0x06, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01,
|
||||||
0x0a, 0x0f, 0x4d, 0x65, 0x64, 0x69, 0x61, 0x53, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63,
|
0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x41,
|
||||||
0x65, 0x12, 0x33, 0x0a, 0x03, 0x47, 0x65, 0x74, 0x12, 0x15, 0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61,
|
0x75, 0x64, 0x69, 0x6f, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x52, 0x06, 0x66, 0x6f, 0x72, 0x6d,
|
||||||
0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
|
0x61, 0x74, 0x22, 0x9a, 0x01, 0x0a, 0x17, 0x47, 0x65, 0x74, 0x41, 0x75, 0x64, 0x69, 0x6f, 0x53,
|
||||||
0x13, 0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x4d, 0x65, 0x64, 0x69,
|
0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x12, 0x1b,
|
||||||
0x61, 0x53, 0x65, 0x74, 0x22, 0x00, 0x12, 0x47, 0x0a, 0x08, 0x47, 0x65, 0x74, 0x50, 0x65, 0x61,
|
0x0a, 0x09, 0x6d, 0x69, 0x6d, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||||
0x6b, 0x73, 0x12, 0x1a, 0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47,
|
0x09, 0x52, 0x08, 0x6d, 0x69, 0x6d, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d,
|
||||||
0x65, 0x74, 0x50, 0x65, 0x61, 0x6b, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b,
|
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65,
|
||||||
0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x50, 0x65,
|
0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x29, 0x0a, 0x10, 0x70, 0x65, 0x72, 0x63, 0x65, 0x6e, 0x74,
|
||||||
0x61, 0x6b, 0x73, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x22, 0x00, 0x30, 0x01, 0x12,
|
0x5f, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x02, 0x52,
|
||||||
0x63, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x50, 0x65, 0x61, 0x6b, 0x73, 0x46, 0x6f, 0x72, 0x53, 0x65,
|
0x0f, 0x70, 0x65, 0x72, 0x63, 0x65, 0x6e, 0x74, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65,
|
||||||
0x67, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x24, 0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65,
|
0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x18, 0x04,
|
||||||
0x74, 0x2e, 0x47, 0x65, 0x74, 0x50, 0x65, 0x61, 0x6b, 0x73, 0x46, 0x6f, 0x72, 0x53, 0x65, 0x67,
|
0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x44, 0x61, 0x74, 0x61, 0x22,
|
||||||
0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x25, 0x2e, 0x6d, 0x65,
|
0x21, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x52, 0x65, 0x71, 0x75, 0x65,
|
||||||
0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x50, 0x65, 0x61, 0x6b, 0x73,
|
0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02,
|
||||||
0x46, 0x6f, 0x72, 0x53, 0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
|
0x69, 0x64, 0x22, 0x4f, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x50, 0x72,
|
||||||
0x73, 0x65, 0x22, 0x00, 0x12, 0x5c, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x41, 0x75, 0x64, 0x69, 0x6f,
|
0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x12, 0x29, 0x0a, 0x10, 0x70, 0x65, 0x72, 0x63, 0x65, 0x6e,
|
||||||
0x53, 0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x21, 0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f,
|
0x74, 0x5f, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x02,
|
||||||
0x73, 0x65, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x75, 0x64, 0x69, 0x6f, 0x53, 0x65, 0x67, 0x6d,
|
0x52, 0x0f, 0x70, 0x65, 0x72, 0x63, 0x65, 0x6e, 0x74, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74,
|
||||||
0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x6d, 0x65, 0x64,
|
0x65, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03,
|
||||||
0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x75, 0x64, 0x69, 0x6f, 0x53,
|
0x75, 0x72, 0x6c, 0x22, 0x2a, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x54,
|
||||||
0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x22, 0x00,
|
0x68, 0x75, 0x6d, 0x62, 0x6e, 0x61, 0x69, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
|
||||||
0x30, 0x01, 0x12, 0x47, 0x0a, 0x08, 0x47, 0x65, 0x74, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x12, 0x1a,
|
0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22,
|
||||||
|
0x5f, 0x0a, 0x19, 0x47, 0x65, 0x74, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x54, 0x68, 0x75, 0x6d, 0x62,
|
||||||
|
0x6e, 0x61, 0x69, 0x6c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05,
|
||||||
|
0x69, 0x6d, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x69, 0x6d, 0x61,
|
||||||
|
0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x77, 0x69, 0x64, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28,
|
||||||
|
0x05, 0x52, 0x05, 0x77, 0x69, 0x64, 0x74, 0x68, 0x12, 0x16, 0x0a, 0x06, 0x68, 0x65, 0x69, 0x67,
|
||||||
|
0x68, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74,
|
||||||
|
0x2a, 0x1f, 0x0a, 0x0b, 0x41, 0x75, 0x64, 0x69, 0x6f, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x12,
|
||||||
|
0x07, 0x0a, 0x03, 0x57, 0x41, 0x56, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x4d, 0x50, 0x33, 0x10,
|
||||||
|
0x01, 0x32, 0xfd, 0x03, 0x0a, 0x0f, 0x4d, 0x65, 0x64, 0x69, 0x61, 0x53, 0x65, 0x74, 0x53, 0x65,
|
||||||
|
0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x33, 0x0a, 0x03, 0x47, 0x65, 0x74, 0x12, 0x15, 0x2e, 0x6d,
|
||||||
|
0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75,
|
||||||
|
0x65, 0x73, 0x74, 0x1a, 0x13, 0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e,
|
||||||
|
0x4d, 0x65, 0x64, 0x69, 0x61, 0x53, 0x65, 0x74, 0x22, 0x00, 0x12, 0x47, 0x0a, 0x08, 0x47, 0x65,
|
||||||
|
0x74, 0x50, 0x65, 0x61, 0x6b, 0x73, 0x12, 0x1a, 0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73,
|
||||||
|
0x65, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x50, 0x65, 0x61, 0x6b, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65,
|
||||||
|
0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47,
|
||||||
|
0x65, 0x74, 0x50, 0x65, 0x61, 0x6b, 0x73, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x22,
|
||||||
|
0x00, 0x30, 0x01, 0x12, 0x63, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x50, 0x65, 0x61, 0x6b, 0x73, 0x46,
|
||||||
|
0x6f, 0x72, 0x53, 0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x24, 0x2e, 0x6d, 0x65, 0x64, 0x69,
|
||||||
|
0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x50, 0x65, 0x61, 0x6b, 0x73, 0x46, 0x6f,
|
||||||
|
0x72, 0x53, 0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
|
||||||
|
0x25, 0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x50,
|
||||||
|
0x65, 0x61, 0x6b, 0x73, 0x46, 0x6f, 0x72, 0x53, 0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65,
|
||||||
|
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x5c, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x41,
|
||||||
|
0x75, 0x64, 0x69, 0x6f, 0x53, 0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x21, 0x2e, 0x6d, 0x65,
|
||||||
|
0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x75, 0x64, 0x69, 0x6f,
|
||||||
|
0x53, 0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22,
|
||||||
|
0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x75,
|
||||||
|
0x64, 0x69, 0x6f, 0x53, 0x65, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65,
|
||||||
|
0x73, 0x73, 0x22, 0x00, 0x30, 0x01, 0x12, 0x47, 0x0a, 0x08, 0x47, 0x65, 0x74, 0x56, 0x69, 0x64,
|
||||||
|
0x65, 0x6f, 0x12, 0x1a, 0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47,
|
||||||
|
0x65, 0x74, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b,
|
||||||
0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x56, 0x69,
|
0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x56, 0x69,
|
||||||
0x64, 0x65, 0x6f, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x6d, 0x65, 0x64,
|
0x64, 0x65, 0x6f, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x22, 0x00, 0x30, 0x01, 0x12,
|
||||||
0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x50,
|
0x60, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x54, 0x68, 0x75, 0x6d, 0x62,
|
||||||
0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x22, 0x00, 0x30, 0x01, 0x12, 0x60, 0x0a, 0x11, 0x47,
|
0x6e, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74,
|
||||||
0x65, 0x74, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x54, 0x68, 0x75, 0x6d, 0x62, 0x6e, 0x61, 0x69, 0x6c,
|
0x2e, 0x47, 0x65, 0x74, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x54, 0x68, 0x75, 0x6d, 0x62, 0x6e, 0x61,
|
||||||
0x12, 0x23, 0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47, 0x65, 0x74,
|
0x69, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x6d, 0x65, 0x64, 0x69,
|
||||||
0x56, 0x69, 0x64, 0x65, 0x6f, 0x54, 0x68, 0x75, 0x6d, 0x62, 0x6e, 0x61, 0x69, 0x6c, 0x52, 0x65,
|
0x61, 0x5f, 0x73, 0x65, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x54, 0x68,
|
||||||
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65,
|
0x75, 0x6d, 0x62, 0x6e, 0x61, 0x69, 0x6c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22,
|
||||||
0x74, 0x2e, 0x47, 0x65, 0x74, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x54, 0x68, 0x75, 0x6d, 0x62, 0x6e,
|
0x00, 0x42, 0x0e, 0x5a, 0x0c, 0x70, 0x62, 0x2f, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65,
|
||||||
0x61, 0x69, 0x6c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x0e, 0x5a,
|
0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||||
0x0c, 0x70, 0x62, 0x2f, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x62, 0x06, 0x70,
|
|
||||||
0x72, 0x6f, 0x74, 0x6f, 0x33,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
|
@ -36,7 +36,4 @@ type MediaSet struct {
|
||||||
VideoContentLength int64
|
VideoContentLength int64
|
||||||
AudioEncodedS3Key sql.NullString
|
AudioEncodedS3Key sql.NullString
|
||||||
AudioEncodedS3UploadedAt sql.NullTime
|
AudioEncodedS3UploadedAt sql.NullTime
|
||||||
Title string
|
|
||||||
Description string
|
|
||||||
Author string
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,16 +11,13 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const createMediaSet = `-- name: CreateMediaSet :one
|
const createMediaSet = `-- name: CreateMediaSet :one
|
||||||
INSERT INTO media_sets (youtube_id, title, description, author, audio_youtube_itag, audio_channels, audio_frames_approx, audio_sample_rate, audio_content_length, audio_encoded_mime_type, video_youtube_itag, video_content_length, video_mime_type, video_duration_nanos, created_at, updated_at)
|
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, $12, $13, $14, NOW(), NOW())
|
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, title, description, author
|
RETURNING id, youtube_id, audio_youtube_itag, audio_channels, audio_frames_approx, audio_frames, audio_sample_rate, audio_raw_s3_key, audio_raw_s3_uploaded_at, audio_encoded_mime_type, video_youtube_itag, video_s3_key, video_s3_uploaded_at, video_mime_type, video_duration_nanos, video_thumbnail_s3_key, video_thumbnail_s3_uploaded_at, video_thumbnail_mime_type, video_thumbnail_width, video_thumbnail_height, created_at, updated_at, audio_content_length, video_content_length, audio_encoded_s3_key, audio_encoded_s3_uploaded_at
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateMediaSetParams struct {
|
type CreateMediaSetParams struct {
|
||||||
YoutubeID string
|
YoutubeID string
|
||||||
Title string
|
|
||||||
Description string
|
|
||||||
Author string
|
|
||||||
AudioYoutubeItag int32
|
AudioYoutubeItag int32
|
||||||
AudioChannels int32
|
AudioChannels int32
|
||||||
AudioFramesApprox int64
|
AudioFramesApprox int64
|
||||||
|
@ -36,9 +33,6 @@ type CreateMediaSetParams struct {
|
||||||
func (q *Queries) CreateMediaSet(ctx context.Context, arg CreateMediaSetParams) (MediaSet, error) {
|
func (q *Queries) CreateMediaSet(ctx context.Context, arg CreateMediaSetParams) (MediaSet, error) {
|
||||||
row := q.db.QueryRow(ctx, createMediaSet,
|
row := q.db.QueryRow(ctx, createMediaSet,
|
||||||
arg.YoutubeID,
|
arg.YoutubeID,
|
||||||
arg.Title,
|
|
||||||
arg.Description,
|
|
||||||
arg.Author,
|
|
||||||
arg.AudioYoutubeItag,
|
arg.AudioYoutubeItag,
|
||||||
arg.AudioChannels,
|
arg.AudioChannels,
|
||||||
arg.AudioFramesApprox,
|
arg.AudioFramesApprox,
|
||||||
|
@ -78,15 +72,12 @@ func (q *Queries) CreateMediaSet(ctx context.Context, arg CreateMediaSetParams)
|
||||||
&i.VideoContentLength,
|
&i.VideoContentLength,
|
||||||
&i.AudioEncodedS3Key,
|
&i.AudioEncodedS3Key,
|
||||||
&i.AudioEncodedS3UploadedAt,
|
&i.AudioEncodedS3UploadedAt,
|
||||||
&i.Title,
|
|
||||||
&i.Description,
|
|
||||||
&i.Author,
|
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getMediaSet = `-- name: GetMediaSet :one
|
const getMediaSet = `-- name: GetMediaSet :one
|
||||||
SELECT id, youtube_id, audio_youtube_itag, audio_channels, audio_frames_approx, audio_frames, audio_sample_rate, audio_raw_s3_key, audio_raw_s3_uploaded_at, audio_encoded_mime_type, video_youtube_itag, video_s3_key, video_s3_uploaded_at, video_mime_type, video_duration_nanos, video_thumbnail_s3_key, video_thumbnail_s3_uploaded_at, video_thumbnail_mime_type, video_thumbnail_width, video_thumbnail_height, created_at, updated_at, audio_content_length, video_content_length, audio_encoded_s3_key, audio_encoded_s3_uploaded_at, title, description, author FROM media_sets WHERE id = $1
|
SELECT id, youtube_id, audio_youtube_itag, audio_channels, audio_frames_approx, audio_frames, audio_sample_rate, audio_raw_s3_key, audio_raw_s3_uploaded_at, audio_encoded_mime_type, video_youtube_itag, video_s3_key, video_s3_uploaded_at, video_mime_type, video_duration_nanos, video_thumbnail_s3_key, video_thumbnail_s3_uploaded_at, video_thumbnail_mime_type, video_thumbnail_width, video_thumbnail_height, created_at, updated_at, audio_content_length, video_content_length, audio_encoded_s3_key, audio_encoded_s3_uploaded_at FROM media_sets WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetMediaSet(ctx context.Context, id uuid.UUID) (MediaSet, error) {
|
func (q *Queries) GetMediaSet(ctx context.Context, id uuid.UUID) (MediaSet, error) {
|
||||||
|
@ -119,15 +110,12 @@ func (q *Queries) GetMediaSet(ctx context.Context, id uuid.UUID) (MediaSet, erro
|
||||||
&i.VideoContentLength,
|
&i.VideoContentLength,
|
||||||
&i.AudioEncodedS3Key,
|
&i.AudioEncodedS3Key,
|
||||||
&i.AudioEncodedS3UploadedAt,
|
&i.AudioEncodedS3UploadedAt,
|
||||||
&i.Title,
|
|
||||||
&i.Description,
|
|
||||||
&i.Author,
|
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getMediaSetByYoutubeID = `-- name: GetMediaSetByYoutubeID :one
|
const getMediaSetByYoutubeID = `-- name: GetMediaSetByYoutubeID :one
|
||||||
SELECT id, youtube_id, audio_youtube_itag, audio_channels, audio_frames_approx, audio_frames, audio_sample_rate, audio_raw_s3_key, audio_raw_s3_uploaded_at, audio_encoded_mime_type, video_youtube_itag, video_s3_key, video_s3_uploaded_at, video_mime_type, video_duration_nanos, video_thumbnail_s3_key, video_thumbnail_s3_uploaded_at, video_thumbnail_mime_type, video_thumbnail_width, video_thumbnail_height, created_at, updated_at, audio_content_length, video_content_length, audio_encoded_s3_key, audio_encoded_s3_uploaded_at, title, description, author FROM media_sets WHERE youtube_id = $1
|
SELECT id, youtube_id, audio_youtube_itag, audio_channels, audio_frames_approx, audio_frames, audio_sample_rate, audio_raw_s3_key, audio_raw_s3_uploaded_at, audio_encoded_mime_type, video_youtube_itag, video_s3_key, video_s3_uploaded_at, video_mime_type, video_duration_nanos, video_thumbnail_s3_key, video_thumbnail_s3_uploaded_at, video_thumbnail_mime_type, video_thumbnail_width, video_thumbnail_height, created_at, updated_at, audio_content_length, video_content_length, audio_encoded_s3_key, audio_encoded_s3_uploaded_at FROM media_sets WHERE youtube_id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetMediaSetByYoutubeID(ctx context.Context, youtubeID string) (MediaSet, error) {
|
func (q *Queries) GetMediaSetByYoutubeID(ctx context.Context, youtubeID string) (MediaSet, error) {
|
||||||
|
@ -160,9 +148,6 @@ func (q *Queries) GetMediaSetByYoutubeID(ctx context.Context, youtubeID string)
|
||||||
&i.VideoContentLength,
|
&i.VideoContentLength,
|
||||||
&i.AudioEncodedS3Key,
|
&i.AudioEncodedS3Key,
|
||||||
&i.AudioEncodedS3UploadedAt,
|
&i.AudioEncodedS3UploadedAt,
|
||||||
&i.Title,
|
|
||||||
&i.Description,
|
|
||||||
&i.Author,
|
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
@ -171,7 +156,7 @@ const setEncodedAudioUploaded = `-- name: SetEncodedAudioUploaded :one
|
||||||
UPDATE media_sets
|
UPDATE media_sets
|
||||||
SET audio_encoded_s3_key = $2, audio_encoded_s3_uploaded_at = NOW(), updated_at = NOW()
|
SET audio_encoded_s3_key = $2, audio_encoded_s3_uploaded_at = NOW(), updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, youtube_id, audio_youtube_itag, audio_channels, audio_frames_approx, audio_frames, audio_sample_rate, audio_raw_s3_key, audio_raw_s3_uploaded_at, audio_encoded_mime_type, video_youtube_itag, video_s3_key, video_s3_uploaded_at, video_mime_type, video_duration_nanos, video_thumbnail_s3_key, video_thumbnail_s3_uploaded_at, video_thumbnail_mime_type, video_thumbnail_width, video_thumbnail_height, created_at, updated_at, audio_content_length, video_content_length, audio_encoded_s3_key, audio_encoded_s3_uploaded_at, title, description, author
|
RETURNING id, youtube_id, audio_youtube_itag, audio_channels, audio_frames_approx, audio_frames, audio_sample_rate, audio_raw_s3_key, audio_raw_s3_uploaded_at, audio_encoded_mime_type, video_youtube_itag, video_s3_key, video_s3_uploaded_at, video_mime_type, video_duration_nanos, video_thumbnail_s3_key, video_thumbnail_s3_uploaded_at, video_thumbnail_mime_type, video_thumbnail_width, video_thumbnail_height, created_at, updated_at, audio_content_length, video_content_length, audio_encoded_s3_key, audio_encoded_s3_uploaded_at
|
||||||
`
|
`
|
||||||
|
|
||||||
type SetEncodedAudioUploadedParams struct {
|
type SetEncodedAudioUploadedParams struct {
|
||||||
|
@ -209,9 +194,6 @@ func (q *Queries) SetEncodedAudioUploaded(ctx context.Context, arg SetEncodedAud
|
||||||
&i.VideoContentLength,
|
&i.VideoContentLength,
|
||||||
&i.AudioEncodedS3Key,
|
&i.AudioEncodedS3Key,
|
||||||
&i.AudioEncodedS3UploadedAt,
|
&i.AudioEncodedS3UploadedAt,
|
||||||
&i.Title,
|
|
||||||
&i.Description,
|
|
||||||
&i.Author,
|
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
@ -220,7 +202,7 @@ const setRawAudioUploaded = `-- name: SetRawAudioUploaded :one
|
||||||
UPDATE media_sets
|
UPDATE media_sets
|
||||||
SET audio_raw_s3_key = $2, audio_frames = $3, audio_raw_s3_uploaded_at = NOW(), updated_at = NOW()
|
SET audio_raw_s3_key = $2, audio_frames = $3, audio_raw_s3_uploaded_at = NOW(), updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, youtube_id, audio_youtube_itag, audio_channels, audio_frames_approx, audio_frames, audio_sample_rate, audio_raw_s3_key, audio_raw_s3_uploaded_at, audio_encoded_mime_type, video_youtube_itag, video_s3_key, video_s3_uploaded_at, video_mime_type, video_duration_nanos, video_thumbnail_s3_key, video_thumbnail_s3_uploaded_at, video_thumbnail_mime_type, video_thumbnail_width, video_thumbnail_height, created_at, updated_at, audio_content_length, video_content_length, audio_encoded_s3_key, audio_encoded_s3_uploaded_at, title, description, author
|
RETURNING id, youtube_id, audio_youtube_itag, audio_channels, audio_frames_approx, audio_frames, audio_sample_rate, audio_raw_s3_key, audio_raw_s3_uploaded_at, audio_encoded_mime_type, video_youtube_itag, video_s3_key, video_s3_uploaded_at, video_mime_type, video_duration_nanos, video_thumbnail_s3_key, video_thumbnail_s3_uploaded_at, video_thumbnail_mime_type, video_thumbnail_width, video_thumbnail_height, created_at, updated_at, audio_content_length, video_content_length, audio_encoded_s3_key, audio_encoded_s3_uploaded_at
|
||||||
`
|
`
|
||||||
|
|
||||||
type SetRawAudioUploadedParams struct {
|
type SetRawAudioUploadedParams struct {
|
||||||
|
@ -259,9 +241,6 @@ func (q *Queries) SetRawAudioUploaded(ctx context.Context, arg SetRawAudioUpload
|
||||||
&i.VideoContentLength,
|
&i.VideoContentLength,
|
||||||
&i.AudioEncodedS3Key,
|
&i.AudioEncodedS3Key,
|
||||||
&i.AudioEncodedS3UploadedAt,
|
&i.AudioEncodedS3UploadedAt,
|
||||||
&i.Title,
|
|
||||||
&i.Description,
|
|
||||||
&i.Author,
|
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
@ -270,7 +249,7 @@ const setVideoThumbnailUploaded = `-- name: SetVideoThumbnailUploaded :one
|
||||||
UPDATE media_sets
|
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()
|
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
|
WHERE id = $1
|
||||||
RETURNING id, youtube_id, audio_youtube_itag, audio_channels, audio_frames_approx, audio_frames, audio_sample_rate, audio_raw_s3_key, audio_raw_s3_uploaded_at, audio_encoded_mime_type, video_youtube_itag, video_s3_key, video_s3_uploaded_at, video_mime_type, video_duration_nanos, video_thumbnail_s3_key, video_thumbnail_s3_uploaded_at, video_thumbnail_mime_type, video_thumbnail_width, video_thumbnail_height, created_at, updated_at, audio_content_length, video_content_length, audio_encoded_s3_key, audio_encoded_s3_uploaded_at, title, description, author
|
RETURNING id, youtube_id, audio_youtube_itag, audio_channels, audio_frames_approx, audio_frames, audio_sample_rate, audio_raw_s3_key, audio_raw_s3_uploaded_at, audio_encoded_mime_type, video_youtube_itag, video_s3_key, video_s3_uploaded_at, video_mime_type, video_duration_nanos, video_thumbnail_s3_key, video_thumbnail_s3_uploaded_at, video_thumbnail_mime_type, video_thumbnail_width, video_thumbnail_height, created_at, updated_at, audio_content_length, video_content_length, audio_encoded_s3_key, audio_encoded_s3_uploaded_at
|
||||||
`
|
`
|
||||||
|
|
||||||
type SetVideoThumbnailUploadedParams struct {
|
type SetVideoThumbnailUploadedParams struct {
|
||||||
|
@ -317,9 +296,6 @@ func (q *Queries) SetVideoThumbnailUploaded(ctx context.Context, arg SetVideoThu
|
||||||
&i.VideoContentLength,
|
&i.VideoContentLength,
|
||||||
&i.AudioEncodedS3Key,
|
&i.AudioEncodedS3Key,
|
||||||
&i.AudioEncodedS3UploadedAt,
|
&i.AudioEncodedS3UploadedAt,
|
||||||
&i.Title,
|
|
||||||
&i.Description,
|
|
||||||
&i.Author,
|
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
@ -328,7 +304,7 @@ const setVideoUploaded = `-- name: SetVideoUploaded :one
|
||||||
UPDATE media_sets
|
UPDATE media_sets
|
||||||
SET video_s3_key = $2, video_s3_uploaded_at = NOW(), updated_at = NOW()
|
SET video_s3_key = $2, video_s3_uploaded_at = NOW(), updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, youtube_id, audio_youtube_itag, audio_channels, audio_frames_approx, audio_frames, audio_sample_rate, audio_raw_s3_key, audio_raw_s3_uploaded_at, audio_encoded_mime_type, video_youtube_itag, video_s3_key, video_s3_uploaded_at, video_mime_type, video_duration_nanos, video_thumbnail_s3_key, video_thumbnail_s3_uploaded_at, video_thumbnail_mime_type, video_thumbnail_width, video_thumbnail_height, created_at, updated_at, audio_content_length, video_content_length, audio_encoded_s3_key, audio_encoded_s3_uploaded_at, title, description, author
|
RETURNING id, youtube_id, audio_youtube_itag, audio_channels, audio_frames_approx, audio_frames, audio_sample_rate, audio_raw_s3_key, audio_raw_s3_uploaded_at, audio_encoded_mime_type, video_youtube_itag, video_s3_key, video_s3_uploaded_at, video_mime_type, video_duration_nanos, video_thumbnail_s3_key, video_thumbnail_s3_uploaded_at, video_thumbnail_mime_type, video_thumbnail_width, video_thumbnail_height, created_at, updated_at, audio_content_length, video_content_length, audio_encoded_s3_key, audio_encoded_s3_uploaded_at
|
||||||
`
|
`
|
||||||
|
|
||||||
type SetVideoUploadedParams struct {
|
type SetVideoUploadedParams struct {
|
||||||
|
@ -366,9 +342,6 @@ func (q *Queries) SetVideoUploaded(ctx context.Context, arg SetVideoUploadedPara
|
||||||
&i.VideoContentLength,
|
&i.VideoContentLength,
|
||||||
&i.AudioEncodedS3Key,
|
&i.AudioEncodedS3Key,
|
||||||
&i.AudioEncodedS3UploadedAt,
|
&i.AudioEncodedS3UploadedAt,
|
||||||
&i.Title,
|
|
||||||
&i.Description,
|
|
||||||
&i.Author,
|
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,69 +1,61 @@
|
||||||
module git.netflux.io/rob/clipper
|
module git.netflux.io/rob/clipper
|
||||||
|
|
||||||
go 1.19
|
go 1.17
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aws/aws-sdk-go-v2 v1.13.0
|
github.com/aws/aws-sdk-go-v2 v1.11.1
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.13.1
|
github.com/aws/aws-sdk-go-v2/config v1.10.2
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.8.0
|
github.com/aws/aws-sdk-go-v2/credentials v1.6.2
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.24.1
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.19.1
|
||||||
github.com/aws/smithy-go v1.10.0
|
github.com/aws/smithy-go v1.9.0
|
||||||
github.com/google/uuid v1.3.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/grpc-ecosystem/go-grpc-middleware v1.3.0
|
||||||
github.com/improbable-eng/grpc-web v0.15.0
|
github.com/improbable-eng/grpc-web v0.15.0
|
||||||
github.com/jackc/pgconn v1.11.0
|
github.com/jackc/pgconn v1.10.1
|
||||||
github.com/jackc/pgx/v4 v4.15.0
|
github.com/jackc/pgx/v4 v4.14.0
|
||||||
github.com/kkdai/youtube/v2 v2.7.10
|
github.com/kkdai/youtube/v2 v2.7.4
|
||||||
github.com/stretchr/testify v1.7.0
|
github.com/stretchr/testify v1.7.0
|
||||||
go.uber.org/zap v1.21.0
|
go.uber.org/zap v1.19.1
|
||||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
|
google.golang.org/grpc v1.42.0
|
||||||
google.golang.org/grpc v1.44.0
|
|
||||||
google.golang.org/protobuf v1.27.1
|
google.golang.org/protobuf v1.27.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.2.0 // indirect
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.0.0 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.10.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.4 // 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.2.0 // 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.5 // 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.7.0 // 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.7.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.11.0 // 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.9.0 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sso v1.6.1 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.14.0 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sts v1.10.1 // indirect
|
||||||
github.com/bitly/go-simplejson v0.5.0 // indirect
|
github.com/bitly/go-simplejson v0.5.0 // indirect
|
||||||
github.com/cenkalti/backoff/v4 v4.1.2 // indirect
|
github.com/cenkalti/backoff/v4 v4.1.2 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f // 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/golang/protobuf v1.5.2 // indirect
|
||||||
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
|
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
|
||||||
github.com/jackc/pgio v1.0.0 // indirect
|
github.com/jackc/pgio v1.0.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgproto3/v2 v2.2.0 // indirect
|
github.com/jackc/pgproto3/v2 v2.2.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
|
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
|
||||||
github.com/jackc/pgtype v1.10.0 // indirect
|
github.com/jackc/pgtype v1.9.0 // indirect
|
||||||
github.com/jackc/puddle v1.2.1 // indirect
|
github.com/jackc/puddle v1.2.0 // indirect
|
||||||
github.com/klauspost/compress v1.14.2 // indirect
|
github.com/klauspost/compress v1.13.6 // indirect
|
||||||
github.com/lib/pq v1.10.4 // indirect
|
github.com/lib/pq v1.10.4 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.14 // indirect
|
github.com/mattn/go-isatty v0.0.14 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/rs/cors v1.8.2 // indirect
|
github.com/rs/cors v1.8.0 // indirect
|
||||||
github.com/stretchr/objx v0.3.0 // indirect
|
github.com/stretchr/objx v0.2.0 // indirect
|
||||||
go.uber.org/atomic v1.9.0 // indirect
|
go.uber.org/atomic v1.9.0 // indirect
|
||||||
go.uber.org/multierr v1.7.0 // indirect
|
go.uber.org/multierr v1.7.0 // indirect
|
||||||
golang.org/x/crypto v0.0.0-20220210151621-f4118a5b28e2 // indirect
|
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 // indirect
|
||||||
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect
|
golang.org/x/net v0.0.0-20210913180222-943fd674d43e // indirect
|
||||||
golang.org/x/sys v0.0.0-20220209214540-3681064d5158 // indirect
|
golang.org/x/sys v0.0.0-20210910150752-751e447fb3d0 // indirect
|
||||||
golang.org/x/text v0.3.7 // indirect
|
golang.org/x/text v0.3.7 // indirect
|
||||||
google.golang.org/genproto v0.0.0-20220208230804-65c12eb4c068 // indirect
|
google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
|
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
|
||||||
nhooyr.io/websocket v1.8.7 // 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"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"git.netflux.io/rob/clipper/config"
|
"git.netflux.io/rob/clipper/config"
|
||||||
"git.netflux.io/rob/clipper/generated/store"
|
"git.netflux.io/rob/clipper/generated/store"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"golang.org/x/sync/errgroup"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type GetPeaksProgress struct {
|
type GetPeaksProgress struct {
|
||||||
PercentComplete float32
|
PercentComplete float32
|
||||||
Peaks []int16
|
Peaks []int16
|
||||||
URL string
|
URL string
|
||||||
AudioFrames int64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetPeaksProgressReader interface {
|
type GetPeaksProgressReader interface {
|
||||||
|
@ -29,25 +29,21 @@ type GetPeaksProgressReader interface {
|
||||||
|
|
||||||
// audioGetter manages getting and processing audio from Youtube.
|
// audioGetter manages getting and processing audio from Youtube.
|
||||||
type audioGetter struct {
|
type audioGetter struct {
|
||||||
store Store
|
store Store
|
||||||
youtube YoutubeClient
|
youtube YoutubeClient
|
||||||
fileStore FileStore
|
fileStore FileStore
|
||||||
commandFunc CommandFunc
|
config config.Config
|
||||||
workerPool *WorkerPool
|
logger *zap.SugaredLogger
|
||||||
config config.Config
|
|
||||||
logger *zap.SugaredLogger
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// newAudioGetter returns a new audioGetter.
|
// newAudioGetter returns a new audioGetter.
|
||||||
func newAudioGetter(store Store, youtube YoutubeClient, fileStore FileStore, commandFunc CommandFunc, workerPool *WorkerPool, config config.Config, logger *zap.SugaredLogger) *audioGetter {
|
func newAudioGetter(store Store, youtube YoutubeClient, fileStore FileStore, config config.Config, logger *zap.SugaredLogger) *audioGetter {
|
||||||
return &audioGetter{
|
return &audioGetter{
|
||||||
store: store,
|
store: store,
|
||||||
youtube: youtube,
|
youtube: youtube,
|
||||||
fileStore: fileStore,
|
fileStore: fileStore,
|
||||||
commandFunc: commandFunc,
|
config: config,
|
||||||
workerPool: workerPool,
|
logger: logger,
|
||||||
config: config,
|
|
||||||
logger: logger,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -64,7 +60,7 @@ func (g *audioGetter) GetAudio(ctx context.Context, mediaSet store.MediaSet, num
|
||||||
|
|
||||||
format := video.Formats.FindByItag(int(mediaSet.AudioYoutubeItag))
|
format := video.Formats.FindByItag(int(mediaSet.AudioYoutubeItag))
|
||||||
if format == nil {
|
if format == nil {
|
||||||
return nil, fmt.Errorf("error finding itag: %d", mediaSet.AudioYoutubeItag)
|
return nil, fmt.Errorf("error finding itag: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stream, _, err := g.youtube.GetStreamContext(ctx, video, format)
|
stream, _, err := g.youtube.GetStreamContext(ctx, video, format)
|
||||||
|
@ -81,13 +77,7 @@ func (g *audioGetter) GetAudio(ctx context.Context, mediaSet store.MediaSet, num
|
||||||
audioGetter: g,
|
audioGetter: g,
|
||||||
getPeaksProgressReader: audioProgressReader,
|
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
|
return s, nil
|
||||||
}
|
}
|
||||||
|
@ -98,109 +88,96 @@ type audioGetterState struct {
|
||||||
*getPeaksProgressReader
|
*getPeaksProgressReader
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *audioGetterState) getAudio(ctx context.Context, r io.ReadCloser, mediaSet store.MediaSet) error {
|
func (s *audioGetterState) getAudio(ctx context.Context, r io.ReadCloser, mediaSet store.MediaSet) {
|
||||||
streamWithProgress := newLogProgressReader(r, "audio", mediaSet.AudioContentLength, s.logger)
|
streamWithProgress := newLogProgressReader(r, "audio", mediaSet.AudioContentLength, s.logger)
|
||||||
|
pr, pw := io.Pipe()
|
||||||
|
teeReader := io.TeeReader(streamWithProgress, pw)
|
||||||
|
|
||||||
var stdErr bytes.Buffer
|
var stdErr bytes.Buffer
|
||||||
cmd := s.commandFunc(ctx, "ffmpeg", "-hide_banner", "-loglevel", "error", "-i", "-", "-f", rawAudioFormat, "-ar", strconv.Itoa(rawAudioSampleRate), "-acodec", rawAudioCodec, "-")
|
cmd := exec.CommandContext(ctx, "ffmpeg", "-hide_banner", "-loglevel", "error", "-i", "-", "-f", rawAudioFormat, "-ar", strconv.Itoa(rawAudioSampleRate), "-acodec", rawAudioCodec, "-")
|
||||||
|
cmd.Stdin = teeReader
|
||||||
cmd.Stderr = &stdErr
|
cmd.Stderr = &stdErr
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
// ffmpegWriter accepts encoded audio and pipes it to FFmpeg.
|
|
||||||
ffmpegWriter, err := cmd.StdinPipe()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error getting stdin: %v", err)
|
s.CloseWithError(fmt.Errorf("error getting stdout: %v", err))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
if err = cmd.Start(); err != nil {
|
||||||
uploadReader, uploadWriter := io.Pipe()
|
s.CloseWithError(fmt.Errorf("error starting command: %v, output: %s", err, stdErr.String()))
|
||||||
mw := io.MultiWriter(uploadWriter, ffmpegWriter)
|
return
|
||||||
|
|
||||||
// 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 presignedAudioURL string
|
||||||
g, ctx := errgroup.WithContext(ctx)
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
|
||||||
// Upload the encoded audio.
|
// Upload the encoded audio.
|
||||||
g.Go(func() error {
|
// TODO: fix error shadowing in these two goroutines.
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
// TODO: use mediaSet func to fetch key
|
// TODO: use mediaSet func to fetch key
|
||||||
key := fmt.Sprintf("media_sets/%s/audio.opus", mediaSet.ID)
|
key := fmt.Sprintf("media_sets/%s/audio.opus", mediaSet.ID)
|
||||||
|
|
||||||
_, encErr := s.fileStore.PutObject(ctx, key, uploadReader, "audio/opus")
|
_, encErr := s.fileStore.PutObject(ctx, key, pr, "audio/opus")
|
||||||
if encErr != nil {
|
if encErr != nil {
|
||||||
return fmt.Errorf("error uploading encoded audio: %v", encErr)
|
s.CloseWithError(fmt.Errorf("error uploading encoded audio: %v", encErr))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
presignedAudioURL, encErr = s.fileStore.GetURL(ctx, key)
|
presignedAudioURL, err = s.fileStore.GetURL(ctx, key)
|
||||||
if encErr != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error generating presigned URL: %v", encErr)
|
s.CloseWithError(fmt.Errorf("error generating presigned URL: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, encErr = s.store.SetEncodedAudioUploaded(ctx, store.SetEncodedAudioUploadedParams{
|
if _, err = s.store.SetEncodedAudioUploaded(ctx, store.SetEncodedAudioUploadedParams{
|
||||||
ID: mediaSet.ID,
|
ID: mediaSet.ID,
|
||||||
AudioEncodedS3Key: sqlString(key),
|
AudioEncodedS3Key: sqlString(key),
|
||||||
}); encErr != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("error setting encoded audio uploaded: %v", encErr)
|
s.CloseWithError(fmt.Errorf("error setting encoded audio uploaded: %v", err))
|
||||||
}
|
}
|
||||||
|
}()
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
// Upload the raw audio.
|
// Upload the raw audio.
|
||||||
g.Go(func() error {
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
// TODO: use mediaSet func to fetch key
|
// TODO: use mediaSet func to fetch key
|
||||||
key := fmt.Sprintf("media_sets/%s/audio.raw", mediaSet.ID)
|
key := fmt.Sprintf("media_sets/%s/audio.raw", mediaSet.ID)
|
||||||
|
|
||||||
bytesUploaded, rawErr := s.fileStore.PutObject(ctx, key, ffmpegReader, rawAudioMimeType)
|
teeReader := io.TeeReader(stdout, s)
|
||||||
|
bytesUploaded, rawErr := s.fileStore.PutObject(ctx, key, teeReader, rawAudioMimeType)
|
||||||
if rawErr != nil {
|
if rawErr != nil {
|
||||||
return fmt.Errorf("error uploading raw audio: %v", rawErr)
|
s.CloseWithError(fmt.Errorf("error uploading raw audio: %v", rawErr))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, rawErr = s.store.SetRawAudioUploaded(ctx, store.SetRawAudioUploadedParams{
|
if _, err = s.store.SetRawAudioUploaded(ctx, store.SetRawAudioUploadedParams{
|
||||||
ID: mediaSet.ID,
|
ID: mediaSet.ID,
|
||||||
AudioRawS3Key: sqlString(key),
|
AudioRawS3Key: sqlString(key),
|
||||||
AudioFrames: sqlInt64(bytesUploaded / SizeOfInt16 / int64(mediaSet.AudioChannels)),
|
AudioFrames: sqlInt64(bytesUploaded / SizeOfInt16 / int64(mediaSet.AudioChannels)),
|
||||||
}); rawErr != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("error setting raw audio uploaded: %v", rawErr)
|
s.CloseWithError(fmt.Errorf("error setting raw audio uploaded: %v", err))
|
||||||
}
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
return nil
|
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()))
|
||||||
g.Go(func() error {
|
return
|
||||||
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 {
|
// Close the pipe sending encoded audio to be uploaded, this ensures the
|
||||||
return fmt.Errorf("error uploading: %v", err)
|
// uploader reading from the pipe will receive io.EOF and complete
|
||||||
}
|
// successfully.
|
||||||
|
pw.Close()
|
||||||
|
|
||||||
if err := cmd.Wait(); err != nil {
|
// Wait for the uploaders to complete.
|
||||||
return fmt.Errorf("error waiting for command: %v, output: %s", err, stdErr.String())
|
wg.Wait()
|
||||||
}
|
|
||||||
|
|
||||||
// Finally, close the progress reader so that the subsequent call to Next()
|
// Finally, close the progress reader so that the subsequent call to Next()
|
||||||
// returns the presigned URL and io.EOF.
|
// returns the presigned URL and io.EOF.
|
||||||
s.Close(presignedAudioURL)
|
s.Close(presignedAudioURL)
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getPeaksProgressReader accepts a byte stream containing little endian
|
// getPeaksProgressReader accepts a byte stream containing little endian
|
||||||
|
@ -252,12 +229,7 @@ func (w *getPeaksProgressReader) Next() (GetPeaksProgress, error) {
|
||||||
select {
|
select {
|
||||||
case progress, ok := <-w.progress:
|
case progress, ok := <-w.progress:
|
||||||
if !ok {
|
if !ok {
|
||||||
return GetPeaksProgress{
|
return GetPeaksProgress{Peaks: w.currPeaks, PercentComplete: w.percentComplete(), URL: w.url}, io.EOF
|
||||||
Peaks: w.currPeaks,
|
|
||||||
PercentComplete: w.percentComplete(),
|
|
||||||
URL: w.url,
|
|
||||||
AudioFrames: w.framesProcessed,
|
|
||||||
}, io.EOF
|
|
||||||
}
|
}
|
||||||
return progress, nil
|
return progress, nil
|
||||||
case err := <-w.errorChan:
|
case err := <-w.errorChan:
|
||||||
|
|
|
@ -1,12 +1,10 @@
|
||||||
package media_test
|
package media_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -15,186 +13,16 @@ import (
|
||||||
"git.netflux.io/rob/clipper/generated/store"
|
"git.netflux.io/rob/clipper/generated/store"
|
||||||
"git.netflux.io/rob/clipper/media"
|
"git.netflux.io/rob/clipper/media"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/kkdai/youtube/v2"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGetAudioFromYoutube(t *testing.T) {
|
|
||||||
const (
|
|
||||||
videoID = "abcdef12"
|
|
||||||
inFixturePath = "testdata/tone-44100-stereo-int16-30000ms.raw"
|
|
||||||
inFixtureLen = int64(5_292_000)
|
|
||||||
inFixtureFrames = inFixtureLen / 4 // stereo-int16
|
|
||||||
)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
wp := media.NewTestWorkerPool()
|
|
||||||
mediaSetID := uuid.New()
|
|
||||||
mediaSet := store.MediaSet{
|
|
||||||
ID: mediaSetID,
|
|
||||||
YoutubeID: videoID,
|
|
||||||
AudioYoutubeItag: 123,
|
|
||||||
AudioChannels: 2,
|
|
||||||
AudioFramesApprox: inFixtureFrames,
|
|
||||||
AudioContentLength: 22,
|
|
||||||
}
|
|
||||||
|
|
||||||
video := &youtube.Video{
|
|
||||||
ID: videoID,
|
|
||||||
Formats: []youtube.Format{{ItagNo: 123, FPS: 0, AudioChannels: 2}},
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("NOK,ErrorFetchingMediaSet", func(t *testing.T) {
|
|
||||||
var mockStore mocks.Store
|
|
||||||
mockStore.On("GetMediaSet", mock.Anything, mediaSetID).Return(store.MediaSet{}, errors.New("db went boom"))
|
|
||||||
service := media.NewMediaSetService(&mockStore, nil, nil, nil, wp, config.Config{}, zap.NewNop().Sugar())
|
|
||||||
_, err := service.GetPeaks(ctx, mediaSetID, 10)
|
|
||||||
assert.EqualError(t, err, "error getting media set: db went boom")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("NOK,ErrorFetchingStream", func(t *testing.T) {
|
|
||||||
var mockStore mocks.Store
|
|
||||||
mockStore.On("GetMediaSet", mock.Anything, mediaSetID).Return(mediaSet, nil)
|
|
||||||
var youtubeClient mocks.YoutubeClient
|
|
||||||
youtubeClient.On("GetVideoContext", mock.Anything, mediaSet.YoutubeID).Return(video, nil)
|
|
||||||
youtubeClient.On("GetStreamContext", mock.Anything, video, &video.Formats[0]).Return(nil, int64(0), errors.New("uh oh"))
|
|
||||||
|
|
||||||
service := media.NewMediaSetService(&mockStore, &youtubeClient, nil, nil, wp, config.Config{}, zap.NewNop().Sugar())
|
|
||||||
_, err := service.GetPeaks(ctx, mediaSetID, 10)
|
|
||||||
assert.EqualError(t, err, "error fetching stream: uh oh")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("NOK,ErrorBuildingProgressReader", func(t *testing.T) {
|
|
||||||
invalidMediaSet := mediaSet
|
|
||||||
invalidMediaSet.AudioChannels = 0
|
|
||||||
var mockStore mocks.Store
|
|
||||||
mockStore.On("GetMediaSet", mock.Anything, mediaSetID).Return(invalidMediaSet, nil)
|
|
||||||
|
|
||||||
var youtubeClient mocks.YoutubeClient
|
|
||||||
youtubeClient.On("GetVideoContext", mock.Anything, mediaSet.YoutubeID).Return(video, nil)
|
|
||||||
youtubeClient.On("GetStreamContext", mock.Anything, video, &video.Formats[0]).Return(nil, int64(0), nil)
|
|
||||||
|
|
||||||
service := media.NewMediaSetService(&mockStore, &youtubeClient, nil, nil, wp, config.Config{}, zap.NewNop().Sugar())
|
|
||||||
_, err := service.GetPeaks(ctx, mediaSetID, 10)
|
|
||||||
assert.EqualError(t, err, "error building progress reader: error creating audio progress reader (framesExpected = 1323000, channels = 0, numBins = 10)")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("NOK,UploadError", func(t *testing.T) {
|
|
||||||
var mockStore mocks.Store
|
|
||||||
mockStore.On("GetMediaSet", mock.Anything, mediaSetID).Return(mediaSet, nil)
|
|
||||||
mockStore.On("SetEncodedAudioUploaded", mock.Anything, mock.Anything).Return(mediaSet, nil)
|
|
||||||
|
|
||||||
var youtubeClient mocks.YoutubeClient
|
|
||||||
youtubeClient.On("GetVideoContext", mock.Anything, mediaSet.YoutubeID).Return(video, nil)
|
|
||||||
youtubeClient.On("GetStreamContext", mock.Anything, video, &video.Formats[0]).Return(io.NopCloser(bytes.NewReader(nil)), int64(0), nil)
|
|
||||||
|
|
||||||
var fileStore mocks.FileStore
|
|
||||||
fileStore.On("PutObject", mock.Anything, mock.Anything, mock.Anything, "audio/raw").Return(int64(0), errors.New("network error"))
|
|
||||||
fileStore.On("PutObject", mock.Anything, mock.Anything, mock.Anything, "audio/opus").Return(int64(0), nil)
|
|
||||||
fileStore.On("GetURL", mock.Anything, mock.Anything).Return("", nil)
|
|
||||||
|
|
||||||
cmd := helperCommand(t, "", inFixturePath, "", 0)
|
|
||||||
service := media.NewMediaSetService(&mockStore, &youtubeClient, &fileStore, cmd, wp, config.Config{}, zap.NewNop().Sugar())
|
|
||||||
stream, err := service.GetPeaks(ctx, mediaSetID, 10)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
_, err = stream.Next()
|
|
||||||
assert.EqualError(t, err, "error waiting for progress: error uploading: error uploading raw audio: network error")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("NOK,FFmpegError", func(t *testing.T) {
|
|
||||||
var mockStore mocks.Store
|
|
||||||
mockStore.On("GetMediaSet", mock.Anything, mediaSetID).Return(mediaSet, nil)
|
|
||||||
mockStore.On("SetEncodedAudioUploaded", mock.Anything, mock.Anything).Return(mediaSet, nil)
|
|
||||||
mockStore.On("SetRawAudioUploaded", mock.Anything, mock.Anything).Return(mediaSet, nil)
|
|
||||||
|
|
||||||
var youtubeClient mocks.YoutubeClient
|
|
||||||
youtubeClient.On("GetVideoContext", mock.Anything, mediaSet.YoutubeID).Return(video, nil)
|
|
||||||
youtubeClient.On("GetStreamContext", mock.Anything, video, &video.Formats[0]).Return(io.NopCloser(strings.NewReader("some audio")), int64(0), nil)
|
|
||||||
|
|
||||||
var fileStore mocks.FileStore
|
|
||||||
fileStore.On("PutObject", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
|
||||||
Run(func(args mock.Arguments) {
|
|
||||||
_, err := io.Copy(io.Discard, args[2].(io.Reader))
|
|
||||||
require.NoError(t, err)
|
|
||||||
}).
|
|
||||||
Return(int64(0), nil)
|
|
||||||
fileStore.On("GetURL", mock.Anything, mock.Anything).Return("", nil)
|
|
||||||
|
|
||||||
cmd := helperCommand(t, "", inFixturePath, "oh no", 101)
|
|
||||||
service := media.NewMediaSetService(&mockStore, &youtubeClient, &fileStore, cmd, wp, config.Config{}, zap.NewNop().Sugar())
|
|
||||||
stream, err := service.GetPeaks(ctx, mediaSetID, 10)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
_, err = stream.Next()
|
|
||||||
assert.EqualError(t, err, "error waiting for progress: error waiting for command: exit status 101, output: oh no")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("OK", func(t *testing.T) {
|
|
||||||
// Mock Store
|
|
||||||
var mockStore mocks.Store
|
|
||||||
mockStore.On("GetMediaSet", mock.Anything, mediaSetID).Return(mediaSet, nil)
|
|
||||||
mockStore.On("SetRawAudioUploaded", mock.Anything, mock.MatchedBy(func(p store.SetRawAudioUploadedParams) bool {
|
|
||||||
return p.ID == mediaSetID && p.AudioFrames.Int64 == inFixtureFrames
|
|
||||||
})).Return(mediaSet, nil)
|
|
||||||
mockStore.On("SetEncodedAudioUploaded", mock.Anything, mock.MatchedBy(func(p store.SetEncodedAudioUploadedParams) bool {
|
|
||||||
return p.ID == mediaSetID
|
|
||||||
})).Return(mediaSet, nil)
|
|
||||||
defer mockStore.AssertExpectations(t)
|
|
||||||
|
|
||||||
// Mock YoutubeClient
|
|
||||||
encodedContent := "this is an opus stream"
|
|
||||||
reader := io.NopCloser(strings.NewReader(encodedContent))
|
|
||||||
|
|
||||||
var youtubeClient mocks.YoutubeClient
|
|
||||||
youtubeClient.On("GetVideoContext", mock.Anything, mediaSet.YoutubeID).Return(video, nil)
|
|
||||||
youtubeClient.On("GetStreamContext", mock.Anything, video, &video.Formats[0]).Return(reader, int64(len(encodedContent)), nil)
|
|
||||||
defer youtubeClient.AssertExpectations(t)
|
|
||||||
|
|
||||||
// Mock FileStore
|
|
||||||
// It is necessary to consume the readers passed into the mocks to avoid IO
|
|
||||||
// errors. Since we're doing that we can also assert the content that is
|
|
||||||
// passed to them is as expected.
|
|
||||||
url := "https://www.example.com/foo"
|
|
||||||
var fileStore mocks.FileStore
|
|
||||||
fileStore.On("PutObject", mock.Anything, "media_sets/"+mediaSetID.String()+"/audio.opus", mock.Anything, "audio/opus").
|
|
||||||
Run(func(args mock.Arguments) {
|
|
||||||
readContent, err := io.ReadAll(args[2].(io.Reader))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, encodedContent, string(readContent))
|
|
||||||
}).
|
|
||||||
Return(int64(len(encodedContent)), nil)
|
|
||||||
fileStore.On("PutObject", mock.Anything, "media_sets/"+mediaSetID.String()+"/audio.raw", mock.Anything, "audio/raw").
|
|
||||||
Run(func(args mock.Arguments) {
|
|
||||||
n, err := io.Copy(io.Discard, args[2].(io.Reader))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, inFixtureLen, n)
|
|
||||||
}).
|
|
||||||
Return(inFixtureLen, nil)
|
|
||||||
fileStore.On("GetURL", mock.Anything, "media_sets/"+mediaSetID.String()+"/audio.opus").Return(url, nil)
|
|
||||||
defer fileStore.AssertExpectations(t)
|
|
||||||
|
|
||||||
numBins := 10
|
|
||||||
cmd := helperCommand(t, "ffmpeg -hide_banner -loglevel error -i - -f s16le -ar 48000 -acodec pcm_s16le -", inFixturePath, "", 0)
|
|
||||||
service := media.NewMediaSetService(&mockStore, &youtubeClient, &fileStore, cmd, wp, config.Config{}, zap.NewNop().Sugar())
|
|
||||||
stream, err := service.GetPeaks(ctx, mediaSetID, numBins)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assertConsumeStream(t, numBins, url, 1_323_000, stream)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetPeaksFromFileStore(t *testing.T) {
|
func TestGetPeaksFromFileStore(t *testing.T) {
|
||||||
const (
|
const inFixturePath = "testdata/tone-44100-stereo-int16-30000ms.raw"
|
||||||
inFixturePath = "testdata/tone-44100-stereo-int16-30000ms.raw"
|
|
||||||
inFixtureLen = 5_292_000
|
|
||||||
)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
wp := media.NewTestWorkerPool()
|
|
||||||
logger := zap.NewNop().Sugar()
|
logger := zap.NewNop().Sugar()
|
||||||
mediaSetID := uuid.New()
|
mediaSetID := uuid.New()
|
||||||
mediaSet := store.MediaSet{
|
mediaSet := store.MediaSet{
|
||||||
|
@ -210,7 +38,7 @@ func TestGetPeaksFromFileStore(t *testing.T) {
|
||||||
t.Run("NOK,ErrorFetchingMediaSet", func(t *testing.T) {
|
t.Run("NOK,ErrorFetchingMediaSet", func(t *testing.T) {
|
||||||
var mockStore mocks.Store
|
var mockStore mocks.Store
|
||||||
mockStore.On("GetMediaSet", mock.Anything, mediaSetID).Return(store.MediaSet{}, errors.New("db went boom"))
|
mockStore.On("GetMediaSet", mock.Anything, mediaSetID).Return(store.MediaSet{}, errors.New("db went boom"))
|
||||||
service := media.NewMediaSetService(&mockStore, nil, nil, nil, wp, config.Config{}, logger)
|
service := media.NewMediaSetService(&mockStore, nil, nil, nil, config.Config{}, logger)
|
||||||
_, err := service.GetPeaks(ctx, mediaSetID, 10)
|
_, err := service.GetPeaks(ctx, mediaSetID, 10)
|
||||||
assert.EqualError(t, err, "error getting media set: db went boom")
|
assert.EqualError(t, err, "error getting media set: db went boom")
|
||||||
})
|
})
|
||||||
|
@ -223,7 +51,7 @@ func TestGetPeaksFromFileStore(t *testing.T) {
|
||||||
var fileStore mocks.FileStore
|
var fileStore mocks.FileStore
|
||||||
fileStore.On("GetObject", mock.Anything, "raw audio key").Return(nil, errors.New("boom"))
|
fileStore.On("GetObject", mock.Anything, "raw audio key").Return(nil, errors.New("boom"))
|
||||||
|
|
||||||
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, wp, config.Config{}, logger)
|
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, config.Config{}, logger)
|
||||||
_, err := service.GetPeaks(ctx, mediaSetID, 10)
|
_, err := service.GetPeaks(ctx, mediaSetID, 10)
|
||||||
require.EqualError(t, err, "error getting object from file store: boom")
|
require.EqualError(t, err, "error getting object from file store: boom")
|
||||||
})
|
})
|
||||||
|
@ -234,12 +62,12 @@ func TestGetPeaksFromFileStore(t *testing.T) {
|
||||||
defer mockStore.AssertExpectations(t)
|
defer mockStore.AssertExpectations(t)
|
||||||
|
|
||||||
var fileStore mocks.FileStore
|
var fileStore mocks.FileStore
|
||||||
reader := fixtureReader(t, inFixturePath, inFixtureLen)
|
reader := fixtureReader(t, inFixturePath, 5_292_000)
|
||||||
fileStore.On("GetObject", mock.Anything, "raw audio key").Return(reader, nil)
|
fileStore.On("GetObject", mock.Anything, "raw audio key").Return(reader, nil)
|
||||||
fileStore.On("GetURL", mock.Anything, "encoded audio key").Return("", errors.New("network error"))
|
fileStore.On("GetURL", mock.Anything, "encoded audio key").Return("", errors.New("network error"))
|
||||||
defer fileStore.AssertExpectations(t)
|
defer fileStore.AssertExpectations(t)
|
||||||
|
|
||||||
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, wp, config.Config{}, logger)
|
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, config.Config{}, logger)
|
||||||
stream, err := service.GetPeaks(ctx, mediaSetID, 10)
|
stream, err := service.GetPeaks(ctx, mediaSetID, 10)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
@ -261,58 +89,48 @@ func TestGetPeaksFromFileStore(t *testing.T) {
|
||||||
defer mockStore.AssertExpectations(t)
|
defer mockStore.AssertExpectations(t)
|
||||||
|
|
||||||
var fileStore mocks.FileStore
|
var fileStore mocks.FileStore
|
||||||
url := "https://www.example.com/foo"
|
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("GetObject", mock.Anything, "raw audio key").Return(reader, nil)
|
||||||
fileStore.On("GetURL", mock.Anything, "encoded audio key").Return(url, nil)
|
fileStore.On("GetURL", mock.Anything, "encoded audio key").Return("https://www.example.com/foo", nil)
|
||||||
defer fileStore.AssertExpectations(t)
|
defer fileStore.AssertExpectations(t)
|
||||||
|
|
||||||
numBins := 10
|
numBins := 10
|
||||||
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, wp, config.Config{}, logger)
|
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, config.Config{}, logger)
|
||||||
stream, err := service.GetPeaks(ctx, mediaSetID, numBins)
|
stream, err := service.GetPeaks(ctx, mediaSetID, numBins)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assertConsumeStream(t, numBins, url, 1_323_000, stream)
|
lastPeaks := make([]int16, 2) // stereo
|
||||||
|
var (
|
||||||
|
count int
|
||||||
|
lastPercentComplete float32
|
||||||
|
lastURL string
|
||||||
|
)
|
||||||
|
|
||||||
|
for {
|
||||||
|
progress, err := stream.Next()
|
||||||
|
if err != io.EOF {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Len(t, progress.Peaks, 2)
|
||||||
|
assert.GreaterOrEqual(t, progress.PercentComplete, lastPercentComplete)
|
||||||
|
lastPercentComplete = progress.PercentComplete
|
||||||
|
lastURL = progress.URL
|
||||||
|
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// the fixture is a tone gradually increasing in amplitude:
|
||||||
|
assert.Greater(t, progress.Peaks[0], lastPeaks[0])
|
||||||
|
assert.Greater(t, progress.Peaks[1], lastPeaks[1])
|
||||||
|
lastPeaks = progress.Peaks
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, float32(100), lastPercentComplete)
|
||||||
|
assert.Equal(t, []int16{32_767, 32_766}, lastPeaks)
|
||||||
|
assert.Equal(t, numBins, count)
|
||||||
|
assert.Equal(t, "https://www.example.com/foo", lastURL)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// assertConsumeStream asserts that the stream produced by both the
|
|
||||||
// from-youtube and from-filestore flows is identical.
|
|
||||||
func assertConsumeStream(t *testing.T, expBins int, expURL string, expAudioFrames int64, stream media.GetPeaksProgressReader) {
|
|
||||||
lastPeaks := make([]int16, 2) // stereo
|
|
||||||
var (
|
|
||||||
count int
|
|
||||||
lastPercentComplete float32
|
|
||||||
lastURL string
|
|
||||||
lastAudioFrames int64
|
|
||||||
)
|
|
||||||
|
|
||||||
for {
|
|
||||||
progress, err := stream.Next()
|
|
||||||
if err != io.EOF {
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Len(t, progress.Peaks, 2)
|
|
||||||
assert.GreaterOrEqual(t, progress.PercentComplete, lastPercentComplete)
|
|
||||||
lastPercentComplete = progress.PercentComplete
|
|
||||||
lastURL = progress.URL
|
|
||||||
lastAudioFrames = progress.AudioFrames
|
|
||||||
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// the fixture is a tone gradually increasing in amplitude:
|
|
||||||
assert.Greater(t, progress.Peaks[0], lastPeaks[0])
|
|
||||||
assert.Greater(t, progress.Peaks[1], lastPeaks[1])
|
|
||||||
lastPeaks = progress.Peaks
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, float32(100), lastPercentComplete)
|
|
||||||
assert.Equal(t, []int16{32_767, 32_766}, lastPeaks)
|
|
||||||
assert.Equal(t, expBins, count)
|
|
||||||
assert.Equal(t, expURL, lastURL)
|
|
||||||
assert.Equal(t, expAudioFrames, lastAudioFrames)
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
package media
|
package media
|
||||||
|
|
||||||
//go:generate mockery --recursive --name AudioSegmentStream --output ../generated/mocks
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
@ -44,21 +42,14 @@ type AudioSegmentProgress struct {
|
||||||
Data []byte
|
Data []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// AudioSegmentStream implements stream of AudioSegmentProgress structs. The
|
// AudioSegmentStream is a stream of AudioSegmentProgress structs.
|
||||||
// Next() method must be called until it returns io.EOF to avoid resource
|
type AudioSegmentStream struct {
|
||||||
// leakage.
|
|
||||||
type AudioSegmentStream interface {
|
|
||||||
Next(ctx context.Context) (AudioSegmentProgress, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// audioSegmentStream implements AudioSegmentStream.
|
|
||||||
type audioSegmentStream struct {
|
|
||||||
progressChan chan AudioSegmentProgress
|
progressChan chan AudioSegmentProgress
|
||||||
errorChan chan error
|
errorChan chan error
|
||||||
}
|
}
|
||||||
|
|
||||||
// send publishes a new partial segment and progress update to the strean.
|
// 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{
|
s.progressChan <- AudioSegmentProgress{
|
||||||
Data: p,
|
Data: p,
|
||||||
PercentComplete: percentComplete,
|
PercentComplete: percentComplete,
|
||||||
|
@ -66,12 +57,12 @@ func (s *audioSegmentStream) send(p []byte, percentComplete float32) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// close signals the successful end of the stream of data.
|
// close signals the successful end of the stream of data.
|
||||||
func (s *audioSegmentStream) close() {
|
func (s *AudioSegmentStream) close() {
|
||||||
close(s.progressChan)
|
close(s.progressChan)
|
||||||
}
|
}
|
||||||
|
|
||||||
// closeWithError signals the unsuccessful end of a stream of data.
|
// 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
|
s.errorChan <- err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -79,25 +70,23 @@ func (s *audioSegmentStream) closeWithError(err error) {
|
||||||
type audioSegmentGetter struct {
|
type audioSegmentGetter struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
commandFunc CommandFunc
|
commandFunc CommandFunc
|
||||||
workerPool *WorkerPool
|
|
||||||
rawAudio io.ReadCloser
|
rawAudio io.ReadCloser
|
||||||
channels int32
|
channels int32
|
||||||
outFormat AudioFormat
|
outFormat AudioFormat
|
||||||
stream *audioSegmentStream
|
stream *AudioSegmentStream
|
||||||
bytesRead, bytesExpected int64
|
bytesRead, bytesExpected int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// newAudioSegmentGetter returns a new audioSegmentGetter. The io.ReadCloser
|
// newAudioSegmentGetter returns a new audioSegmentGetter. The io.ReadCloser
|
||||||
// will be consumed and closed by the getAudioSegment() function.
|
// will be consumed and closed by the getAudioSegment() function.
|
||||||
func newAudioSegmentGetter(commandFunc CommandFunc, workerPool *WorkerPool, rawAudio io.ReadCloser, channels int32, bytesExpected int64, outFormat AudioFormat) *audioSegmentGetter {
|
func newAudioSegmentGetter(commandFunc CommandFunc, rawAudio io.ReadCloser, channels int32, bytesExpected int64, outFormat AudioFormat) *audioSegmentGetter {
|
||||||
return &audioSegmentGetter{
|
return &audioSegmentGetter{
|
||||||
commandFunc: commandFunc,
|
commandFunc: commandFunc,
|
||||||
workerPool: workerPool,
|
|
||||||
rawAudio: rawAudio,
|
rawAudio: rawAudio,
|
||||||
channels: channels,
|
channels: channels,
|
||||||
bytesExpected: bytesExpected,
|
bytesExpected: bytesExpected,
|
||||||
outFormat: outFormat,
|
outFormat: outFormat,
|
||||||
stream: &audioSegmentStream{
|
stream: &AudioSegmentStream{
|
||||||
progressChan: make(chan AudioSegmentProgress),
|
progressChan: make(chan AudioSegmentProgress),
|
||||||
errorChan: make(chan error, 1),
|
errorChan: make(chan error, 1),
|
||||||
},
|
},
|
||||||
|
@ -131,7 +120,7 @@ func (s *audioSegmentGetter) percentComplete() float32 {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Next implements AudioSegmentStream.
|
// Next implements AudioSegmentStream.
|
||||||
func (s *audioSegmentStream) Next(ctx context.Context) (AudioSegmentProgress, error) {
|
func (s *AudioSegmentStream) Next(ctx context.Context) (AudioSegmentProgress, error) {
|
||||||
select {
|
select {
|
||||||
case progress, ok := <-s.progressChan:
|
case progress, ok := <-s.progressChan:
|
||||||
if !ok {
|
if !ok {
|
||||||
|
@ -148,26 +137,19 @@ func (s *audioSegmentStream) Next(ctx context.Context) (AudioSegmentProgress, er
|
||||||
func (s *audioSegmentGetter) getAudioSegment(ctx context.Context) {
|
func (s *audioSegmentGetter) getAudioSegment(ctx context.Context) {
|
||||||
defer s.rawAudio.Close()
|
defer s.rawAudio.Close()
|
||||||
|
|
||||||
err := s.workerPool.WaitForTask(ctx, func() error {
|
var stdErr bytes.Buffer
|
||||||
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 := s.commandFunc(ctx, "ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "s16le", "-ac", itoa(int(s.channels)), "-ar", itoa(rawAudioSampleRate), "-i", "-", "-f", s.outFormat.String(), "-")
|
cmd.Stderr = &stdErr
|
||||||
cmd.Stderr = &stdErr
|
cmd.Stdin = s
|
||||||
cmd.Stdin = s
|
cmd.Stdout = s
|
||||||
cmd.Stdout = s
|
|
||||||
|
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return fmt.Errorf("error starting command: %v, output: %s", err, stdErr.String())
|
s.stream.closeWithError(fmt.Errorf("error starting command: %v, output: %s", err, stdErr.String()))
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := cmd.Wait(); err != nil {
|
if err := cmd.Wait(); err != nil {
|
||||||
return fmt.Errorf("error waiting for ffmpeg: %v, output: %s", err, stdErr.String())
|
s.stream.closeWithError(fmt.Errorf("error waiting for ffmpeg: %v, output: %s", err, stdErr.String()))
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
s.stream.closeWithError(err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,12 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.netflux.io/rob/clipper/config"
|
"git.netflux.io/rob/clipper/config"
|
||||||
|
@ -19,15 +24,87 @@ import (
|
||||||
"go.uber.org/zap"
|
"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) {
|
func TestGetSegment(t *testing.T) {
|
||||||
const inFixturePath = "testdata/tone-44100-stereo-int16-30000ms.raw"
|
|
||||||
mediaSetID := uuid.MustParse("4c440241-cca9-436f-adb0-be074588cf2b")
|
mediaSetID := uuid.MustParse("4c440241-cca9-436f-adb0-be074588cf2b")
|
||||||
wp := media.NewTestWorkerPool()
|
const inFixturePath = "testdata/tone-44100-stereo-int16-30000ms.raw"
|
||||||
|
|
||||||
t.Run("invalid range", func(t *testing.T) {
|
t.Run("invalid range", func(t *testing.T) {
|
||||||
var mockStore mocks.Store
|
var mockStore mocks.Store
|
||||||
var fileStore mocks.FileStore
|
var fileStore mocks.FileStore
|
||||||
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, wp, config.Config{}, zap.NewNop().Sugar())
|
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, config.Config{}, zap.NewNop().Sugar())
|
||||||
|
|
||||||
stream, err := service.GetAudioSegment(context.Background(), mediaSetID, 1, 0, media.AudioFormatMP3)
|
stream, err := service.GetAudioSegment(context.Background(), mediaSetID, 1, 0, media.AudioFormatMP3)
|
||||||
require.Nil(t, stream)
|
require.Nil(t, stream)
|
||||||
|
@ -38,7 +115,7 @@ func TestGetSegment(t *testing.T) {
|
||||||
var mockStore mocks.Store
|
var mockStore mocks.Store
|
||||||
mockStore.On("GetMediaSet", mock.Anything, mediaSetID).Return(store.MediaSet{}, pgx.ErrNoRows)
|
mockStore.On("GetMediaSet", mock.Anything, mediaSetID).Return(store.MediaSet{}, pgx.ErrNoRows)
|
||||||
var fileStore mocks.FileStore
|
var fileStore mocks.FileStore
|
||||||
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, wp, config.Config{}, zap.NewNop().Sugar())
|
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, config.Config{}, zap.NewNop().Sugar())
|
||||||
|
|
||||||
stream, err := service.GetAudioSegment(context.Background(), mediaSetID, 0, 1, media.AudioFormatMP3)
|
stream, err := service.GetAudioSegment(context.Background(), mediaSetID, 0, 1, media.AudioFormatMP3)
|
||||||
require.Nil(t, stream)
|
require.Nil(t, stream)
|
||||||
|
@ -55,7 +132,7 @@ func TestGetSegment(t *testing.T) {
|
||||||
fileStore.On("GetObjectWithRange", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
fileStore.On("GetObjectWithRange", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
||||||
Return(nil, errors.New("network error"))
|
Return(nil, errors.New("network error"))
|
||||||
|
|
||||||
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, wp, config.Config{}, zap.NewNop().Sugar())
|
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, config.Config{}, zap.NewNop().Sugar())
|
||||||
|
|
||||||
stream, err := service.GetAudioSegment(context.Background(), mediaSetID, 0, 1, media.AudioFormatMP3)
|
stream, err := service.GetAudioSegment(context.Background(), mediaSetID, 0, 1, media.AudioFormatMP3)
|
||||||
require.Nil(t, stream)
|
require.Nil(t, stream)
|
||||||
|
@ -73,7 +150,7 @@ func TestGetSegment(t *testing.T) {
|
||||||
Return(fixtureReader(t, inFixturePath, 1), nil)
|
Return(fixtureReader(t, inFixturePath, 1), nil)
|
||||||
|
|
||||||
cmd := helperCommand(t, "", "", "something bad happened", 2)
|
cmd := helperCommand(t, "", "", "something bad happened", 2)
|
||||||
service := media.NewMediaSetService(&mockStore, nil, &fileStore, cmd, wp, config.Config{}, zap.NewNop().Sugar())
|
service := media.NewMediaSetService(&mockStore, nil, &fileStore, cmd, config.Config{}, zap.NewNop().Sugar())
|
||||||
|
|
||||||
stream, err := service.GetAudioSegment(context.Background(), mediaSetID, 0, 1, media.AudioFormatMP3)
|
stream, err := service.GetAudioSegment(context.Background(), mediaSetID, 0, 1, media.AudioFormatMP3)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
@ -159,7 +236,7 @@ func TestGetSegment(t *testing.T) {
|
||||||
defer fileStore.AssertExpectations(t)
|
defer fileStore.AssertExpectations(t)
|
||||||
|
|
||||||
cmd := helperCommand(t, tc.wantCommand, tc.outFixturePath, "", 0)
|
cmd := helperCommand(t, tc.wantCommand, tc.outFixturePath, "", 0)
|
||||||
service := media.NewMediaSetService(&mockStore, nil, &fileStore, cmd, wp, config.Config{}, zap.NewNop().Sugar())
|
service := media.NewMediaSetService(&mockStore, nil, &fileStore, cmd, config.Config{}, zap.NewNop().Sugar())
|
||||||
|
|
||||||
stream, err := service.GetAudioSegment(ctx, mediaSetID, tc.inStartFrame, tc.inEndFrame, tc.audioFormat)
|
stream, err := service.GetAudioSegment(ctx, mediaSetID, tc.inStartFrame, tc.inEndFrame, tc.audioFormat)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
|
@ -30,7 +30,7 @@ type videoGetter struct {
|
||||||
type videoGetterState struct {
|
type videoGetterState struct {
|
||||||
*videoGetter
|
*videoGetter
|
||||||
|
|
||||||
r io.ReadCloser
|
r io.Reader
|
||||||
count, exp int64
|
count, exp int64
|
||||||
mediaSetID uuid.UUID
|
mediaSetID uuid.UUID
|
||||||
key, contentType string
|
key, contentType string
|
||||||
|
@ -45,14 +45,12 @@ func newVideoGetter(store Store, fileStore FileStore, logger *zap.SugaredLogger)
|
||||||
|
|
||||||
// GetVideo gets video from Youtube and uploads it to a filestore using the
|
// 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()
|
// specified key and content type. The returned reader must have its Next()
|
||||||
// method called until err == io.EOF, otherwise a deadlock or other resource
|
// method called until error = io.EOF, otherwise a deadlock or other resource
|
||||||
// leakage is likely.
|
// 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{
|
s := &videoGetterState{
|
||||||
videoGetter: g,
|
videoGetter: g,
|
||||||
r: r,
|
r: newLogProgressReader(r, "video", exp, g.logger),
|
||||||
exp: exp,
|
exp: exp,
|
||||||
mediaSetID: mediaSetID,
|
mediaSetID: mediaSetID,
|
||||||
key: key,
|
key: key,
|
||||||
|
@ -77,8 +75,7 @@ func (s *videoGetterState) Write(p []byte) (int, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *videoGetterState) getVideo(ctx context.Context) {
|
func (s *videoGetterState) getVideo(ctx context.Context) {
|
||||||
logReader := newLogProgressReader(s.r, "video", s.exp, s.logger)
|
teeReader := io.TeeReader(s.r, s)
|
||||||
teeReader := io.TeeReader(logReader, s)
|
|
||||||
|
|
||||||
_, err := s.fileStore.PutObject(ctx, s.key, teeReader, s.contentType)
|
_, err := s.fileStore.PutObject(ctx, s.key, teeReader, s.contentType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -86,15 +83,9 @@ func (s *videoGetterState) getVideo(ctx context.Context) {
|
||||||
return
|
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)
|
s.url, err = s.fileStore.GetURL(ctx, s.key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.errorChan <- fmt.Errorf("error getting object URL: %v", err)
|
s.errorChan <- fmt.Errorf("error getting object URL: %v", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
storeParams := store.SetVideoUploadedParams{
|
storeParams := store.SetVideoUploadedParams{
|
||||||
|
@ -104,7 +95,6 @@ func (s *videoGetterState) getVideo(ctx context.Context) {
|
||||||
_, err = s.store.SetVideoUploaded(ctx, storeParams)
|
_, err = s.store.SetVideoUploaded(ctx, storeParams)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.errorChan <- fmt.Errorf("error saving to store: %v", err)
|
s.errorChan <- fmt.Errorf("error saving to store: %v", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
close(s.progressChan)
|
close(s.progressChan)
|
||||||
|
@ -125,10 +115,10 @@ func (s *videoGetterState) Next() (GetVideoProgress, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type videoGetterFromFileStore string
|
type videoGetterDownloaded string
|
||||||
|
|
||||||
// Next() implements GetVideoProgressReader.
|
// Next() implements GetVideoProgressReader.
|
||||||
func (s *videoGetterFromFileStore) Next() (GetVideoProgress, error) {
|
func (s *videoGetterDownloaded) Next() (GetVideoProgress, error) {
|
||||||
return GetVideoProgress{
|
return GetVideoProgress{
|
||||||
PercentComplete: 100,
|
PercentComplete: 100,
|
||||||
URL: string(*s),
|
URL: string(*s),
|
||||||
|
|
|
@ -1,276 +0,0 @@
|
||||||
package media_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.netflux.io/rob/clipper/config"
|
|
||||||
"git.netflux.io/rob/clipper/generated/mocks"
|
|
||||||
"git.netflux.io/rob/clipper/generated/store"
|
|
||||||
"git.netflux.io/rob/clipper/media"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/kkdai/youtube/v2"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/mock"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGetVideoFromYoutube(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
wp := media.NewTestWorkerPool()
|
|
||||||
logger := zap.NewNop().Sugar()
|
|
||||||
|
|
||||||
const (
|
|
||||||
itag = 234
|
|
||||||
videoID = "video001"
|
|
||||||
videoMimeType = "video/mp4"
|
|
||||||
videoContentLength = int64(100_000)
|
|
||||||
)
|
|
||||||
mediaSetID := uuid.New()
|
|
||||||
mediaSet := store.MediaSet{
|
|
||||||
ID: mediaSetID,
|
|
||||||
YoutubeID: videoID,
|
|
||||||
VideoYoutubeItag: int32(itag),
|
|
||||||
}
|
|
||||||
|
|
||||||
video := &youtube.Video{
|
|
||||||
ID: videoID,
|
|
||||||
Formats: []youtube.Format{{ItagNo: itag, FPS: 30, AudioChannels: 0, MimeType: videoMimeType, ContentLength: videoContentLength}},
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("NOK,ErrorFetchingVideo", func(t *testing.T) {
|
|
||||||
var mockStore mocks.Store
|
|
||||||
mockStore.On("GetMediaSet", ctx, mediaSetID).Return(mediaSet, nil)
|
|
||||||
|
|
||||||
var youtubeClient mocks.YoutubeClient
|
|
||||||
youtubeClient.On("GetVideoContext", ctx, videoID).Return(nil, errors.New("nope"))
|
|
||||||
|
|
||||||
service := media.NewMediaSetService(&mockStore, &youtubeClient, nil, nil, wp, config.Config{}, logger)
|
|
||||||
_, err := service.GetVideo(ctx, mediaSetID)
|
|
||||||
assert.EqualError(t, err, "error fetching video: nope")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("NOK,ErrorFetchingStream", func(t *testing.T) {
|
|
||||||
var mockStore mocks.Store
|
|
||||||
mockStore.On("GetMediaSet", ctx, mediaSetID).Return(mediaSet, nil)
|
|
||||||
|
|
||||||
var youtubeClient mocks.YoutubeClient
|
|
||||||
youtubeClient.On("GetVideoContext", ctx, videoID).Return(video, nil)
|
|
||||||
youtubeClient.On("GetStreamContext", ctx, video, &video.Formats[0]).Return(nil, int64(0), errors.New("network failure"))
|
|
||||||
|
|
||||||
service := media.NewMediaSetService(&mockStore, &youtubeClient, nil, nil, wp, config.Config{}, logger)
|
|
||||||
_, err := service.GetVideo(ctx, mediaSetID)
|
|
||||||
assert.EqualError(t, err, "error fetching stream: network failure")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("NOK,ErrorPuttingObjectInFileStore", func(t *testing.T) {
|
|
||||||
var mockStore mocks.Store
|
|
||||||
mockStore.On("GetMediaSet", ctx, mediaSetID).Return(mediaSet, nil)
|
|
||||||
|
|
||||||
encodedContent := "a video stream"
|
|
||||||
reader := io.NopCloser(strings.NewReader(encodedContent))
|
|
||||||
|
|
||||||
var youtubeClient mocks.YoutubeClient
|
|
||||||
youtubeClient.On("GetVideoContext", ctx, videoID).Return(video, nil)
|
|
||||||
youtubeClient.On("GetStreamContext", ctx, video, &video.Formats[0]).Return(reader, int64(len(encodedContent)), nil)
|
|
||||||
|
|
||||||
var fileStore mocks.FileStore
|
|
||||||
fileStore.On("PutObject", ctx, mock.Anything, mock.Anything, videoMimeType).Return(int64(0), errors.New("error storing object"))
|
|
||||||
|
|
||||||
service := media.NewMediaSetService(&mockStore, &youtubeClient, &fileStore, nil, wp, config.Config{}, logger)
|
|
||||||
stream, err := service.GetVideo(ctx, mediaSetID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, err = stream.Next()
|
|
||||||
assert.EqualError(t, err, "error waiting for progress: error uploading to file store: error storing object")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("NOK,ErrorClosingStream", func(t *testing.T) {
|
|
||||||
var mockStore mocks.Store
|
|
||||||
mockStore.On("GetMediaSet", ctx, mediaSetID).Return(mediaSet, nil)
|
|
||||||
|
|
||||||
encodedContent := "a video stream"
|
|
||||||
reader := errorCloser{Reader: strings.NewReader(encodedContent)}
|
|
||||||
|
|
||||||
var youtubeClient mocks.YoutubeClient
|
|
||||||
youtubeClient.On("GetVideoContext", ctx, videoID).Return(video, nil)
|
|
||||||
youtubeClient.On("GetStreamContext", ctx, video, &video.Formats[0]).Return(reader, int64(len(encodedContent)), nil)
|
|
||||||
|
|
||||||
var fileStore mocks.FileStore
|
|
||||||
fileStore.On("PutObject", ctx, mock.Anything, mock.Anything, videoMimeType).Return(int64(len(encodedContent)), nil)
|
|
||||||
|
|
||||||
service := media.NewMediaSetService(&mockStore, &youtubeClient, &fileStore, nil, wp, config.Config{}, logger)
|
|
||||||
stream, err := service.GetVideo(ctx, mediaSetID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, err = stream.Next()
|
|
||||||
assert.EqualError(t, err, "error waiting for progress: error closing video stream: close error")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("NOK,ErrorGettingObjectURL", func(t *testing.T) {
|
|
||||||
var mockStore mocks.Store
|
|
||||||
mockStore.On("GetMediaSet", ctx, mediaSetID).Return(mediaSet, nil)
|
|
||||||
|
|
||||||
encodedContent := "a video stream"
|
|
||||||
reader := io.NopCloser(strings.NewReader(encodedContent))
|
|
||||||
|
|
||||||
var youtubeClient mocks.YoutubeClient
|
|
||||||
youtubeClient.On("GetVideoContext", ctx, videoID).Return(video, nil)
|
|
||||||
youtubeClient.On("GetStreamContext", ctx, video, &video.Formats[0]).Return(reader, int64(len(encodedContent)), nil)
|
|
||||||
|
|
||||||
var fileStore mocks.FileStore
|
|
||||||
fileStore.On("PutObject", ctx, mock.Anything, mock.Anything, videoMimeType).Return(int64(len(encodedContent)), nil)
|
|
||||||
fileStore.On("GetURL", ctx, mock.Anything).Return("", errors.New("URL error"))
|
|
||||||
|
|
||||||
service := media.NewMediaSetService(&mockStore, &youtubeClient, &fileStore, nil, wp, config.Config{}, logger)
|
|
||||||
stream, err := service.GetVideo(ctx, mediaSetID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, err = stream.Next()
|
|
||||||
assert.EqualError(t, err, "error waiting for progress: error getting object URL: URL error")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("NOK,ErrorUpdatingStore", func(t *testing.T) {
|
|
||||||
var mockStore mocks.Store
|
|
||||||
mockStore.On("GetMediaSet", ctx, mediaSetID).Return(mediaSet, nil)
|
|
||||||
mockStore.On("SetVideoUploaded", ctx, mock.Anything).Return(mediaSet, errors.New("boom"))
|
|
||||||
|
|
||||||
encodedContent := "a video stream"
|
|
||||||
reader := io.NopCloser(strings.NewReader(encodedContent))
|
|
||||||
|
|
||||||
var youtubeClient mocks.YoutubeClient
|
|
||||||
youtubeClient.On("GetVideoContext", ctx, videoID).Return(video, nil)
|
|
||||||
youtubeClient.On("GetStreamContext", ctx, video, &video.Formats[0]).Return(reader, int64(len(encodedContent)), nil)
|
|
||||||
|
|
||||||
var fileStore mocks.FileStore
|
|
||||||
fileStore.On("PutObject", ctx, mock.Anything, mock.Anything, videoMimeType).Return(int64(len(encodedContent)), nil)
|
|
||||||
fileStore.On("GetURL", ctx, mock.Anything).Return("a url", nil)
|
|
||||||
|
|
||||||
service := media.NewMediaSetService(&mockStore, &youtubeClient, &fileStore, nil, wp, config.Config{}, logger)
|
|
||||||
stream, err := service.GetVideo(ctx, mediaSetID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, err = stream.Next()
|
|
||||||
assert.EqualError(t, err, "error waiting for progress: error saving to store: boom")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("OK", func(t *testing.T) {
|
|
||||||
var mockStore mocks.Store
|
|
||||||
mockStore.On("GetMediaSet", ctx, mediaSetID).Return(mediaSet, nil)
|
|
||||||
mockStore.On("SetVideoUploaded", ctx, mock.Anything).Return(mediaSet, nil)
|
|
||||||
defer mockStore.AssertExpectations(t)
|
|
||||||
|
|
||||||
encodedContent := make([]byte, videoContentLength)
|
|
||||||
reader := io.NopCloser(bytes.NewReader(encodedContent))
|
|
||||||
|
|
||||||
var youtubeClient mocks.YoutubeClient
|
|
||||||
youtubeClient.On("GetVideoContext", ctx, videoID).Return(video, nil)
|
|
||||||
youtubeClient.On("GetStreamContext", ctx, video, &video.Formats[0]).Return(reader, videoContentLength, nil)
|
|
||||||
defer youtubeClient.AssertExpectations(t)
|
|
||||||
|
|
||||||
var fileStore mocks.FileStore
|
|
||||||
fileStore.On("PutObject", ctx, mock.Anything, mock.Anything, videoMimeType).
|
|
||||||
Run(func(args mock.Arguments) {
|
|
||||||
n, err := io.Copy(io.Discard, args[2].(io.Reader))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, videoContentLength, n)
|
|
||||||
}).
|
|
||||||
Return(videoContentLength, nil)
|
|
||||||
fileStore.On("GetURL", ctx, mock.Anything).Return("a url", nil)
|
|
||||||
defer fileStore.AssertExpectations(t)
|
|
||||||
|
|
||||||
service := media.NewMediaSetService(&mockStore, &youtubeClient, &fileStore, nil, wp, config.Config{}, logger)
|
|
||||||
stream, err := service.GetVideo(ctx, mediaSetID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var (
|
|
||||||
lastPercentComplete float32
|
|
||||||
lastURL string
|
|
||||||
)
|
|
||||||
|
|
||||||
for {
|
|
||||||
progress, err := stream.Next()
|
|
||||||
if err != io.EOF {
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.GreaterOrEqual(t, progress.PercentComplete, lastPercentComplete)
|
|
||||||
lastPercentComplete = progress.PercentComplete
|
|
||||||
lastURL = progress.URL
|
|
||||||
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, float32(100), lastPercentComplete)
|
|
||||||
assert.Equal(t, "a url", lastURL)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type errorCloser struct {
|
|
||||||
io.Reader
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c errorCloser) Close() error { return errors.New("close error") }
|
|
||||||
|
|
||||||
func TestGetVideoFromFileStore(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
wp := media.NewTestWorkerPool()
|
|
||||||
logger := zap.NewNop().Sugar()
|
|
||||||
|
|
||||||
videoID := "video002"
|
|
||||||
mediaSetID := uuid.New()
|
|
||||||
mediaSet := store.MediaSet{
|
|
||||||
ID: mediaSetID,
|
|
||||||
YoutubeID: videoID,
|
|
||||||
VideoS3UploadedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
|
||||||
VideoS3Key: sql.NullString{String: "videos/myvideo", Valid: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("NOK,ErrorFetchingMediaSet", func(t *testing.T) {
|
|
||||||
var mockStore mocks.Store
|
|
||||||
mockStore.On("GetMediaSet", ctx, mediaSetID).Return(store.MediaSet{}, errors.New("database fail"))
|
|
||||||
|
|
||||||
service := media.NewMediaSetService(&mockStore, nil, nil, nil, wp, config.Config{}, logger)
|
|
||||||
_, err := service.GetVideo(ctx, mediaSetID)
|
|
||||||
require.EqualError(t, err, "error getting media set: database fail")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("NOK,ErrorGettingObjectURL", func(t *testing.T) {
|
|
||||||
var mockStore mocks.Store
|
|
||||||
mockStore.On("GetMediaSet", ctx, mediaSetID).Return(mediaSet, nil)
|
|
||||||
|
|
||||||
var fileStore mocks.FileStore
|
|
||||||
fileStore.On("GetURL", ctx, "videos/myvideo").Return("", errors.New("key missing"))
|
|
||||||
|
|
||||||
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, wp, config.Config{}, logger)
|
|
||||||
_, err := service.GetVideo(ctx, mediaSetID)
|
|
||||||
require.EqualError(t, err, "error generating presigned URL: key missing")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("OK", func(t *testing.T) {
|
|
||||||
var mockStore mocks.Store
|
|
||||||
mockStore.On("GetMediaSet", ctx, mediaSetID).Return(mediaSet, nil)
|
|
||||||
|
|
||||||
const url = "https://www.example.com/audio"
|
|
||||||
var fileStore mocks.FileStore
|
|
||||||
fileStore.On("GetURL", ctx, "videos/myvideo").Return(url, nil)
|
|
||||||
|
|
||||||
service := media.NewMediaSetService(&mockStore, nil, &fileStore, nil, wp, config.Config{}, logger)
|
|
||||||
stream, err := service.GetVideo(ctx, mediaSetID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
progress, err := stream.Next()
|
|
||||||
assert.Equal(t, float32(100), progress.PercentComplete)
|
|
||||||
assert.Equal(t, url, progress.URL)
|
|
||||||
assert.Equal(t, io.EOF, err)
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -9,7 +9,6 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.netflux.io/rob/clipper/config"
|
"git.netflux.io/rob/clipper/config"
|
||||||
|
@ -38,18 +37,16 @@ type MediaSetService struct {
|
||||||
youtube YoutubeClient
|
youtube YoutubeClient
|
||||||
fileStore FileStore
|
fileStore FileStore
|
||||||
commandFunc CommandFunc
|
commandFunc CommandFunc
|
||||||
workerPool *WorkerPool
|
|
||||||
config config.Config
|
config config.Config
|
||||||
logger *zap.SugaredLogger
|
logger *zap.SugaredLogger
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMediaSetService(store Store, youtubeClient YoutubeClient, fileStore FileStore, commandFunc CommandFunc, workerPool *WorkerPool, config config.Config, logger *zap.SugaredLogger) *MediaSetService {
|
func NewMediaSetService(store Store, youtubeClient YoutubeClient, fileStore FileStore, commandFunc CommandFunc, config config.Config, logger *zap.SugaredLogger) *MediaSetService {
|
||||||
return &MediaSetService{
|
return &MediaSetService{
|
||||||
store: store,
|
store: store,
|
||||||
youtube: youtubeClient,
|
youtube: youtubeClient,
|
||||||
fileStore: fileStore,
|
fileStore: fileStore,
|
||||||
commandFunc: commandFunc,
|
commandFunc: commandFunc,
|
||||||
workerPool: workerPool,
|
|
||||||
config: config,
|
config: config,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
|
@ -101,9 +98,6 @@ func (s *MediaSetService) createMediaSet(ctx context.Context, youtubeID string)
|
||||||
|
|
||||||
storeParams := store.CreateMediaSetParams{
|
storeParams := store.CreateMediaSetParams{
|
||||||
YoutubeID: youtubeID,
|
YoutubeID: youtubeID,
|
||||||
Title: strings.TrimSpace(video.Title),
|
|
||||||
Description: strings.TrimSpace(video.Description),
|
|
||||||
Author: strings.TrimSpace(video.Author),
|
|
||||||
AudioYoutubeItag: int32(audioMetadata.YoutubeItag),
|
AudioYoutubeItag: int32(audioMetadata.YoutubeItag),
|
||||||
AudioChannels: int32(audioMetadata.Channels),
|
AudioChannels: int32(audioMetadata.Channels),
|
||||||
AudioFramesApprox: audioMetadata.ApproxFrames,
|
AudioFramesApprox: audioMetadata.ApproxFrames,
|
||||||
|
@ -121,13 +115,10 @@ func (s *MediaSetService) createMediaSet(ctx context.Context, youtubeID string)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &MediaSet{
|
return &MediaSet{
|
||||||
ID: mediaSet.ID,
|
ID: mediaSet.ID,
|
||||||
YoutubeID: youtubeID,
|
YoutubeID: youtubeID,
|
||||||
Title: mediaSet.Title,
|
Audio: audioMetadata,
|
||||||
Description: mediaSet.Description,
|
Video: videoMetadata,
|
||||||
Author: mediaSet.Author,
|
|
||||||
Audio: audioMetadata,
|
|
||||||
Video: videoMetadata,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -142,11 +133,8 @@ func (s *MediaSetService) findMediaSet(ctx context.Context, youtubeID string) (*
|
||||||
}
|
}
|
||||||
|
|
||||||
return &MediaSet{
|
return &MediaSet{
|
||||||
ID: mediaSet.ID,
|
ID: mediaSet.ID,
|
||||||
YoutubeID: mediaSet.YoutubeID,
|
YoutubeID: mediaSet.YoutubeID,
|
||||||
Title: mediaSet.Title,
|
|
||||||
Description: mediaSet.Description,
|
|
||||||
Author: mediaSet.Author,
|
|
||||||
Audio: Audio{
|
Audio: Audio{
|
||||||
YoutubeItag: int(mediaSet.AudioYoutubeItag),
|
YoutubeItag: int(mediaSet.AudioYoutubeItag),
|
||||||
ContentLength: mediaSet.AudioContentLength,
|
ContentLength: mediaSet.AudioContentLength,
|
||||||
|
@ -226,12 +214,11 @@ func (s *MediaSetService) GetVideo(ctx context.Context, id uuid.UUID) (GetVideoP
|
||||||
}
|
}
|
||||||
|
|
||||||
if mediaSet.VideoS3UploadedAt.Valid {
|
if mediaSet.VideoS3UploadedAt.Valid {
|
||||||
var url string
|
url, err := s.fileStore.GetURL(ctx, mediaSet.VideoS3Key.String)
|
||||||
url, err = s.fileStore.GetURL(ctx, mediaSet.VideoS3Key.String)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error generating presigned URL: %v", err)
|
return nil, fmt.Errorf("error generating presigned URL: %v", err)
|
||||||
}
|
}
|
||||||
videoGetter := videoGetterFromFileStore(url)
|
videoGetter := videoGetterDownloaded(url)
|
||||||
return &videoGetter, nil
|
return &videoGetter, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -284,7 +271,7 @@ func (s *MediaSetService) GetPeaks(ctx context.Context, id uuid.UUID, numBins in
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MediaSetService) getAudioFromYoutube(ctx context.Context, mediaSet store.MediaSet, numBins int) (GetPeaksProgressReader, error) {
|
func (s *MediaSetService) getAudioFromYoutube(ctx context.Context, mediaSet store.MediaSet, numBins int) (GetPeaksProgressReader, error) {
|
||||||
audioGetter := newAudioGetter(s.store, s.youtube, s.fileStore, s.commandFunc, s.workerPool, s.config, s.logger)
|
audioGetter := newAudioGetter(s.store, s.youtube, s.fileStore, s.config, s.logger)
|
||||||
return audioGetter.GetAudio(ctx, mediaSet, numBins)
|
return audioGetter.GetAudio(ctx, mediaSet, numBins)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -367,7 +354,7 @@ outer:
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MediaSetService) GetPeaksForSegment(ctx context.Context, id uuid.UUID, startFrame, endFrame int64, numBins int) ([]int16, error) {
|
func (s *MediaSetService) GetPeaksForSegment(ctx context.Context, id uuid.UUID, startFrame, endFrame int64, numBins int) ([]int16, error) {
|
||||||
if startFrame < 0 || endFrame < 0 || numBins <= 0 || startFrame == endFrame {
|
if startFrame < 0 || endFrame < 0 || numBins <= 0 {
|
||||||
s.logger.With("startFrame", startFrame, "endFrame", endFrame, "numBins", numBins).Error("invalid arguments")
|
s.logger.With("startFrame", startFrame, "endFrame", endFrame, "numBins", numBins).Error("invalid arguments")
|
||||||
return nil, errors.New("invalid arguments")
|
return nil, errors.New("invalid arguments")
|
||||||
}
|
}
|
||||||
|
@ -398,54 +385,44 @@ func (s *MediaSetService) GetPeaksForSegment(ctx context.Context, id uuid.UUID,
|
||||||
sampleBuf := make([]int16, readBufSizeBytes/SizeOfInt16)
|
sampleBuf := make([]int16, readBufSizeBytes/SizeOfInt16)
|
||||||
bytesExpected := (endFrame - startFrame) * int64(channels) * SizeOfInt16
|
bytesExpected := (endFrame - startFrame) * int64(channels) * SizeOfInt16
|
||||||
|
|
||||||
var bytesRead int64
|
var (
|
||||||
var closing bool
|
bytesRead int64
|
||||||
|
closing bool
|
||||||
|
currPeakIndex int
|
||||||
|
currFrame int64
|
||||||
|
)
|
||||||
|
|
||||||
for bin := 0; bin < numBins; bin++ {
|
for {
|
||||||
framesRemaining := framesPerBin
|
n, err := modReader.Read(readBuf)
|
||||||
if bin == numBins-1 {
|
if err == io.EOF {
|
||||||
framesRemaining += totalFrames % int64(numBins)
|
closing = true
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, fmt.Errorf("read error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for {
|
bytesRead += int64(n)
|
||||||
// Read as many bytes as possible, but not exceeding the available buffer
|
samples := sampleBuf[:n/SizeOfInt16]
|
||||||
// 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 := binary.Read(bytes.NewReader(readBuf[:n]), binary.LittleEndian, samples); err != nil {
|
||||||
if err == io.EOF {
|
return nil, fmt.Errorf("error interpreting samples: %v", err)
|
||||||
closing = true
|
}
|
||||||
} else if err != nil {
|
|
||||||
return nil, fmt.Errorf("read error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ss := sampleBuf[:n/SizeOfInt16]
|
for i := 0; i < len(samples); i += channels {
|
||||||
if err := binary.Read(bytes.NewReader(readBuf[:n]), binary.LittleEndian, ss); err != nil {
|
for j := 0; j < channels; j++ {
|
||||||
return nil, fmt.Errorf("error interpreting samples: %v", err)
|
samp := sampleBuf[i+j]
|
||||||
}
|
if samp < 0 {
|
||||||
|
samp = -samp
|
||||||
pi := bin * channels
|
}
|
||||||
for i := 0; i < len(ss); i += channels {
|
if samp > peaks[currPeakIndex+j] {
|
||||||
for j := 0; j < channels; j++ {
|
peaks[currPeakIndex+j] = samp
|
||||||
s := ss[i+j]
|
|
||||||
if s < 0 {
|
|
||||||
s = -s
|
|
||||||
}
|
|
||||||
if s > peaks[pi+j] {
|
|
||||||
peaks[pi+j] = s
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
framesRemaining -= int64(n) / int64(channels) / SizeOfInt16
|
if currFrame == framesPerBin {
|
||||||
bytesRead += int64(n)
|
currFrame = 0
|
||||||
|
currPeakIndex += channels
|
||||||
if closing || framesRemaining == 0 {
|
} else {
|
||||||
break
|
currFrame++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -461,7 +438,7 @@ func (s *MediaSetService) GetPeaksForSegment(ctx context.Context, id uuid.UUID,
|
||||||
return peaks, nil
|
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 {
|
if startFrame > endFrame {
|
||||||
return nil, errors.New("invalid range")
|
return nil, errors.New("invalid range")
|
||||||
}
|
}
|
||||||
|
@ -481,7 +458,7 @@ func (s *MediaSetService) GetAudioSegment(ctx context.Context, id uuid.UUID, sta
|
||||||
return nil, fmt.Errorf("error getting object from store: %v", err)
|
return nil, fmt.Errorf("error getting object from store: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
g := newAudioSegmentGetter(s.commandFunc, s.workerPool, rawAudio, mediaSet.AudioChannels, endByte-startByte, outFormat)
|
g := newAudioSegmentGetter(s.commandFunc, rawAudio, mediaSet.AudioChannels, endByte-startByte, outFormat)
|
||||||
go g.getAudioSegment(ctx)
|
go g.getAudioSegment(ctx)
|
||||||
|
|
||||||
return g.stream, nil
|
return g.stream, nil
|
||||||
|
|
|
@ -4,9 +4,9 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.netflux.io/rob/clipper/config"
|
"git.netflux.io/rob/clipper/config"
|
||||||
|
@ -20,31 +20,11 @@ import (
|
||||||
"go.uber.org/zap"
|
"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) {
|
func TestPeaksForSegment(t *testing.T) {
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
name string
|
name string
|
||||||
fixturePath string
|
fixturePath string
|
||||||
fixtureReadErrBytes int64
|
fixtureLen int64
|
||||||
fixtureReadErr error
|
|
||||||
fixtureMaxRead int64
|
|
||||||
startFrame, endFrame int64
|
startFrame, endFrame int64
|
||||||
channels int32
|
channels int32
|
||||||
numBins int
|
numBins int
|
||||||
|
@ -52,17 +32,9 @@ func TestPeaksForSegment(t *testing.T) {
|
||||||
wantErr string
|
wantErr string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "NOK, invalid arguments",
|
name: "entire fixture, stereo, 1 bin",
|
||||||
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",
|
fixturePath: "testdata/tone-44100-stereo-int16.raw",
|
||||||
|
fixtureLen: 176400,
|
||||||
startFrame: 0,
|
startFrame: 0,
|
||||||
endFrame: 44100,
|
endFrame: 44100,
|
||||||
channels: 2,
|
channels: 2,
|
||||||
|
@ -70,8 +42,9 @@ func TestPeaksForSegment(t *testing.T) {
|
||||||
wantPeaks: []int16{32747, 32747},
|
wantPeaks: []int16{32747, 32747},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "OK, entire fixture, stereo, 4 bins",
|
name: "entire fixture, stereo, 4 bins",
|
||||||
fixturePath: "testdata/tone-44100-stereo-int16.raw",
|
fixturePath: "testdata/tone-44100-stereo-int16.raw",
|
||||||
|
fixtureLen: 176400,
|
||||||
startFrame: 0,
|
startFrame: 0,
|
||||||
endFrame: 44100,
|
endFrame: 44100,
|
||||||
channels: 2,
|
channels: 2,
|
||||||
|
@ -79,8 +52,9 @@ func TestPeaksForSegment(t *testing.T) {
|
||||||
wantPeaks: []int16{8173, 8177, 16366, 16370, 24557, 24555, 32747, 32747},
|
wantPeaks: []int16{8173, 8177, 16366, 16370, 24557, 24555, 32747, 32747},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "OK, entire fixture, stereo, 16 bins",
|
name: "entire fixture, stereo, 16 bins",
|
||||||
fixturePath: "testdata/tone-44100-stereo-int16.raw",
|
fixturePath: "testdata/tone-44100-stereo-int16.raw",
|
||||||
|
fixtureLen: 176400,
|
||||||
startFrame: 0,
|
startFrame: 0,
|
||||||
endFrame: 44100,
|
endFrame: 44100,
|
||||||
channels: 2,
|
channels: 2,
|
||||||
|
@ -88,8 +62,9 @@ func TestPeaksForSegment(t *testing.T) {
|
||||||
wantPeaks: []int16{2029, 2029, 4075, 4076, 6124, 6125, 8173, 8177, 10222, 10221, 12267, 12265, 14314, 14313, 16366, 16370, 18413, 18411, 20453, 20454, 22505, 22508, 24557, 24555, 26604, 26605, 28644, 28643, 30698, 30694, 32747, 32747},
|
wantPeaks: []int16{2029, 2029, 4075, 4076, 6124, 6125, 8173, 8177, 10222, 10221, 12267, 12265, 14314, 14313, 16366, 16370, 18413, 18411, 20453, 20454, 22505, 22508, 24557, 24555, 26604, 26605, 28644, 28643, 30698, 30694, 32747, 32747},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "OK, entire fixture, mono, 1 bin",
|
name: "entire fixture, mono, 1 bin",
|
||||||
fixturePath: "testdata/tone-44100-mono-int16.raw",
|
fixturePath: "testdata/tone-44100-mono-int16.raw",
|
||||||
|
fixtureLen: 88200,
|
||||||
startFrame: 0,
|
startFrame: 0,
|
||||||
endFrame: 44100,
|
endFrame: 44100,
|
||||||
channels: 1,
|
channels: 1,
|
||||||
|
@ -97,34 +72,14 @@ func TestPeaksForSegment(t *testing.T) {
|
||||||
wantPeaks: []int16{32748},
|
wantPeaks: []int16{32748},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "OK, entire fixture, mono, 32 bins",
|
name: "entire fixture, mono, 32 bins",
|
||||||
fixturePath: "testdata/tone-44100-mono-int16.raw",
|
fixturePath: "testdata/tone-44100-mono-int16.raw",
|
||||||
|
fixtureLen: 88200,
|
||||||
startFrame: 0,
|
startFrame: 0,
|
||||||
endFrame: 44100,
|
endFrame: 44100,
|
||||||
channels: 1,
|
channels: 1,
|
||||||
numBins: 32,
|
numBins: 32,
|
||||||
wantPeaks: []int16{1018, 2030, 3060, 4075, 5092, 6126, 7129, 8172, 9174, 10217, 11227, 12264, 13272, 14315, 15319, 16364, 17370, 18412, 19417, 20453, 21457, 22504, 23513, 24554, 25564, 26607, 27607, 28642, 29647, 30700, 31699, 32748},
|
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},
|
||||||
},
|
|
||||||
{
|
|
||||||
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",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -133,18 +88,11 @@ func TestPeaksForSegment(t *testing.T) {
|
||||||
startByte := tc.startFrame * int64(tc.channels) * media.SizeOfInt16
|
startByte := tc.startFrame * int64(tc.channels) * media.SizeOfInt16
|
||||||
endByte := tc.endFrame * int64(tc.channels) * media.SizeOfInt16
|
endByte := tc.endFrame * int64(tc.channels) * media.SizeOfInt16
|
||||||
expectedBytes := endByte - startByte
|
expectedBytes := endByte - startByte
|
||||||
if tc.fixtureMaxRead != 0 {
|
|
||||||
expectedBytes = tc.fixtureMaxRead
|
|
||||||
}
|
|
||||||
|
|
||||||
fixture, err := os.Open(tc.fixturePath)
|
audioFile, err := os.Open(tc.fixturePath)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer fixture.Close()
|
defer audioFile.Close()
|
||||||
sr := segmentReader{
|
audioData := io.NopCloser(io.LimitReader(audioFile, int64(expectedBytes)))
|
||||||
r: io.LimitReader(fixture, int64(expectedBytes)),
|
|
||||||
err: tc.fixtureReadErr,
|
|
||||||
errBytes: tc.fixtureReadErrBytes,
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaSet := store.MediaSet{
|
mediaSet := store.MediaSet{
|
||||||
ID: uuid.New(),
|
ID: uuid.New(),
|
||||||
|
@ -155,20 +103,19 @@ func TestPeaksForSegment(t *testing.T) {
|
||||||
// store is passed the mediaSetID and returns a mediaSet
|
// store is passed the mediaSetID and returns a mediaSet
|
||||||
store := &mocks.Store{}
|
store := &mocks.Store{}
|
||||||
store.On("GetMediaSet", mock.Anything, mediaSet.ID).Return(mediaSet, nil)
|
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 is passed the expected byte range, and returns an io.Reader
|
||||||
fileStore := &mocks.FileStore{}
|
fileStore := &mocks.FileStore{}
|
||||||
fileStore.
|
fileStore.
|
||||||
On("GetObjectWithRange", mock.Anything, "foo", startByte, endByte).
|
On("GetObjectWithRange", mock.Anything, "foo", startByte, endByte).
|
||||||
Return(&sr, nil)
|
Return(audioData, nil)
|
||||||
|
|
||||||
service := media.NewMediaSetService(store, nil, fileStore, nil, media.NewTestWorkerPool(), config.Config{}, zap.NewNop().Sugar())
|
service := media.NewMediaSetService(store, nil, fileStore, exec.CommandContext, config.Config{}, zap.NewNop().Sugar())
|
||||||
peaks, err := service.GetPeaksForSegment(context.Background(), mediaSet.ID, tc.startFrame, tc.endFrame, tc.numBins)
|
peaks, err := service.GetPeaksForSegment(context.Background(), mediaSet.ID, tc.startFrame, tc.endFrame, tc.numBins)
|
||||||
|
|
||||||
if tc.wantErr == "" {
|
if tc.wantErr == "" {
|
||||||
defer store.AssertExpectations(t)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, tc.wantPeaks, peaks)
|
assert.Equal(t, tc.wantPeaks, peaks)
|
||||||
} else {
|
} else {
|
||||||
assert.EqualError(t, err, tc.wantErr)
|
assert.EqualError(t, err, tc.wantErr)
|
||||||
|
@ -183,6 +130,7 @@ func BenchmarkGetPeaksForSegment(b *testing.B) {
|
||||||
endFrame = 1323000
|
endFrame = 1323000
|
||||||
channels = 2
|
channels = 2
|
||||||
fixturePath = "testdata/tone-44100-stereo-int16-30000ms.raw"
|
fixturePath = "testdata/tone-44100-stereo-int16-30000ms.raw"
|
||||||
|
fixtureLen = 5292000
|
||||||
numBins = 2000
|
numBins = 2000
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -206,7 +154,7 @@ func BenchmarkGetPeaksForSegment(b *testing.B) {
|
||||||
On("GetObjectWithRange", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
On("GetObjectWithRange", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
||||||
Return(readCloser, nil)
|
Return(readCloser, nil)
|
||||||
|
|
||||||
service := media.NewMediaSetService(store, nil, fileStore, nil, media.NewTestWorkerPool(), config.Config{}, zap.NewNop().Sugar())
|
service := media.NewMediaSetService(store, nil, fileStore, exec.CommandContext, config.Config{}, zap.NewNop().Sugar())
|
||||||
b.StartTimer()
|
b.StartTimer()
|
||||||
|
|
||||||
_, err = service.GetPeaksForSegment(context.Background(), mediaSetID, startFrame, endFrame, numBins)
|
_, err = service.GetPeaksForSegment(context.Background(), mediaSetID, startFrame, endFrame, numBins)
|
||||||
|
|
|
@ -1,103 +0,0 @@
|
||||||
package media_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.netflux.io/rob/clipper/media"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fixtureReader loads a fixture into a ReadCloser with the provided limit.
|
|
||||||
func fixtureReader(t *testing.T, fixturePath string, limit int64) io.ReadCloser {
|
|
||||||
fptr, err := os.Open(fixturePath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// limitReader to make the mock work realistically, not intended for assertions:
|
|
||||||
return struct {
|
|
||||||
io.Reader
|
|
||||||
io.Closer
|
|
||||||
}{
|
|
||||||
Reader: io.LimitReader(fptr, limit),
|
|
||||||
Closer: fptr,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// helperCommand returns a function that builds an *exec.Cmd which executes a
|
|
||||||
// test function in order to act as a mock process.
|
|
||||||
func helperCommand(t *testing.T, wantCommand, stdoutFile, stderrString string, forceExitCode int) media.CommandFunc {
|
|
||||||
return func(ctx context.Context, name string, args ...string) *exec.Cmd {
|
|
||||||
cs := []string{"-test.run=TestHelperProcess", "--", name}
|
|
||||||
cs = append(cs, args...)
|
|
||||||
cmd := exec.CommandContext(ctx, os.Args[0], cs...)
|
|
||||||
cmd.Env = []string{
|
|
||||||
"GO_WANT_HELPER_PROCESS=1",
|
|
||||||
"GO_WANT_COMMAND=" + wantCommand,
|
|
||||||
"GO_STDOUT_FILE=" + stdoutFile,
|
|
||||||
"GO_STDERR_STRING=" + stderrString,
|
|
||||||
"GO_FORCE_EXIT_CODE=" + strconv.Itoa(forceExitCode),
|
|
||||||
}
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHelperProcess is the body for the mock executable process built by
|
|
||||||
// helperCommand.
|
|
||||||
func TestHelperProcess(t *testing.T) {
|
|
||||||
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
// Stop the helper process writing to stdout after the test has finished.
|
|
||||||
// This prevents it from writing the "PASS" string which is unwanted in
|
|
||||||
// this context.
|
|
||||||
if !t.Failed() {
|
|
||||||
os.Stdout, _ = os.Open(os.DevNull)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if exitCode := os.Getenv("GO_FORCE_EXIT_CODE"); exitCode != "0" {
|
|
||||||
c, _ := strconv.Atoi(exitCode)
|
|
||||||
os.Stderr.WriteString(os.Getenv("GO_STDERR_STRING"))
|
|
||||||
os.Exit(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
if wantCommand := os.Getenv("GO_WANT_COMMAND"); wantCommand != "" {
|
|
||||||
_, gotCmd, _ := strings.Cut(strings.Join(os.Args, " "), " -- ")
|
|
||||||
if wantCommand != gotCmd {
|
|
||||||
fmt.Fprintf(os.Stderr, "GO_WANT_COMMAND assertion failed:\nwant = %v\ngot = %v", wantCommand, gotCmd)
|
|
||||||
t.Fail() // necessary to make the test fail
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy stdin to /dev/null. This is required to avoid broken pipe errors in
|
|
||||||
// the tests:
|
|
||||||
_, err := io.Copy(io.Discard, os.Stdin)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// If an output file is provided, then copy that to stdout:
|
|
||||||
if fname := os.Getenv("GO_STDOUT_FILE"); fname != "" {
|
|
||||||
fptr, err := os.Open(fname)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer fptr.Close()
|
|
||||||
|
|
||||||
_, err = io.Copy(os.Stdout, fptr)
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// testLogger returns a functional development logger.
|
|
||||||
//lint:ignore U1000 helper method
|
|
||||||
func testLogger(t *testing.T) *zap.Logger {
|
|
||||||
l, err := zap.NewDevelopment()
|
|
||||||
require.NoError(t, err)
|
|
||||||
return l
|
|
||||||
}
|
|
|
@ -20,11 +20,10 @@ const SizeOfInt16 = 2
|
||||||
// MediaSet represents the media and metadata associated with a single media
|
// MediaSet represents the media and metadata associated with a single media
|
||||||
// resource (for example, a YouTube video).
|
// resource (for example, a YouTube video).
|
||||||
type MediaSet struct {
|
type MediaSet struct {
|
||||||
Audio Audio
|
Audio Audio
|
||||||
Video Video
|
Video Video
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
YoutubeID string
|
YoutubeID string
|
||||||
Title, Description, Author string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio contains the metadata for the audio part of the media set.
|
// Audio contains the metadata for the audio part of the media set.
|
||||||
|
|
|
@ -1,73 +0,0 @@
|
||||||
package media
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
// WorkerPool is a pool of workers that can consume and run a queue of tasks.
|
|
||||||
type WorkerPool struct {
|
|
||||||
size int
|
|
||||||
ch chan func()
|
|
||||||
logger *zap.SugaredLogger
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWorkerPool returns a new WorkerPool containing the specified number of
|
|
||||||
// workers, and with the provided maximum queue size. Jobs added to the queue
|
|
||||||
// after it reaches this size limit will be rejected.
|
|
||||||
func NewWorkerPool(size int, maxQueueSize int, logger *zap.SugaredLogger) *WorkerPool {
|
|
||||||
return &WorkerPool{
|
|
||||||
size: size,
|
|
||||||
ch: make(chan func(), maxQueueSize),
|
|
||||||
logger: logger,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewTestWorkerPool returns a new running WorkerPool with a single worker,
|
|
||||||
// and noop logger, suitable for test environments.
|
|
||||||
func NewTestWorkerPool() *WorkerPool {
|
|
||||||
p := NewWorkerPool(1, 256, zap.NewNop().Sugar())
|
|
||||||
p.Run()
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run launches the workers, and returns immediately.
|
|
||||||
func (p *WorkerPool) Run() {
|
|
||||||
for i := 0; i < p.size; i++ {
|
|
||||||
go func() {
|
|
||||||
for task := range p.ch {
|
|
||||||
task()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WaitForTask blocks while the provided task is executed by a worker,
|
|
||||||
// returning the error returned by the task.
|
|
||||||
func (p *WorkerPool) WaitForTask(ctx context.Context, taskFunc func() error) error {
|
|
||||||
done := make(chan error)
|
|
||||||
queuedAt := time.Now()
|
|
||||||
fn := func() {
|
|
||||||
startedAt := time.Now()
|
|
||||||
result := taskFunc()
|
|
||||||
|
|
||||||
durTotal := time.Since(queuedAt)
|
|
||||||
durTask := time.Since(startedAt)
|
|
||||||
durQueue := startedAt.Sub(queuedAt)
|
|
||||||
p.logger.With("task", durTask, "queue", durQueue, "total", durTotal).Infof("Completed task")
|
|
||||||
|
|
||||||
done <- result
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case p.ch <- fn:
|
|
||||||
return <-done
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
default:
|
|
||||||
return errors.New("worker queue full")
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,53 +0,0 @@
|
||||||
package media_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.netflux.io/rob/clipper/media"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestWorkerPool(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
p := media.NewWorkerPool(2, 1, zap.NewNop().Sugar())
|
|
||||||
p.Run()
|
|
||||||
|
|
||||||
const taskCount = 4
|
|
||||||
const dur = time.Millisecond * 100
|
|
||||||
|
|
||||||
ch := make(chan error, taskCount)
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
wg.Add(taskCount)
|
|
||||||
|
|
||||||
for i := 0; i < taskCount; i++ {
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
ch <- p.WaitForTask(ctx, func() error { time.Sleep(dur); return nil })
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
close(ch)
|
|
||||||
|
|
||||||
var okCount, errCount int
|
|
||||||
|
|
||||||
for err := range ch {
|
|
||||||
if err == nil {
|
|
||||||
okCount++
|
|
||||||
} else {
|
|
||||||
errCount++
|
|
||||||
require.EqualError(t, err, "worker queue full")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// There can either be 1 or 2 failures, depending on whether a worker picks
|
|
||||||
// up one job before the last one is added to the queue.
|
|
||||||
ok := (okCount == 2 && errCount == 2) || (okCount == 3 && errCount == 1)
|
|
||||||
assert.True(t, ok)
|
|
||||||
}
|
|
|
@ -1,218 +0,0 @@
|
||||||
package server
|
|
||||||
|
|
||||||
//go:generate mockery --recursive --name MediaSetService --output ../generated/mocks
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
|
|
||||||
pbmediaset "git.netflux.io/rob/clipper/generated/pb/media_set"
|
|
||||||
"git.netflux.io/rob/clipper/media"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
"google.golang.org/protobuf/types/known/durationpb"
|
|
||||||
)
|
|
||||||
|
|
||||||
// mediaSetServiceController implements gRPC controller for MediaSetService
|
|
||||||
type mediaSetServiceController struct {
|
|
||||||
pbmediaset.UnimplementedMediaSetServiceServer
|
|
||||||
|
|
||||||
mediaSetService MediaSetService
|
|
||||||
logger *zap.SugaredLogger
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get returns a pbMediaSet.MediaSet
|
|
||||||
func (c *mediaSetServiceController) Get(ctx context.Context, request *pbmediaset.GetRequest) (*pbmediaset.MediaSet, error) {
|
|
||||||
mediaSet, err := c.mediaSetService.Get(ctx, request.GetYoutubeId())
|
|
||||||
if err != nil {
|
|
||||||
return nil, newResponseError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := pbmediaset.MediaSet{
|
|
||||||
Id: mediaSet.ID.String(),
|
|
||||||
Title: mediaSet.Title,
|
|
||||||
Description: mediaSet.Description,
|
|
||||||
Author: mediaSet.Author,
|
|
||||||
YoutubeId: mediaSet.YoutubeID,
|
|
||||||
AudioChannels: int32(mediaSet.Audio.Channels),
|
|
||||||
AudioFrames: mediaSet.Audio.Frames,
|
|
||||||
AudioApproxFrames: mediaSet.Audio.ApproxFrames,
|
|
||||||
AudioSampleRate: int32(mediaSet.Audio.SampleRate),
|
|
||||||
AudioYoutubeItag: int32(mediaSet.Audio.YoutubeItag),
|
|
||||||
AudioMimeType: mediaSet.Audio.MimeType,
|
|
||||||
VideoDuration: durationpb.New(mediaSet.Video.Duration),
|
|
||||||
VideoYoutubeItag: int32(mediaSet.Video.YoutubeItag),
|
|
||||||
VideoMimeType: mediaSet.Video.MimeType,
|
|
||||||
}
|
|
||||||
|
|
||||||
return &result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPeaks returns a stream of GetPeaksProgress relating to the entire audio
|
|
||||||
// part of the MediaSet.
|
|
||||||
func (c *mediaSetServiceController) GetPeaks(request *pbmediaset.GetPeaksRequest, stream pbmediaset.MediaSetService_GetPeaksServer) error {
|
|
||||||
// TODO: reduce timeout when fetching from S3
|
|
||||||
ctx, cancel := context.WithTimeout(stream.Context(), getPeaksTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
id, err := uuid.Parse(request.GetId())
|
|
||||||
if err != nil {
|
|
||||||
return newResponseError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
reader, err := c.mediaSetService.GetPeaks(ctx, id, int(request.GetNumBins()))
|
|
||||||
if err != nil {
|
|
||||||
return newResponseError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
|
||||||
progress, err := reader.Next()
|
|
||||||
if err != nil && err != io.EOF {
|
|
||||||
return newResponseError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
peaks := make([]int32, len(progress.Peaks))
|
|
||||||
for i, p := range progress.Peaks {
|
|
||||||
peaks[i] = int32(p)
|
|
||||||
}
|
|
||||||
|
|
||||||
progressPb := pbmediaset.GetPeaksProgress{
|
|
||||||
PercentComplete: progress.PercentComplete,
|
|
||||||
Peaks: peaks,
|
|
||||||
Url: progress.URL,
|
|
||||||
AudioFrames: progress.AudioFrames,
|
|
||||||
}
|
|
||||||
stream.Send(&progressPb)
|
|
||||||
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPeaksForSegment returns a set of peaks for a segment of an audio part of
|
|
||||||
// a MediaSet.
|
|
||||||
func (c *mediaSetServiceController) GetPeaksForSegment(ctx context.Context, request *pbmediaset.GetPeaksForSegmentRequest) (*pbmediaset.GetPeaksForSegmentResponse, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, getPeaksForSegmentTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
id, err := uuid.Parse(request.GetId())
|
|
||||||
if err != nil {
|
|
||||||
return nil, newResponseError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
peaks, err := c.mediaSetService.GetPeaksForSegment(ctx, id, request.StartFrame, request.EndFrame, int(request.GetNumBins()))
|
|
||||||
if err != nil {
|
|
||||||
return nil, newResponseError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
peaks32 := make([]int32, len(peaks))
|
|
||||||
for i, p := range peaks {
|
|
||||||
peaks32[i] = int32(p)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &pbmediaset.GetPeaksForSegmentResponse{Peaks: peaks32}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *mediaSetServiceController) GetAudioSegment(request *pbmediaset.GetAudioSegmentRequest, outStream pbmediaset.MediaSetService_GetAudioSegmentServer) error {
|
|
||||||
ctx, cancel := context.WithTimeout(outStream.Context(), getPeaksForSegmentTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
id, err := uuid.Parse(request.GetId())
|
|
||||||
if err != nil {
|
|
||||||
return newResponseError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var format media.AudioFormat
|
|
||||||
switch request.Format {
|
|
||||||
case pbmediaset.AudioFormat_MP3:
|
|
||||||
format = media.AudioFormatMP3
|
|
||||||
case pbmediaset.AudioFormat_WAV:
|
|
||||||
format = media.AudioFormatWAV
|
|
||||||
default:
|
|
||||||
return newResponseError(errors.New("unknown format"))
|
|
||||||
}
|
|
||||||
|
|
||||||
stream, err := c.mediaSetService.GetAudioSegment(ctx, id, request.StartFrame, request.EndFrame, format)
|
|
||||||
if err != nil {
|
|
||||||
return newResponseError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
|
||||||
progress, err := stream.Next(ctx)
|
|
||||||
if err != nil && err != io.EOF {
|
|
||||||
return newResponseError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
progressPb := pbmediaset.GetAudioSegmentProgress{
|
|
||||||
PercentComplete: progress.PercentComplete,
|
|
||||||
AudioData: progress.Data,
|
|
||||||
}
|
|
||||||
|
|
||||||
outStream.Send(&progressPb)
|
|
||||||
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *mediaSetServiceController) GetVideo(request *pbmediaset.GetVideoRequest, stream pbmediaset.MediaSetService_GetVideoServer) error {
|
|
||||||
// TODO: reduce timeout when already fetched from Youtube
|
|
||||||
ctx, cancel := context.WithTimeout(stream.Context(), getVideoTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
id, err := uuid.Parse(request.GetId())
|
|
||||||
if err != nil {
|
|
||||||
return newResponseError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
reader, err := c.mediaSetService.GetVideo(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return newResponseError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
|
||||||
progress, err := reader.Next()
|
|
||||||
if err != nil && err != io.EOF {
|
|
||||||
return newResponseError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
progressPb := pbmediaset.GetVideoProgress{
|
|
||||||
PercentComplete: progress.PercentComplete,
|
|
||||||
Url: progress.URL,
|
|
||||||
}
|
|
||||||
stream.Send(&progressPb)
|
|
||||||
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *mediaSetServiceController) GetVideoThumbnail(ctx context.Context, request *pbmediaset.GetVideoThumbnailRequest) (*pbmediaset.GetVideoThumbnailResponse, error) {
|
|
||||||
id, err := uuid.Parse(request.GetId())
|
|
||||||
if err != nil {
|
|
||||||
return nil, newResponseError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
thumbnail, err := c.mediaSetService.GetVideoThumbnail(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, newResponseError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
response := pbmediaset.GetVideoThumbnailResponse{
|
|
||||||
Image: thumbnail.Data,
|
|
||||||
Width: int32(thumbnail.Width),
|
|
||||||
Height: int32(thumbnail.Height),
|
|
||||||
}
|
|
||||||
|
|
||||||
return &response, nil
|
|
||||||
}
|
|
|
@ -1,180 +0,0 @@
|
||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"git.netflux.io/rob/clipper/config"
|
|
||||||
"git.netflux.io/rob/clipper/filestore"
|
|
||||||
"git.netflux.io/rob/clipper/media"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/gorilla/handlers"
|
|
||||||
"github.com/gorilla/mux"
|
|
||||||
"github.com/gorilla/schema"
|
|
||||||
"github.com/improbable-eng/grpc-web/go/grpcweb"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
type httpHandler struct {
|
|
||||||
*mux.Router
|
|
||||||
|
|
||||||
grpcHandler *grpcweb.WrappedGrpcServer
|
|
||||||
mediaSetService MediaSetService
|
|
||||||
logger *zap.SugaredLogger
|
|
||||||
}
|
|
||||||
|
|
||||||
func newHTTPHandler(grpcHandler *grpcweb.WrappedGrpcServer, mediaSetService MediaSetService, c config.Config, logger *zap.SugaredLogger) *httpHandler {
|
|
||||||
fileStoreHandler := http.NotFoundHandler()
|
|
||||||
if c.FileStoreHTTPRoot != "" {
|
|
||||||
logger.With("root", c.FileStoreHTTPRoot, "baseURL", c.FileStoreHTTPBaseURL.String()).Info("Configured to serve file store over HTTP")
|
|
||||||
fileStoreHandler = http.FileServer(&indexedFileSystem{http.Dir(c.FileStoreHTTPRoot)})
|
|
||||||
}
|
|
||||||
|
|
||||||
assetsHandler := http.NotFoundHandler()
|
|
||||||
if c.AssetsHTTPRoot != "" {
|
|
||||||
logger.With("root", c.AssetsHTTPRoot).Info("Configured to serve assets over HTTP")
|
|
||||||
assetsHandler = http.FileServer(&indexedFileSystem{http.Dir(c.AssetsHTTPRoot)})
|
|
||||||
}
|
|
||||||
|
|
||||||
// If FileSystemStore AND assets serving are both enabled,
|
|
||||||
// FileStoreHTTPBaseURL *must* be set to a value other than "/" to avoid
|
|
||||||
// clobbering the assets routes.
|
|
||||||
h := &httpHandler{
|
|
||||||
Router: mux.NewRouter(),
|
|
||||||
grpcHandler: grpcHandler,
|
|
||||||
mediaSetService: mediaSetService,
|
|
||||||
logger: logger,
|
|
||||||
}
|
|
||||||
|
|
||||||
h.
|
|
||||||
Methods("POST").
|
|
||||||
Path("/api/media_sets/{id}/clip").
|
|
||||||
Handler(http.HandlerFunc(h.handleClip))
|
|
||||||
|
|
||||||
if c.FileStore == config.FileSystemStore {
|
|
||||||
h.
|
|
||||||
Methods("GET").
|
|
||||||
PathPrefix(c.FileStoreHTTPBaseURL.Path).
|
|
||||||
Handler(filestore.NewFileSystemStoreHTTPMiddleware(c.FileStoreHTTPBaseURL, fileStoreHandler))
|
|
||||||
}
|
|
||||||
|
|
||||||
h.
|
|
||||||
Methods("GET").
|
|
||||||
Handler(assetsHandler)
|
|
||||||
|
|
||||||
h.Use(handlers.CORS(handlers.AllowedOrigins(c.CORSAllowedOrigins)))
|
|
||||||
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *httpHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !h.grpcHandler.IsGrpcWebRequest(r) && !h.grpcHandler.IsAcceptableGrpcCorsRequest(r) {
|
|
||||||
h.Router.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.grpcHandler.ServeHTTP(w, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *httpHandler) handleClip(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), getPeaksForSegmentTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
h.logger.With("err", err).Info("error parsing form")
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var params struct {
|
|
||||||
StartFrame int64 `schema:"start_frame,required"`
|
|
||||||
EndFrame int64 `schema:"end_frame,required"`
|
|
||||||
Format string `schema:"format,required"`
|
|
||||||
}
|
|
||||||
decoder := schema.NewDecoder()
|
|
||||||
if err := decoder.Decode(¶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
|
|
||||||
}
|
|
|
@ -1,254 +0,0 @@
|
||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.netflux.io/rob/clipper/config"
|
|
||||||
"git.netflux.io/rob/clipper/generated/mocks"
|
|
||||||
"git.netflux.io/rob/clipper/media"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/mock"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestHandler(t *testing.T) {
|
|
||||||
testCases := []struct {
|
|
||||||
name string
|
|
||||||
path, body, method, contentType, origin string
|
|
||||||
config config.Config
|
|
||||||
wantStartFrame, wantEndFrame int64
|
|
||||||
wantAudioFormat media.AudioFormat
|
|
||||||
wantStatus int
|
|
||||||
wantHeaders map[string]string
|
|
||||||
wantBody string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "assets disabled, file system store disabled, GET /",
|
|
||||||
path: "/",
|
|
||||||
method: http.MethodGet,
|
|
||||||
config: config.Config{FileStore: config.S3Store},
|
|
||||||
wantStatus: http.StatusNotFound,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "assets disabled, file system store disabled, GET /foo.js",
|
|
||||||
path: "/foo.js",
|
|
||||||
method: http.MethodGet,
|
|
||||||
config: config.Config{FileStore: config.S3Store},
|
|
||||||
wantStatus: http.StatusNotFound,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "assets enabled, file system store disabled, index.html exists, GET /",
|
|
||||||
path: "/",
|
|
||||||
method: http.MethodGet,
|
|
||||||
config: config.Config{FileStore: config.S3Store, AssetsHTTPRoot: "testdata/http/assets"},
|
|
||||||
wantStatus: http.StatusOK,
|
|
||||||
wantBody: "index",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "assets enabled, file system store disabled, index.html does not exist, GET /css/",
|
|
||||||
path: "/css/",
|
|
||||||
method: http.MethodGet,
|
|
||||||
config: config.Config{FileStore: config.S3Store, AssetsHTTPRoot: "testdata/http/assets"},
|
|
||||||
wantStatus: http.StatusNotFound,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "assets enabled, file system store disabled, index.html does not exist, GET /css/style.css",
|
|
||||||
path: "/css/style.css",
|
|
||||||
method: http.MethodGet,
|
|
||||||
config: config.Config{FileStore: config.S3Store, AssetsHTTPRoot: "testdata/http/assets"},
|
|
||||||
wantStatus: http.StatusOK,
|
|
||||||
wantBody: "css",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "assets enabled, file system store disabled, GET /foo.js",
|
|
||||||
path: "/foo.js",
|
|
||||||
method: http.MethodGet,
|
|
||||||
config: config.Config{FileStore: config.S3Store, AssetsHTTPRoot: "testdata/http/assets"},
|
|
||||||
wantStatus: http.StatusOK,
|
|
||||||
wantBody: "foo",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "assets enabled, file system store enabled with path prefix /store/, GET /foo.js",
|
|
||||||
path: "/foo.js",
|
|
||||||
method: http.MethodGet,
|
|
||||||
config: config.Config{FileStore: config.FileSystemStore, FileStoreHTTPBaseURL: mustParseURL(t, "/store/"), FileStoreHTTPRoot: "testdata/http/filestore", AssetsHTTPRoot: "testdata/http/assets"},
|
|
||||||
wantStatus: http.StatusOK,
|
|
||||||
wantBody: "foo",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "assets enabled, file system store enabled with path prefix /store/, GET /store/bar.mp4",
|
|
||||||
path: "/store/bar.mp4",
|
|
||||||
method: http.MethodGet,
|
|
||||||
config: config.Config{FileStore: config.FileSystemStore, FileStoreHTTPBaseURL: mustParseURL(t, "/store/"), FileStoreHTTPRoot: "testdata/http/filestore", AssetsHTTPRoot: "testdata/http/assets"},
|
|
||||||
wantStatus: http.StatusOK,
|
|
||||||
wantBody: "bar",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "assets enabled, file system store enabled with path prefix /store/, GET /store/",
|
|
||||||
path: "/store/",
|
|
||||||
method: http.MethodGet,
|
|
||||||
config: config.Config{FileStore: config.FileSystemStore, FileStoreHTTPBaseURL: mustParseURL(t, "/store/"), FileStoreHTTPRoot: "testdata/http/filestore", AssetsHTTPRoot: "testdata/http/assets"},
|
|
||||||
wantStatus: http.StatusNotFound,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "assets enabled, file system store enabled with path prefix /store/, GET /",
|
|
||||||
path: "/",
|
|
||||||
method: http.MethodGet,
|
|
||||||
config: config.Config{FileStore: config.FileSystemStore, FileStoreHTTPBaseURL: mustParseURL(t, "/store/"), FileStoreHTTPRoot: "testdata/http/filestore", AssetsHTTPRoot: "testdata/http/assets"},
|
|
||||||
wantStatus: http.StatusOK,
|
|
||||||
wantBody: "index",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "assets enabled, file system store enabled with path prefix /, GET / clobbers the assets routes",
|
|
||||||
path: "/",
|
|
||||||
method: http.MethodGet,
|
|
||||||
config: config.Config{FileStore: config.FileSystemStore, FileStoreHTTPBaseURL: mustParseURL(t, "/"), FileStoreHTTPRoot: "testdata/http/filestore", AssetsHTTPRoot: "testdata/http/assets"},
|
|
||||||
wantStatus: http.StatusNotFound,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "assets enabled, configured with custom Allowed-Origins header, origin does not match",
|
|
||||||
path: "/css/style.css",
|
|
||||||
method: http.MethodGet,
|
|
||||||
origin: "https://localhost:3000",
|
|
||||||
config: config.Config{
|
|
||||||
FileStore: config.S3Store,
|
|
||||||
AssetsHTTPRoot: "testdata/http/assets",
|
|
||||||
CORSAllowedOrigins: []string{"https://www.example.com"},
|
|
||||||
},
|
|
||||||
wantHeaders: map[string]string{"access-control-allow-origin": ""},
|
|
||||||
wantStatus: http.StatusOK,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "assets enabled, configured with custom Allowed-Origins header, origin does match",
|
|
||||||
path: "/css/style.css",
|
|
||||||
method: http.MethodGet,
|
|
||||||
origin: "https://www.example.com",
|
|
||||||
config: config.Config{
|
|
||||||
FileStore: config.S3Store,
|
|
||||||
AssetsHTTPRoot: "testdata/http/assets",
|
|
||||||
CORSAllowedOrigins: []string{"https://www.example.com"},
|
|
||||||
},
|
|
||||||
wantHeaders: map[string]string{"access-control-allow-origin": "https://www.example.com"},
|
|
||||||
wantStatus: http.StatusOK,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "POST /api/media_sets/:id/clip, NOK, no body",
|
|
||||||
path: "/api/media_sets/05951a4d-584e-4056-9ae7-08b9e4cd355d/clip",
|
|
||||||
contentType: "application/x-www-form-urlencoded",
|
|
||||||
method: http.MethodPost,
|
|
||||||
config: config.Config{FileStore: config.FileSystemStore, FileStoreHTTPBaseURL: mustParseURL(t, "/store/")},
|
|
||||||
wantStatus: http.StatusBadRequest,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "POST /api/media_sets/:id/clip, NOK, missing params",
|
|
||||||
path: "/api/media_sets/05951a4d-584e-4056-9ae7-08b9e4cd355d/clip",
|
|
||||||
body: "start_frame=0&end_frame=1024",
|
|
||||||
contentType: "application/x-www-form-urlencoded",
|
|
||||||
method: http.MethodPost,
|
|
||||||
config: config.Config{FileStore: config.FileSystemStore, FileStoreHTTPBaseURL: mustParseURL(t, "/store/")},
|
|
||||||
wantStatus: http.StatusBadRequest,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "POST /api/media_sets/:id/clip, NOK, invalid UUID",
|
|
||||||
path: "/api/media_sets/123/clip",
|
|
||||||
body: "start_frame=0&end_frame=1024&format=mp3",
|
|
||||||
contentType: "application/x-www-form-urlencoded",
|
|
||||||
method: http.MethodPost,
|
|
||||||
config: config.Config{FileStore: config.FileSystemStore, FileStoreHTTPBaseURL: mustParseURL(t, "/store/")},
|
|
||||||
wantStatus: http.StatusNotFound,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "POST /api/media_sets/:id/clip, MP3, OK",
|
|
||||||
path: "/api/media_sets/05951a4d-584e-4056-9ae7-08b9e4cd355d/clip",
|
|
||||||
body: "start_frame=0&end_frame=1024&format=mp3",
|
|
||||||
contentType: "application/x-www-form-urlencoded",
|
|
||||||
method: http.MethodPost,
|
|
||||||
config: config.Config{FileStore: config.FileSystemStore, FileStoreHTTPBaseURL: mustParseURL(t, "/store/")},
|
|
||||||
wantStartFrame: 0,
|
|
||||||
wantEndFrame: 1024,
|
|
||||||
wantAudioFormat: media.AudioFormatMP3,
|
|
||||||
wantHeaders: map[string]string{
|
|
||||||
"content-type": "audio/mp3",
|
|
||||||
"content-disposition": "attachment; filename=clip.mp3",
|
|
||||||
},
|
|
||||||
wantStatus: http.StatusOK,
|
|
||||||
wantBody: "an audio file",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "POST /api/media_sets/:id/clip, WAV, OK",
|
|
||||||
path: "/api/media_sets/05951a4d-584e-4056-9ae7-08b9e4cd355d/clip",
|
|
||||||
body: "start_frame=4096&end_frame=8192&format=wav",
|
|
||||||
contentType: "application/x-www-form-urlencoded",
|
|
||||||
method: http.MethodPost,
|
|
||||||
config: config.Config{FileStore: config.FileSystemStore, FileStoreHTTPBaseURL: mustParseURL(t, "/store/")},
|
|
||||||
wantStartFrame: 4096,
|
|
||||||
wantEndFrame: 8192,
|
|
||||||
wantAudioFormat: media.AudioFormatWAV,
|
|
||||||
wantHeaders: map[string]string{
|
|
||||||
"content-type": "audio/wav",
|
|
||||||
"content-disposition": "attachment; filename=clip.wav",
|
|
||||||
},
|
|
||||||
wantStatus: http.StatusOK,
|
|
||||||
wantBody: "an audio file",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range testCases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
var stream mocks.AudioSegmentStream
|
|
||||||
stream.On("Next", mock.Anything).Return(media.AudioSegmentProgress{PercentComplete: 60, Data: []byte("an aud")}, nil).Once()
|
|
||||||
stream.On("Next", mock.Anything).Return(media.AudioSegmentProgress{PercentComplete: 80, Data: []byte("io file")}, nil).Once()
|
|
||||||
stream.On("Next", mock.Anything).Return(media.AudioSegmentProgress{PercentComplete: 100}, io.EOF).Once()
|
|
||||||
|
|
||||||
var mediaSetService mocks.MediaSetService
|
|
||||||
mediaSetService.
|
|
||||||
On("GetAudioSegment", mock.Anything, uuid.MustParse("05951a4d-584e-4056-9ae7-08b9e4cd355d"), tc.wantStartFrame, tc.wantEndFrame, tc.wantAudioFormat).
|
|
||||||
Return(&stream, nil)
|
|
||||||
if tc.wantStartFrame != 0 {
|
|
||||||
defer stream.AssertExpectations(t)
|
|
||||||
defer mediaSetService.AssertExpectations(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := newHTTPHandler(nil, &mediaSetService, tc.config, zap.NewNop().Sugar())
|
|
||||||
|
|
||||||
var body io.Reader
|
|
||||||
if tc.body != "" {
|
|
||||||
body = strings.NewReader(tc.body)
|
|
||||||
}
|
|
||||||
req := httptest.NewRequest(tc.method, tc.path, body)
|
|
||||||
if tc.origin != "" {
|
|
||||||
req.Header.Add("origin", tc.origin)
|
|
||||||
}
|
|
||||||
if tc.contentType != "" {
|
|
||||||
req.Header.Add("content-type", tc.contentType)
|
|
||||||
}
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(w, req)
|
|
||||||
resp := w.Result()
|
|
||||||
|
|
||||||
assert.Equal(t, tc.wantStatus, resp.StatusCode)
|
|
||||||
if tc.wantBody != "" {
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, tc.wantBody, string(body))
|
|
||||||
}
|
|
||||||
for k, v := range tc.wantHeaders {
|
|
||||||
assert.Equal(t, v, resp.Header.Get(k))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustParseURL(t *testing.T, u string) *url.URL {
|
|
||||||
pu, err := url.Parse(u)
|
|
||||||
require.NoError(t, err)
|
|
||||||
return pu
|
|
||||||
}
|
|
|
@ -2,7 +2,9 @@ package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"time"
|
"time"
|
||||||
|
@ -19,6 +21,7 @@ import (
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
|
"google.golang.org/protobuf/types/known/durationpb"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -38,15 +41,6 @@ const (
|
||||||
getVideoTimeout = time.Minute * 5
|
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 {
|
type ResponseError struct {
|
||||||
err error
|
err error
|
||||||
s string
|
s string
|
||||||
|
@ -74,55 +68,260 @@ type Options struct {
|
||||||
Store media.Store
|
Store media.Store
|
||||||
YoutubeClient media.YoutubeClient
|
YoutubeClient media.YoutubeClient
|
||||||
FileStore media.FileStore
|
FileStore media.FileStore
|
||||||
WorkerPool *media.WorkerPool
|
|
||||||
Logger *zap.Logger
|
Logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func Start(options Options) error {
|
// mediaSetServiceController implements gRPC controller for MediaSetService
|
||||||
conf := options.Config
|
type mediaSetServiceController struct {
|
||||||
|
pbmediaset.UnimplementedMediaSetServiceServer
|
||||||
|
|
||||||
mediaSetService := media.NewMediaSetService(
|
mediaSetService *media.MediaSetService
|
||||||
|
logger *zap.SugaredLogger
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a pbMediaSet.MediaSet
|
||||||
|
func (c *mediaSetServiceController) Get(ctx context.Context, request *pbmediaset.GetRequest) (*pbmediaset.MediaSet, error) {
|
||||||
|
mediaSet, err := c.mediaSetService.Get(ctx, request.GetYoutubeId())
|
||||||
|
if err != nil {
|
||||||
|
return nil, newResponseError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := pbmediaset.MediaSet{
|
||||||
|
Id: mediaSet.ID.String(),
|
||||||
|
YoutubeId: mediaSet.YoutubeID,
|
||||||
|
AudioChannels: int32(mediaSet.Audio.Channels),
|
||||||
|
AudioFrames: mediaSet.Audio.Frames,
|
||||||
|
AudioApproxFrames: mediaSet.Audio.ApproxFrames,
|
||||||
|
AudioSampleRate: int32(mediaSet.Audio.SampleRate),
|
||||||
|
AudioYoutubeItag: int32(mediaSet.Audio.YoutubeItag),
|
||||||
|
AudioMimeType: mediaSet.Audio.MimeType,
|
||||||
|
VideoDuration: durationpb.New(mediaSet.Video.Duration),
|
||||||
|
VideoYoutubeItag: int32(mediaSet.Video.YoutubeItag),
|
||||||
|
VideoMimeType: mediaSet.Video.MimeType,
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPeaks returns a stream of GetPeaksProgress relating to the entire audio
|
||||||
|
// part of the MediaSet.
|
||||||
|
func (c *mediaSetServiceController) GetPeaks(request *pbmediaset.GetPeaksRequest, stream pbmediaset.MediaSetService_GetPeaksServer) error {
|
||||||
|
// TODO: reduce timeout when fetching from S3
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), getPeaksTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
id, err := uuid.Parse(request.GetId())
|
||||||
|
if err != nil {
|
||||||
|
return newResponseError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := c.mediaSetService.GetPeaks(ctx, id, int(request.GetNumBins()))
|
||||||
|
if err != nil {
|
||||||
|
return newResponseError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
progress, err := reader.Next()
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return newResponseError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
peaks := make([]int32, len(progress.Peaks))
|
||||||
|
for i, p := range progress.Peaks {
|
||||||
|
peaks[i] = int32(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
progressPb := pbmediaset.GetPeaksProgress{
|
||||||
|
PercentComplete: progress.PercentComplete,
|
||||||
|
Url: progress.URL,
|
||||||
|
Peaks: peaks,
|
||||||
|
}
|
||||||
|
stream.Send(&progressPb)
|
||||||
|
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPeaksForSegment returns a set of peaks for a segment of an audio part of
|
||||||
|
// a MediaSet.
|
||||||
|
func (c *mediaSetServiceController) GetPeaksForSegment(ctx context.Context, request *pbmediaset.GetPeaksForSegmentRequest) (*pbmediaset.GetPeaksForSegmentResponse, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, getPeaksForSegmentTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
id, err := uuid.Parse(request.GetId())
|
||||||
|
if err != nil {
|
||||||
|
return nil, newResponseError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
peaks, err := c.mediaSetService.GetPeaksForSegment(ctx, id, request.StartFrame, request.EndFrame, int(request.GetNumBins()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, newResponseError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
peaks32 := make([]int32, len(peaks))
|
||||||
|
for i, p := range peaks {
|
||||||
|
peaks32[i] = int32(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pbmediaset.GetPeaksForSegmentResponse{Peaks: peaks32}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *mediaSetServiceController) GetAudioSegment(request *pbmediaset.GetAudioSegmentRequest, outStream pbmediaset.MediaSetService_GetAudioSegmentServer) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), getPeaksForSegmentTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
id, err := uuid.Parse(request.GetId())
|
||||||
|
if err != nil {
|
||||||
|
return newResponseError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var format media.AudioFormat
|
||||||
|
switch request.Format {
|
||||||
|
case pbmediaset.AudioFormat_MP3:
|
||||||
|
format = media.AudioFormatMP3
|
||||||
|
case pbmediaset.AudioFormat_WAV:
|
||||||
|
format = media.AudioFormatWAV
|
||||||
|
default:
|
||||||
|
return newResponseError(errors.New("unknown format"))
|
||||||
|
}
|
||||||
|
|
||||||
|
stream, err := c.mediaSetService.GetAudioSegment(ctx, id, request.StartFrame, request.EndFrame, format)
|
||||||
|
if err != nil {
|
||||||
|
return newResponseError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
progress, err := stream.Next(ctx)
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return newResponseError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
progressPb := pbmediaset.GetAudioSegmentProgress{
|
||||||
|
PercentComplete: progress.PercentComplete,
|
||||||
|
AudioData: progress.Data,
|
||||||
|
}
|
||||||
|
|
||||||
|
outStream.Send(&progressPb)
|
||||||
|
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *mediaSetServiceController) GetVideo(request *pbmediaset.GetVideoRequest, stream pbmediaset.MediaSetService_GetVideoServer) error {
|
||||||
|
// TODO: reduce timeout when already fetched from Youtube
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), getVideoTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
id, err := uuid.Parse(request.GetId())
|
||||||
|
if err != nil {
|
||||||
|
return newResponseError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := c.mediaSetService.GetVideo(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return newResponseError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
progress, err := reader.Next()
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return newResponseError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
progressPb := pbmediaset.GetVideoProgress{
|
||||||
|
PercentComplete: progress.PercentComplete,
|
||||||
|
Url: progress.URL,
|
||||||
|
}
|
||||||
|
stream.Send(&progressPb)
|
||||||
|
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *mediaSetServiceController) GetVideoThumbnail(ctx context.Context, request *pbmediaset.GetVideoThumbnailRequest) (*pbmediaset.GetVideoThumbnailResponse, error) {
|
||||||
|
id, err := uuid.Parse(request.GetId())
|
||||||
|
if err != nil {
|
||||||
|
return nil, newResponseError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
thumbnail, err := c.mediaSetService.GetVideoThumbnail(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, newResponseError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
response := pbmediaset.GetVideoThumbnailResponse{
|
||||||
|
Image: thumbnail.Data,
|
||||||
|
Width: int32(thumbnail.Width),
|
||||||
|
Height: int32(thumbnail.Height),
|
||||||
|
}
|
||||||
|
|
||||||
|
return &response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Start(options Options) error {
|
||||||
|
fetchMediaSetService := media.NewMediaSetService(
|
||||||
options.Store,
|
options.Store,
|
||||||
options.YoutubeClient,
|
options.YoutubeClient,
|
||||||
options.FileStore,
|
options.FileStore,
|
||||||
exec.CommandContext,
|
exec.CommandContext,
|
||||||
options.WorkerPool,
|
options.Config,
|
||||||
conf,
|
|
||||||
options.Logger.Sugar().Named("mediaSetService"),
|
options.Logger.Sugar().Named("mediaSetService"),
|
||||||
)
|
)
|
||||||
|
|
||||||
grpcServer, err := buildGRPCServer(conf, options.Logger)
|
grpcServer, err := buildGRPCServer(options.Config, options.Logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error building server: %v", err)
|
return fmt.Errorf("error building server: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaSetController := &mediaSetServiceController{mediaSetService: mediaSetService, logger: options.Logger.Sugar().Named("controller")}
|
mediaSetController := &mediaSetServiceController{mediaSetService: fetchMediaSetService, logger: options.Logger.Sugar().Named("controller")}
|
||||||
pbmediaset.RegisterMediaSetServiceServer(grpcServer, mediaSetController)
|
pbmediaset.RegisterMediaSetServiceServer(grpcServer, mediaSetController)
|
||||||
|
|
||||||
// TODO: convert CORSAllowedOrigins to a map[string]struct{}
|
// TODO: configure CORS
|
||||||
originChecker := func(origin string) bool {
|
grpcWebServer := grpcweb.WrapServer(grpcServer, grpcweb.WithOriginFunc(func(string) bool { return true }))
|
||||||
for _, s := range conf.CORSAllowedOrigins {
|
|
||||||
if origin == s {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
grpcHandler := grpcweb.WrapServer(grpcServer, grpcweb.WithOriginFunc(originChecker))
|
|
||||||
httpHandler := newHTTPHandler(grpcHandler, mediaSetService, conf, options.Logger.Sugar().Named("httpHandler"))
|
|
||||||
|
|
||||||
httpServer := http.Server{
|
|
||||||
Addr: conf.BindAddr,
|
|
||||||
ReadTimeout: options.Timeout,
|
|
||||||
WriteTimeout: options.Timeout,
|
|
||||||
Handler: httpHandler,
|
|
||||||
}
|
|
||||||
|
|
||||||
log := options.Logger.Sugar()
|
log := options.Logger.Sugar()
|
||||||
|
fileHandler := http.NotFoundHandler()
|
||||||
|
|
||||||
|
// Enabling the file system store disables serving assets over HTTP.
|
||||||
|
// TODO: fix this.
|
||||||
|
if options.Config.AssetsHTTPRoot != "" {
|
||||||
|
log.With("root", options.Config.AssetsHTTPRoot).Info("Configured to serve assets over HTTP")
|
||||||
|
fileHandler = http.FileServer(http.Dir(options.Config.AssetsHTTPRoot))
|
||||||
|
}
|
||||||
|
if options.Config.FileStoreHTTPRoot != "" {
|
||||||
|
log.With("root", options.Config.FileStoreHTTPRoot).Info("Configured to serve file store over HTTP")
|
||||||
|
fileHandler = http.FileServer(http.Dir(options.Config.FileStoreHTTPRoot))
|
||||||
|
}
|
||||||
|
|
||||||
|
httpServer := http.Server{
|
||||||
|
Addr: options.Config.BindAddr,
|
||||||
|
ReadTimeout: options.Timeout,
|
||||||
|
WriteTimeout: options.Timeout,
|
||||||
|
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !grpcWebServer.IsGrpcWebRequest(r) && !grpcWebServer.IsAcceptableGrpcCorsRequest(r) {
|
||||||
|
fileHandler.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
grpcWebServer.ServeHTTP(w, r)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
log.Infof("Listening at %s", options.Config.BindAddr)
|
log.Infof("Listening at %s", options.Config.BindAddr)
|
||||||
|
|
||||||
if conf.TLSCertFile != "" && conf.TLSKeyFile != "" {
|
if options.Config.TLSCertFile != "" && options.Config.TLSKeyFile != "" {
|
||||||
return httpServer.ListenAndServeTLS(conf.TLSCertFile, conf.TLSKeyFile)
|
return httpServer.ListenAndServeTLS(options.Config.TLSCertFile, options.Config.TLSKeyFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
return httpServer.ListenAndServe()
|
return httpServer.ListenAndServe()
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
css
|
|
|
@ -1 +0,0 @@
|
||||||
foo
|
|
|
@ -1 +0,0 @@
|
||||||
index
|
|
|
@ -1 +0,0 @@
|
||||||
bar
|
|
|
@ -1,3 +0,0 @@
|
||||||
ALTER TABLE media_sets DROP COLUMN title;
|
|
||||||
ALTER TABLE media_sets DROP COLUMN description;
|
|
||||||
ALTER TABLE media_sets DROP COLUMN author;
|
|
|
@ -1,5 +0,0 @@
|
||||||
ALTER TABLE media_sets ADD COLUMN title CHARACTER VARYING(256) NOT NULL DEFAULT '';
|
|
||||||
ALTER TABLE media_sets ADD COLUMN description text NOT NULL DEFAULT '';
|
|
||||||
ALTER TABLE media_sets ADD COLUMN author CHARACTER VARYING(256) NOT NULL DEFAULT '';
|
|
||||||
|
|
||||||
ALTER TABLE media_sets ADD CONSTRAINT check_description_length CHECK (LENGTH(description) <= 4096);
|
|
|
@ -5,8 +5,8 @@ SELECT * FROM media_sets WHERE id = $1;
|
||||||
SELECT * FROM media_sets WHERE youtube_id = $1;
|
SELECT * FROM media_sets WHERE youtube_id = $1;
|
||||||
|
|
||||||
-- name: CreateMediaSet :one
|
-- name: CreateMediaSet :one
|
||||||
INSERT INTO media_sets (youtube_id, title, description, author, audio_youtube_itag, audio_channels, audio_frames_approx, audio_sample_rate, audio_content_length, audio_encoded_mime_type, video_youtube_itag, video_content_length, video_mime_type, video_duration_nanos, created_at, updated_at)
|
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, $12, $13, $14, NOW(), NOW())
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), NOW())
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: SetRawAudioUploaded :one
|
-- name: SetRawAudioUploaded :one
|
||||||
|
|
|
@ -16,11 +16,6 @@ You will also see any lint errors in the console.
|
||||||
|
|
||||||
### `yarn test`
|
### `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.\
|
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.
|
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,6 @@
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroicons/react": "^1.0.5",
|
|
||||||
"@improbable-eng/grpc-web": "^0.14.1",
|
"@improbable-eng/grpc-web": "^0.14.1",
|
||||||
"@testing-library/jest-dom": "^5.11.4",
|
"@testing-library/jest-dom": "^5.11.4",
|
||||||
"@testing-library/react": "^11.1.0",
|
"@testing-library/react": "^11.1.0",
|
||||||
|
@ -15,15 +14,15 @@
|
||||||
"google-protobuf": "^3.19.0",
|
"google-protobuf": "^3.19.0",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
"react-scripts": "5.0.0",
|
"react-router-dom": "^6.2.1",
|
||||||
|
"react-scripts": "4.0.3",
|
||||||
"typescript": "^4.1.2",
|
"typescript": "^4.1.2",
|
||||||
"web-vitals": "^1.0.1"
|
"web-vitals": "^1.0.1"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
"build": "react-scripts build",
|
"build": "react-scripts build",
|
||||||
"test": "react-scripts test --watchAll=false",
|
"test": "echo 'no tests yet' # react-scripts test",
|
||||||
"test:watch": "react-scripts test",
|
|
||||||
"eject": "react-scripts eject"
|
"eject": "react-scripts eject"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
|
@ -48,14 +47,11 @@
|
||||||
"@types/wicg-file-system-access": "^2020.9.4",
|
"@types/wicg-file-system-access": "^2020.9.4",
|
||||||
"@typescript-eslint/eslint-plugin": "^4.31.0",
|
"@typescript-eslint/eslint-plugin": "^4.31.0",
|
||||||
"@typescript-eslint/parser": "^4.31.0",
|
"@typescript-eslint/parser": "^4.31.0",
|
||||||
"autoprefixer": "^10.4.2",
|
|
||||||
"eslint": "^7.32.0",
|
"eslint": "^7.32.0",
|
||||||
"eslint-config-prettier": "^8.3.0",
|
"eslint-config-prettier": "^8.3.0",
|
||||||
"eslint-plugin-react": "^7.25.1",
|
"eslint-plugin-react": "^7.25.1",
|
||||||
"postcss": "^8.4.5",
|
|
||||||
"prettier": "2.4.0",
|
"prettier": "2.4.0",
|
||||||
"rxjs": "^7.4.0",
|
"rxjs": "^7.4.0",
|
||||||
"tailwindcss": "^3.0.12",
|
|
||||||
"ts-proto": "^1.85.0"
|
"ts-proto": "^1.85.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"short_name": "Clipper",
|
"short_name": "React App",
|
||||||
"name": "Clipper",
|
"name": "Create React App Sample",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "favicon.ico",
|
"src": "favicon.ico",
|
||||||
|
@ -20,7 +20,6 @@
|
||||||
],
|
],
|
||||||
"start_url": ".",
|
"start_url": ".",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"orientation": "landscape",
|
|
||||||
"theme_color": "#000000",
|
"theme_color": "#000000",
|
||||||
"background_color": "#ffffff"
|
"background_color": "#ffffff"
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
body {
|
||||||
|
background-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App {
|
||||||
|
text-align: center;
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
test('renders learn react link', () => {
|
||||||
|
render(<App />);
|
||||||
|
const linkElement = screen.getByText(/learn react/i);
|
||||||
|
expect(linkElement).toBeInTheDocument();
|
||||||
|
});
|
|
@ -1,410 +1,19 @@
|
||||||
import {
|
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||||
GrpcWebImpl,
|
import HomePage from "./components/HomePage";
|
||||||
MediaSetServiceClientImpl,
|
import VideoPage from "./components/VideoPage";
|
||||||
GetVideoProgress,
|
import { GrpcWebImpl } from "./generated/media_set";
|
||||||
GetPeaksProgress,
|
import "./App.css";
|
||||||
} from './generated/media_set';
|
|
||||||
|
|
||||||
import { useEffect, useCallback, useReducer } from 'react';
|
const apiURL = process.env.REACT_APP_API_URL || "http://localhost:8888";
|
||||||
import { State, stateReducer, zoomFactor, PlayState } from './AppState';
|
|
||||||
import { AudioFormat } from './generated/media_set';
|
|
||||||
import { WaveformCanvas } from './WaveformCanvas';
|
|
||||||
import { HudCanvas } from './HudCanvas';
|
|
||||||
import { Player } from './Player';
|
|
||||||
import {
|
|
||||||
CanvasWidth,
|
|
||||||
CanvasHeight,
|
|
||||||
EmptySelectionAction,
|
|
||||||
} from './HudCanvasState';
|
|
||||||
import { ControlBar } from './ControlBar';
|
|
||||||
import { SeekBar } from './SeekBar';
|
|
||||||
import { firstValueFrom, from, Observable } from 'rxjs';
|
|
||||||
import { first, map, bufferCount } from 'rxjs/operators';
|
|
||||||
import { canZoomViewportIn, canZoomViewportOut } from './helpers/zoom';
|
|
||||||
import toHHMMSS from './helpers/toHHMMSS';
|
|
||||||
import framesToDuration from './helpers/framesToDuration';
|
|
||||||
import frameToWaveformCanvasX from './helpers/frameToWaveformCanvasX';
|
|
||||||
import { ClockIcon, ExternalLinkIcon } from '@heroicons/react/solid';
|
|
||||||
|
|
||||||
// ported from backend, where should they live?
|
|
||||||
const thumbnailWidth = 177; // height 100
|
|
||||||
|
|
||||||
const apiURL = process.env.REACT_APP_API_URL || 'http://localhost:8888';
|
|
||||||
|
|
||||||
// Frames represents a range of audio frames.
|
|
||||||
export interface Frames {
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VideoPosition {
|
|
||||||
currentTime: number;
|
|
||||||
percent: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: State = {
|
|
||||||
selection: { start: 0, end: 0 },
|
|
||||||
viewport: { start: 0, end: 0 },
|
|
||||||
overviewPeaks: from([]),
|
|
||||||
waveformPeaks: from([]),
|
|
||||||
selectionCanvas: { x1: 0, x2: 0 },
|
|
||||||
viewportCanvas: { x1: 0, x2: 0 },
|
|
||||||
position: { currentTime: 0, frame: 0, percent: 0 },
|
|
||||||
audioSrc: '',
|
|
||||||
videoSrc: '',
|
|
||||||
currentTime: 0,
|
|
||||||
playState: PlayState.Paused,
|
|
||||||
};
|
|
||||||
|
|
||||||
function App(): JSX.Element {
|
function App(): JSX.Element {
|
||||||
const [state, dispatch] = useReducer(stateReducer, { ...initialState });
|
|
||||||
|
|
||||||
const {
|
|
||||||
mediaSet,
|
|
||||||
waveformPeaks,
|
|
||||||
overviewPeaks,
|
|
||||||
selection,
|
|
||||||
selectionCanvas,
|
|
||||||
viewport,
|
|
||||||
viewportCanvas,
|
|
||||||
position,
|
|
||||||
playState,
|
|
||||||
} = state;
|
|
||||||
|
|
||||||
// effects
|
|
||||||
|
|
||||||
// TODO: error handling
|
|
||||||
const videoID = new URLSearchParams(window.location.search).get('video_id');
|
|
||||||
if (videoID == null) {
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetch mediaset on page load:
|
|
||||||
useEffect(() => {
|
|
||||||
(async function () {
|
|
||||||
const rpc = newRPC();
|
|
||||||
const service = new MediaSetServiceClientImpl(rpc);
|
|
||||||
const mediaSet = await service.Get({ youtubeId: videoID });
|
|
||||||
|
|
||||||
console.log('got media set:', mediaSet);
|
|
||||||
dispatch({ type: 'mediasetloaded', mediaSet: mediaSet });
|
|
||||||
|
|
||||||
// fetch audio asynchronously
|
|
||||||
console.log('fetching audio...');
|
|
||||||
const audioProgressStream = service.GetPeaks({
|
|
||||||
id: mediaSet.id,
|
|
||||||
numBins: CanvasWidth,
|
|
||||||
});
|
|
||||||
const peaks = audioProgressStream.pipe(map((progress) => progress.peaks));
|
|
||||||
dispatch({ type: 'overviewpeaksloaded', peaks: peaks });
|
|
||||||
|
|
||||||
const audioPipe = audioProgressStream.pipe(
|
|
||||||
first((progress: GetPeaksProgress) => progress.url != '')
|
|
||||||
);
|
|
||||||
const fetchAudioTask = firstValueFrom(audioPipe);
|
|
||||||
|
|
||||||
// fetch video asynchronously
|
|
||||||
console.log('fetching video...');
|
|
||||||
const videoProgressStream = service.GetVideo({ id: mediaSet.id });
|
|
||||||
const videoPipe = videoProgressStream.pipe(
|
|
||||||
first((progress: GetVideoProgress) => progress.url != '')
|
|
||||||
);
|
|
||||||
const fetchVideoTask = firstValueFrom(videoPipe);
|
|
||||||
|
|
||||||
// wait for both audio, then video.
|
|
||||||
const audioProgress = await fetchAudioTask;
|
|
||||||
dispatch({
|
|
||||||
type: 'audiosourceloaded',
|
|
||||||
src: audioProgress.url,
|
|
||||||
numFrames: audioProgress.audioFrames,
|
|
||||||
});
|
|
||||||
|
|
||||||
const videoProgress = await fetchVideoTask;
|
|
||||||
dispatch({ type: 'videosourceloaded', src: videoProgress.url });
|
|
||||||
})();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// load waveform peaks on MediaSet change
|
|
||||||
useEffect(() => {
|
|
||||||
(async function () {
|
|
||||||
const { mediaSet, viewport } = state;
|
|
||||||
|
|
||||||
if (mediaSet == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (viewport.start >= viewport.end) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const service = new MediaSetServiceClientImpl(newRPC());
|
|
||||||
const segment = await service.GetPeaksForSegment({
|
|
||||||
id: mediaSet.id,
|
|
||||||
numBins: CanvasWidth,
|
|
||||||
startFrame: viewport.start,
|
|
||||||
endFrame: viewport.end,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('got segment', segment);
|
|
||||||
|
|
||||||
const peaks: Observable<number[]> = from(segment.peaks).pipe(
|
|
||||||
bufferCount(mediaSet.audioChannels)
|
|
||||||
);
|
|
||||||
dispatch({ type: 'waveformpeaksloaded', peaks: peaks });
|
|
||||||
})();
|
|
||||||
}, [viewport, mediaSet]);
|
|
||||||
|
|
||||||
// bind to keypress handler.
|
|
||||||
useEffect(() => {
|
|
||||||
document.addEventListener('keypress', handleKeyPress);
|
|
||||||
return () => document.removeEventListener('keypress', handleKeyPress);
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.debug('viewport updated', viewport);
|
|
||||||
}, [viewport]);
|
|
||||||
|
|
||||||
// handlers
|
|
||||||
|
|
||||||
const togglePlay = () => (playState == PlayState.Paused ? play() : pause());
|
|
||||||
const play = () => dispatch({ type: 'play' });
|
|
||||||
const pause = () => dispatch({ type: 'pause' });
|
|
||||||
|
|
||||||
const handleKeyPress = (evt: KeyboardEvent) => {
|
|
||||||
if (evt.code != 'Space') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
togglePlay();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClip = () => {
|
|
||||||
if (!window.showSaveFilePicker) {
|
|
||||||
downloadClipHTTP();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadClipFileSystemAccessAPI();
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadClipHTTP = () => {
|
|
||||||
(async function () {
|
|
||||||
if (mediaSet == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.debug('clip http', selection);
|
|
||||||
|
|
||||||
const form = document.createElement('form');
|
|
||||||
form.method = 'POST';
|
|
||||||
form.action = `${apiURL}/api/media_sets/${mediaSet.id}/clip`;
|
|
||||||
const startFrameInput = document.createElement('input');
|
|
||||||
startFrameInput.type = 'hidden';
|
|
||||||
startFrameInput.name = 'start_frame';
|
|
||||||
startFrameInput.value = String(selection.start);
|
|
||||||
form.appendChild(startFrameInput);
|
|
||||||
const endFrameInput = document.createElement('input');
|
|
||||||
endFrameInput.type = 'hidden';
|
|
||||||
endFrameInput.name = 'end_frame';
|
|
||||||
endFrameInput.value = String(selection.end);
|
|
||||||
form.appendChild(endFrameInput);
|
|
||||||
const formatInput = document.createElement('input');
|
|
||||||
formatInput.type = 'hidden';
|
|
||||||
formatInput.name = 'format';
|
|
||||||
formatInput.value = 'mp3';
|
|
||||||
form.appendChild(formatInput);
|
|
||||||
document.body.appendChild(form);
|
|
||||||
form.submit();
|
|
||||||
})();
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadClipFileSystemAccessAPI = () => {
|
|
||||||
(async function () {
|
|
||||||
if (mediaSet == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.debug('clip grpc', selection);
|
|
||||||
const h = await window.showSaveFilePicker({ suggestedName: 'clip.mp3' });
|
|
||||||
const fileStream = await h.createWritable();
|
|
||||||
|
|
||||||
const rpc = newRPC();
|
|
||||||
const service = new MediaSetServiceClientImpl(rpc);
|
|
||||||
const stream = service.GetAudioSegment({
|
|
||||||
id: mediaSet.id,
|
|
||||||
format: AudioFormat.MP3,
|
|
||||||
startFrame: selection.start,
|
|
||||||
endFrame: selection.end,
|
|
||||||
});
|
|
||||||
|
|
||||||
await stream.forEach((p) => fileStream.write(p.audioData));
|
|
||||||
console.debug('finished writing stream');
|
|
||||||
|
|
||||||
await fileStream.close();
|
|
||||||
console.debug('closed stream');
|
|
||||||
})();
|
|
||||||
};
|
|
||||||
|
|
||||||
const durationString = useCallback((): string => {
|
|
||||||
if (!mediaSet || !mediaSet.videoDuration) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const { selection } = state;
|
|
||||||
|
|
||||||
const totalDur = toHHMMSS(mediaSet.videoDuration);
|
|
||||||
if (selection.start == selection.end) {
|
|
||||||
return totalDur;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clipDur = toHHMMSS(
|
|
||||||
framesToDuration(
|
|
||||||
selection.end - selection.start,
|
|
||||||
mediaSet.audioSampleRate
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return `Selected ${clipDur} of ${totalDur}`;
|
|
||||||
}, [mediaSet, selection]);
|
|
||||||
|
|
||||||
// render component
|
|
||||||
|
|
||||||
const offsetPixels = Math.floor(thumbnailWidth / 2);
|
|
||||||
const marginClass = 'mx-[88px]'; // offsetPixels
|
|
||||||
|
|
||||||
if (mediaSet == null) {
|
|
||||||
// TODO: improve
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<BrowserRouter>
|
||||||
<div className="App bg-gray-800 h-screen flex flex-col">
|
<Routes>
|
||||||
<header className="bg-green-900 h-16 grow-0 flex items-center mb-12 px-[88px]">
|
<Route path="/" element={<HomePage />} />
|
||||||
<h1 className="text-3xl font-bold">Clipper</h1>
|
<Route path="/video/:videoId" element={<VideoPage />} />
|
||||||
</header>
|
</Routes>
|
||||||
<div className="flex flex-col grow bg-gray-800 w-full h-full mx-auto">
|
</BrowserRouter>
|
||||||
<div className={`flex flex-col grow ${marginClass}`}>
|
|
||||||
<div className="flex grow-0 h-8 pt-4 pb-2 items-center space-x-2 text-white">
|
|
||||||
<span className="text-gray-300">{mediaSet.author}</span>
|
|
||||||
<span>/</span>
|
|
||||||
<span>{mediaSet.title}</span>
|
|
||||||
<a
|
|
||||||
href={`https://www.youtube.com/watch?v=${mediaSet.youtubeId}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
title="Open in YouTube"
|
|
||||||
>
|
|
||||||
<ExternalLinkIcon className="h-6 w-6 text-gray-500 hover:text-gray-200" />
|
|
||||||
</a>
|
|
||||||
<span className="flex grow justify-end text-gray-500">
|
|
||||||
<ClockIcon className="h-5 w-5 mr-1 mt-0.5" />
|
|
||||||
{durationString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ControlBar
|
|
||||||
playState={playState}
|
|
||||||
zoomInEnabled={canZoomViewportIn(viewport, selection, zoomFactor)}
|
|
||||||
zoomOutEnabled={canZoomViewportOut(
|
|
||||||
viewport,
|
|
||||||
mediaSet.audioFrames
|
|
||||||
)}
|
|
||||||
onTogglePlay={togglePlay}
|
|
||||||
onClip={handleClip}
|
|
||||||
onZoomIn={() => dispatch({ type: 'zoomin' })}
|
|
||||||
onZoomOut={() => dispatch({ type: 'zoomout' })}
|
|
||||||
downloadClipEnabled={selection.start != selection.end}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="w-full bg-gray-600 h-6"></div>
|
|
||||||
|
|
||||||
<div className={`relative grow-0 h-16`}>
|
|
||||||
<WaveformCanvas
|
|
||||||
peaks={overviewPeaks}
|
|
||||||
channels={mediaSet.audioChannels}
|
|
||||||
width={CanvasWidth}
|
|
||||||
height={CanvasHeight}
|
|
||||||
strokeStyle="black"
|
|
||||||
fillStyle="#003300"
|
|
||||||
alpha={1}
|
|
||||||
></WaveformCanvas>
|
|
||||||
<HudCanvas
|
|
||||||
width={CanvasWidth}
|
|
||||||
height={CanvasHeight}
|
|
||||||
emptySelectionAction={EmptySelectionAction.SelectPrevious}
|
|
||||||
styles={{
|
|
||||||
borderLineWidth: 4,
|
|
||||||
borderStrokeStyle: 'red',
|
|
||||||
positionLineWidth: 4,
|
|
||||||
positionStrokeStyle: 'red',
|
|
||||||
hoverPositionStrokeStyle: 'transparent',
|
|
||||||
}}
|
|
||||||
position={(position.percent / 100) * CanvasWidth}
|
|
||||||
selection={viewportCanvas}
|
|
||||||
onSelectionChange={(event) =>
|
|
||||||
dispatch({ type: 'viewportchanged', event })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`relative grow`}>
|
|
||||||
<WaveformCanvas
|
|
||||||
peaks={waveformPeaks}
|
|
||||||
channels={mediaSet.audioChannels}
|
|
||||||
width={CanvasWidth}
|
|
||||||
height={CanvasHeight}
|
|
||||||
strokeStyle="green"
|
|
||||||
fillStyle="black"
|
|
||||||
alpha={1}
|
|
||||||
></WaveformCanvas>
|
|
||||||
<HudCanvas
|
|
||||||
width={CanvasWidth}
|
|
||||||
height={CanvasHeight}
|
|
||||||
emptySelectionAction={EmptySelectionAction.SelectNothing}
|
|
||||||
styles={{
|
|
||||||
borderLineWidth: 0,
|
|
||||||
borderStrokeStyle: 'transparent',
|
|
||||||
positionLineWidth: 6,
|
|
||||||
positionStrokeStyle: 'red',
|
|
||||||
hoverPositionStrokeStyle: '#666666',
|
|
||||||
}}
|
|
||||||
position={frameToWaveformCanvasX(
|
|
||||||
position.frame,
|
|
||||||
viewport,
|
|
||||||
CanvasWidth
|
|
||||||
)}
|
|
||||||
selection={selectionCanvas}
|
|
||||||
onSelectionChange={(event) =>
|
|
||||||
dispatch({
|
|
||||||
type: 'waveformselectionchanged',
|
|
||||||
event,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SeekBar
|
|
||||||
position={position.currentTime}
|
|
||||||
duration={mediaSet.audioFrames / mediaSet.audioSampleRate}
|
|
||||||
offsetPixels={offsetPixels}
|
|
||||||
onPositionChanged={(currentTime: number) => {
|
|
||||||
dispatch({ type: 'skip', currentTime });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Player
|
|
||||||
mediaSet={mediaSet}
|
|
||||||
playState={playState}
|
|
||||||
audioSrc={state.audioSrc}
|
|
||||||
videoSrc={state.videoSrc}
|
|
||||||
currentTime={state.currentTime}
|
|
||||||
onPositionChanged={(currentTime) =>
|
|
||||||
dispatch({ type: 'positionchanged', currentTime: currentTime })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,392 +0,0 @@
|
||||||
import { MediaSet } from './generated/media_set';
|
|
||||||
import { stateReducer, State, PlayState } from './AppState';
|
|
||||||
import { from } from 'rxjs';
|
|
||||||
import { CanvasWidth, SelectionMode } from './HudCanvasState';
|
|
||||||
|
|
||||||
const initialState: State = {
|
|
||||||
selection: { start: 0, end: 0 },
|
|
||||||
viewport: { start: 0, end: 441000 },
|
|
||||||
overviewPeaks: from([]),
|
|
||||||
waveformPeaks: from([]),
|
|
||||||
selectionCanvas: { x1: 0, x2: 0 },
|
|
||||||
viewportCanvas: { x1: 0, x2: CanvasWidth },
|
|
||||||
position: { currentTime: 0, frame: 0, percent: 0 },
|
|
||||||
audioSrc: '',
|
|
||||||
videoSrc: '',
|
|
||||||
currentTime: undefined,
|
|
||||||
playState: PlayState.Paused,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('stateReducer', () => {
|
|
||||||
describe('audiosourceloaded', () => {
|
|
||||||
describe.each([
|
|
||||||
{
|
|
||||||
src: 'foo.opus',
|
|
||||||
numFrames: 22050,
|
|
||||||
wantViewport: { start: 0, end: 1100 },
|
|
||||||
wantViewportCanvas: { x1: 0, x2: 100 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: 'foo.opus',
|
|
||||||
numFrames: 44100000,
|
|
||||||
wantViewport: { start: 0, end: 2205000 },
|
|
||||||
wantViewportCanvas: { x1: 0, x2: 100 },
|
|
||||||
},
|
|
||||||
])(
|
|
||||||
'$numFrames frames',
|
|
||||||
({ src, numFrames, wantViewport, wantViewportCanvas }) => {
|
|
||||||
it('generates the expected state', () => {
|
|
||||||
const mediaSet = MediaSet.fromPartial({
|
|
||||||
id: '123',
|
|
||||||
audioFrames: 0,
|
|
||||||
});
|
|
||||||
const state = stateReducer(
|
|
||||||
{ ...initialState, mediaSet },
|
|
||||||
{ type: 'audiosourceloaded', src, numFrames }
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(state.mediaSet!.audioFrames).toEqual(numFrames);
|
|
||||||
expect(state.audioSrc).toEqual(src);
|
|
||||||
expect(state.viewport).toEqual(wantViewport);
|
|
||||||
expect(state.viewportCanvas).toEqual(wantViewportCanvas);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('setviewport', () => {
|
|
||||||
describe.each([
|
|
||||||
{
|
|
||||||
audioFrames: 441000,
|
|
||||||
viewport: { start: 0, end: 44100 },
|
|
||||||
selection: { start: 0, end: 0 },
|
|
||||||
wantViewportCanvas: { x1: 0, x2: 200 },
|
|
||||||
wantSelectionCanvas: { x1: 0, x2: 0 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
audioFrames: 441000,
|
|
||||||
viewport: { start: 0, end: 441000 },
|
|
||||||
selection: { start: 0, end: 0 },
|
|
||||||
wantViewportCanvas: { x1: 0, x2: 2000 },
|
|
||||||
wantSelectionCanvas: { x1: 0, x2: 0 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
audioFrames: 441000,
|
|
||||||
viewport: { start: 0, end: 441000 },
|
|
||||||
selection: { start: 0, end: 44100 },
|
|
||||||
wantViewportCanvas: { x1: 0, x2: 2000 },
|
|
||||||
wantSelectionCanvas: { x1: 0, x2: 200 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
audioFrames: 441000,
|
|
||||||
viewport: { start: 0, end: 22050 },
|
|
||||||
selection: { start: 0, end: 44100 },
|
|
||||||
wantViewportCanvas: { x1: 0, x2: 100 },
|
|
||||||
wantSelectionCanvas: { x1: 0, x2: 2000 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
audioFrames: 441000,
|
|
||||||
viewport: { start: 44100, end: 88200 },
|
|
||||||
selection: { start: 22050, end: 66150 },
|
|
||||||
wantViewportCanvas: { x1: 200, x2: 400 },
|
|
||||||
wantSelectionCanvas: { x1: 0, x2: 1000 },
|
|
||||||
},
|
|
||||||
])(
|
|
||||||
'selection $selection.start-$selection.end, viewport: $viewport.start-$viewport.end',
|
|
||||||
({
|
|
||||||
audioFrames,
|
|
||||||
viewport,
|
|
||||||
selection,
|
|
||||||
wantViewportCanvas,
|
|
||||||
wantSelectionCanvas,
|
|
||||||
}) => {
|
|
||||||
it('generates the expected state', () => {
|
|
||||||
const mediaSet = MediaSet.fromPartial({
|
|
||||||
id: '123',
|
|
||||||
audioFrames: audioFrames,
|
|
||||||
});
|
|
||||||
const state = stateReducer(
|
|
||||||
{ ...initialState, mediaSet: mediaSet, selection: selection },
|
|
||||||
{ type: 'setviewport', viewport: viewport }
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(state.viewport).toEqual(viewport);
|
|
||||||
expect(state.selection).toEqual(selection);
|
|
||||||
expect(state.viewportCanvas).toEqual(wantViewportCanvas);
|
|
||||||
expect(state.selectionCanvas).toEqual(wantSelectionCanvas);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('viewportchanged', () => {
|
|
||||||
describe.each([
|
|
||||||
{
|
|
||||||
audioFrames: 441000,
|
|
||||||
event: {
|
|
||||||
mode: SelectionMode.Selecting,
|
|
||||||
prevMode: SelectionMode.Selecting,
|
|
||||||
selection: { x1: 0, x2: 200 },
|
|
||||||
},
|
|
||||||
selection: { start: 0, end: 0 },
|
|
||||||
wantViewport: { start: 0, end: 441000 },
|
|
||||||
wantSelectionCanvas: { x1: 0, x2: 0 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
audioFrames: 441000,
|
|
||||||
event: {
|
|
||||||
mode: SelectionMode.Normal,
|
|
||||||
prevMode: SelectionMode.Selecting,
|
|
||||||
selection: { x1: 0, x2: 200 },
|
|
||||||
},
|
|
||||||
selection: { start: 0, end: 0 },
|
|
||||||
wantViewport: { start: 0, end: 44100 },
|
|
||||||
wantSelectionCanvas: { x1: 0, x2: 0 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
audioFrames: 441000,
|
|
||||||
event: {
|
|
||||||
mode: SelectionMode.Normal,
|
|
||||||
prevMode: SelectionMode.Selecting,
|
|
||||||
selection: { x1: 0, x2: 200 },
|
|
||||||
},
|
|
||||||
selection: { start: 0, end: 22050 },
|
|
||||||
wantViewport: { start: 0, end: 44100 },
|
|
||||||
wantSelectionCanvas: { x1: 0, x2: 1000 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
audioFrames: 441000,
|
|
||||||
event: {
|
|
||||||
mode: SelectionMode.Normal,
|
|
||||||
prevMode: SelectionMode.Selecting,
|
|
||||||
selection: { x1: 1000, x2: 1500 },
|
|
||||||
},
|
|
||||||
selection: { start: 220500, end: 264600 },
|
|
||||||
wantViewport: { start: 220500, end: 330750 },
|
|
||||||
wantSelectionCanvas: { x1: 0, x2: 800 },
|
|
||||||
},
|
|
||||||
])(
|
|
||||||
'mode $event.mode, audioFrames $audioFrames, canvas range $event.selection.x1-$event.selection.x2, selectedFrames $selection.start-$selection.end',
|
|
||||||
({
|
|
||||||
audioFrames,
|
|
||||||
event,
|
|
||||||
selection,
|
|
||||||
wantViewport,
|
|
||||||
wantSelectionCanvas,
|
|
||||||
}) => {
|
|
||||||
it('generates the expected state', () => {
|
|
||||||
const mediaSet = MediaSet.fromPartial({
|
|
||||||
id: '123',
|
|
||||||
audioFrames: audioFrames,
|
|
||||||
});
|
|
||||||
const state = stateReducer(
|
|
||||||
{
|
|
||||||
...initialState,
|
|
||||||
mediaSet: mediaSet,
|
|
||||||
viewport: { start: 0, end: audioFrames },
|
|
||||||
selection: selection,
|
|
||||||
},
|
|
||||||
{ type: 'viewportchanged', event }
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(state.selection).toEqual(selection);
|
|
||||||
expect(state.viewport).toEqual(wantViewport);
|
|
||||||
expect(state.selectionCanvas).toEqual(wantSelectionCanvas);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('waveformselectionchanged', () => {
|
|
||||||
describe.each([
|
|
||||||
{
|
|
||||||
name: 'paused',
|
|
||||||
audioSampleRate: 44100,
|
|
||||||
event: {
|
|
||||||
mode: SelectionMode.Selecting,
|
|
||||||
prevMode: SelectionMode.Selecting,
|
|
||||||
selection: { x1: 100, x2: 200 },
|
|
||||||
},
|
|
||||||
playState: PlayState.Paused,
|
|
||||||
position: { frame: 0, currentTime: 0, percent: 0 },
|
|
||||||
viewport: { start: 0, end: 88200 },
|
|
||||||
wantSelection: { start: 4410, end: 8820 },
|
|
||||||
wantCurrentTime: undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'playing, viewport 100%, selection is in progress',
|
|
||||||
audioSampleRate: 44100,
|
|
||||||
event: {
|
|
||||||
mode: SelectionMode.Selecting,
|
|
||||||
prevMode: SelectionMode.Selecting,
|
|
||||||
selection: { x1: 200, x2: 220 },
|
|
||||||
},
|
|
||||||
playState: PlayState.Playing,
|
|
||||||
position: { frame: 22000, currentTime: 0.4988, percent: 4.98 },
|
|
||||||
viewport: { start: 0, end: 441000 },
|
|
||||||
wantSelection: { start: 44100, end: 48510 },
|
|
||||||
wantCurrentTime: undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'playing, viewport partial, selection is in progress',
|
|
||||||
audioSampleRate: 44100,
|
|
||||||
event: {
|
|
||||||
mode: SelectionMode.Selecting,
|
|
||||||
prevMode: SelectionMode.Selecting,
|
|
||||||
selection: { x1: 0, x2: 100 },
|
|
||||||
},
|
|
||||||
playState: PlayState.Playing,
|
|
||||||
position: { frame: 22000, currentTime: 0.4988, percent: 4.98 },
|
|
||||||
viewport: { start: 88200, end: 176400 },
|
|
||||||
wantSelection: { start: 88200, end: 92610 },
|
|
||||||
wantCurrentTime: undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'playing, selection is ending, currFrame is before selection start',
|
|
||||||
audioSampleRate: 44100,
|
|
||||||
event: {
|
|
||||||
mode: SelectionMode.Normal,
|
|
||||||
prevMode: SelectionMode.Selecting,
|
|
||||||
selection: { x1: 1100, x2: 1200 },
|
|
||||||
},
|
|
||||||
playState: PlayState.Playing,
|
|
||||||
position: { frame: 22000, currentTime: 0.4988, percent: 4.98 },
|
|
||||||
viewport: { start: 0, end: 88200 },
|
|
||||||
wantSelection: { start: 48510, end: 52920 },
|
|
||||||
wantCurrentTime: 1.1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'playing, selection is ending, currFrame is within selection',
|
|
||||||
audioSampleRate: 44100,
|
|
||||||
event: {
|
|
||||||
mode: SelectionMode.Normal,
|
|
||||||
prevMode: SelectionMode.Selecting,
|
|
||||||
selection: { x1: 1001, x2: 1200 },
|
|
||||||
},
|
|
||||||
playState: PlayState.Playing,
|
|
||||||
position: { frame: 50000, currentTime: 1.133, percent: 11.33 },
|
|
||||||
viewport: { start: 0, end: 88200 },
|
|
||||||
wantSelection: { start: 44144, end: 52920 },
|
|
||||||
wantCurrentTime: undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'playing, selection is ending, currFrame is after selection end',
|
|
||||||
audioSampleRate: 44100,
|
|
||||||
event: {
|
|
||||||
mode: SelectionMode.Normal,
|
|
||||||
prevMode: SelectionMode.Selecting,
|
|
||||||
selection: { x1: 1001, x2: 1200 },
|
|
||||||
},
|
|
||||||
playState: PlayState.Playing,
|
|
||||||
position: { frame: 88200, currentTime: 2.0, percent: 20.0 },
|
|
||||||
viewport: { start: 0, end: 88200 },
|
|
||||||
wantSelection: { start: 44144, end: 52920 },
|
|
||||||
wantCurrentTime: 1.000997732426304,
|
|
||||||
},
|
|
||||||
])(
|
|
||||||
'$name',
|
|
||||||
({
|
|
||||||
audioSampleRate,
|
|
||||||
event,
|
|
||||||
playState,
|
|
||||||
position,
|
|
||||||
viewport,
|
|
||||||
wantSelection,
|
|
||||||
wantCurrentTime,
|
|
||||||
}) => {
|
|
||||||
it('generates the expected state', () => {
|
|
||||||
const mediaSet = MediaSet.fromPartial({
|
|
||||||
id: '123',
|
|
||||||
audioFrames: 441000,
|
|
||||||
audioSampleRate: audioSampleRate,
|
|
||||||
});
|
|
||||||
const state = stateReducer(
|
|
||||||
{
|
|
||||||
...initialState,
|
|
||||||
position,
|
|
||||||
mediaSet,
|
|
||||||
playState,
|
|
||||||
viewport,
|
|
||||||
},
|
|
||||||
{ type: 'waveformselectionchanged', event }
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(state.selection).toEqual(wantSelection);
|
|
||||||
expect(state.currentTime).toEqual(wantCurrentTime);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('positionchanged', () => {
|
|
||||||
describe.each([
|
|
||||||
{
|
|
||||||
name: 'playing, 48k',
|
|
||||||
audioSampleRate: 48000,
|
|
||||||
newCurrentTime: 0.02,
|
|
||||||
position: { frame: 0, currentTime: 0, percent: 0 },
|
|
||||||
selection: { start: 0, end: 0 },
|
|
||||||
wantPosition: {
|
|
||||||
frame: 960,
|
|
||||||
currentTime: 0.02,
|
|
||||||
percent: 0.21768707482993196,
|
|
||||||
},
|
|
||||||
wantCurrentTime: undefined,
|
|
||||||
wantPlayState: PlayState.Playing,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'playing, 44.1k',
|
|
||||||
audioSampleRate: 44100,
|
|
||||||
newCurrentTime: 8.51,
|
|
||||||
position: { frame: 360000, currentTime: 8.16, percent: 81.6 },
|
|
||||||
selection: { start: 0, end: 0 },
|
|
||||||
wantPosition: { frame: 375291, currentTime: 8.51, percent: 85.1 },
|
|
||||||
wantCurrentTime: undefined,
|
|
||||||
wantPlayState: PlayState.Playing,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'playing, passed selection end',
|
|
||||||
audioSampleRate: 44100,
|
|
||||||
newCurrentTime: 8.51,
|
|
||||||
position: { frame: 360000, currentTime: 8.16, percent: 81.6 },
|
|
||||||
selection: { start: 22050, end: 375290 },
|
|
||||||
wantPosition: { frame: 375291, currentTime: 8.51, percent: 85.1 },
|
|
||||||
wantCurrentTime: 0.5,
|
|
||||||
wantPlayState: PlayState.Paused,
|
|
||||||
},
|
|
||||||
])(
|
|
||||||
'$name',
|
|
||||||
({
|
|
||||||
audioSampleRate,
|
|
||||||
newCurrentTime,
|
|
||||||
position,
|
|
||||||
selection,
|
|
||||||
wantPosition,
|
|
||||||
wantCurrentTime,
|
|
||||||
wantPlayState,
|
|
||||||
}) => {
|
|
||||||
it('generates the expected state', () => {
|
|
||||||
const mediaSet = MediaSet.fromPartial({
|
|
||||||
id: '123',
|
|
||||||
audioFrames: 441000,
|
|
||||||
audioSampleRate: audioSampleRate,
|
|
||||||
});
|
|
||||||
const state = stateReducer(
|
|
||||||
{
|
|
||||||
...initialState,
|
|
||||||
playState: PlayState.Playing,
|
|
||||||
mediaSet,
|
|
||||||
position,
|
|
||||||
selection,
|
|
||||||
},
|
|
||||||
{ type: 'positionchanged', currentTime: newCurrentTime }
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(state.position).toEqual(wantPosition);
|
|
||||||
expect(state.playState).toEqual(wantPlayState);
|
|
||||||
expect(state.currentTime).toEqual(wantCurrentTime);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,424 +0,0 @@
|
||||||
import { MediaSet } from './generated/media_set';
|
|
||||||
import { Observable } from 'rxjs';
|
|
||||||
import { SelectionChangeEvent } from './HudCanvas';
|
|
||||||
import { CanvasRange, SelectionMode, CanvasWidth } from './HudCanvasState';
|
|
||||||
import { zoomViewportIn, zoomViewportOut } from './helpers/zoom';
|
|
||||||
import frameToWaveformCanvasX from './helpers/frameToWaveformCanvasX';
|
|
||||||
|
|
||||||
export const zoomFactor = 2;
|
|
||||||
|
|
||||||
const initialViewportCanvasPixels = 100;
|
|
||||||
|
|
||||||
export interface FrameRange {
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Position {
|
|
||||||
currentTime: number;
|
|
||||||
frame: number;
|
|
||||||
percent: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum PlayState {
|
|
||||||
Paused,
|
|
||||||
Playing,
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface State {
|
|
||||||
mediaSet?: MediaSet;
|
|
||||||
selection: FrameRange;
|
|
||||||
viewport: FrameRange;
|
|
||||||
overviewPeaks: Observable<number[]>;
|
|
||||||
waveformPeaks: Observable<number[]>;
|
|
||||||
// selection canvas. Not kept up-to-date, only used for pushing updates.
|
|
||||||
selectionCanvas: CanvasRange;
|
|
||||||
// viewport canvas. Not kept up-to-date, only used for pushing updates.
|
|
||||||
viewportCanvas: CanvasRange;
|
|
||||||
audioSrc: string;
|
|
||||||
videoSrc: string;
|
|
||||||
position: Position;
|
|
||||||
// playback position in seconds, only used for forcing a change of position.
|
|
||||||
currentTime?: number;
|
|
||||||
playState: PlayState;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MediaSetLoadedAction {
|
|
||||||
type: 'mediasetloaded';
|
|
||||||
mediaSet: MediaSet;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OverviewPeaksLoadedAction {
|
|
||||||
type: 'overviewpeaksloaded';
|
|
||||||
peaks: Observable<number[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WaveformPeaksLoadedAction {
|
|
||||||
type: 'waveformpeaksloaded';
|
|
||||||
peaks: Observable<number[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AudioSourceLoadedAction {
|
|
||||||
type: 'audiosourceloaded';
|
|
||||||
numFrames: number;
|
|
||||||
src: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VideoSourceLoadedAction {
|
|
||||||
type: 'videosourceloaded';
|
|
||||||
src: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SetViewportAction {
|
|
||||||
type: 'setviewport';
|
|
||||||
viewport: FrameRange;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ZoomInAction {
|
|
||||||
type: 'zoomin';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ZoomOutAction {
|
|
||||||
type: 'zoomout';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ViewportChangedAction {
|
|
||||||
type: 'viewportchanged';
|
|
||||||
event: SelectionChangeEvent;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WaveformSelectionChangedAction {
|
|
||||||
type: 'waveformselectionchanged';
|
|
||||||
event: SelectionChangeEvent;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PositionChangedAction {
|
|
||||||
type: 'positionchanged';
|
|
||||||
currentTime: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SkipAction {
|
|
||||||
type: 'skip';
|
|
||||||
currentTime: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PlayAction {
|
|
||||||
type: 'play';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PauseAction {
|
|
||||||
type: 'pause';
|
|
||||||
}
|
|
||||||
|
|
||||||
type Action =
|
|
||||||
| MediaSetLoadedAction
|
|
||||||
| OverviewPeaksLoadedAction
|
|
||||||
| WaveformPeaksLoadedAction
|
|
||||||
| AudioSourceLoadedAction
|
|
||||||
| VideoSourceLoadedAction
|
|
||||||
| SetViewportAction
|
|
||||||
| ZoomInAction
|
|
||||||
| ZoomOutAction
|
|
||||||
| ViewportChangedAction
|
|
||||||
| WaveformSelectionChangedAction
|
|
||||||
| PositionChangedAction
|
|
||||||
| SkipAction
|
|
||||||
| PlayAction
|
|
||||||
| PauseAction;
|
|
||||||
|
|
||||||
export const stateReducer = (state: State, action: Action): State => {
|
|
||||||
switch (action.type) {
|
|
||||||
case 'mediasetloaded':
|
|
||||||
return handleMediaSetLoaded(state, action);
|
|
||||||
case 'overviewpeaksloaded':
|
|
||||||
return handleOverviewPeaksLoaded(state, action);
|
|
||||||
case 'waveformpeaksloaded':
|
|
||||||
return handleWaveformPeaksLoaded(state, action);
|
|
||||||
case 'audiosourceloaded':
|
|
||||||
return handleAudioSourceLoaded(state, action);
|
|
||||||
case 'videosourceloaded':
|
|
||||||
return handleVideoSourceLoaded(state, action);
|
|
||||||
case 'setviewport':
|
|
||||||
return setViewport(state, action);
|
|
||||||
case 'zoomin':
|
|
||||||
return handleZoomIn(state);
|
|
||||||
case 'zoomout':
|
|
||||||
return handleZoomOut(state);
|
|
||||||
case 'viewportchanged':
|
|
||||||
return handleViewportChanged(state, action);
|
|
||||||
case 'waveformselectionchanged':
|
|
||||||
return handleWaveformSelectionChanged(state, action);
|
|
||||||
case 'positionchanged':
|
|
||||||
return handlePositionChanged(state, action);
|
|
||||||
case 'skip':
|
|
||||||
return skip(state, action);
|
|
||||||
case 'play':
|
|
||||||
return play(state);
|
|
||||||
case 'pause':
|
|
||||||
return pause(state);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function handleMediaSetLoaded(
|
|
||||||
state: State,
|
|
||||||
{ mediaSet }: MediaSetLoadedAction
|
|
||||||
): State {
|
|
||||||
return { ...state, mediaSet };
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleOverviewPeaksLoaded(
|
|
||||||
state: State,
|
|
||||||
{ peaks }: OverviewPeaksLoadedAction
|
|
||||||
) {
|
|
||||||
return { ...state, overviewPeaks: peaks };
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleWaveformPeaksLoaded(
|
|
||||||
state: State,
|
|
||||||
{ peaks }: WaveformPeaksLoadedAction
|
|
||||||
) {
|
|
||||||
return { ...state, waveformPeaks: peaks };
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAudioSourceLoaded(
|
|
||||||
state: State,
|
|
||||||
{ src, numFrames }: AudioSourceLoadedAction
|
|
||||||
): State {
|
|
||||||
const mediaSet = state.mediaSet;
|
|
||||||
if (mediaSet == null) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
const viewportEnd = Math.min(
|
|
||||||
Math.round(numFrames / CanvasWidth) * initialViewportCanvasPixels,
|
|
||||||
numFrames
|
|
||||||
);
|
|
||||||
|
|
||||||
return setViewport(
|
|
||||||
{
|
|
||||||
...state,
|
|
||||||
audioSrc: src,
|
|
||||||
mediaSet: {
|
|
||||||
...mediaSet,
|
|
||||||
audioFrames: numFrames,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: 'setviewport', viewport: { start: 0, end: viewportEnd } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleVideoSourceLoaded(
|
|
||||||
state: State,
|
|
||||||
{ src }: VideoSourceLoadedAction
|
|
||||||
): State {
|
|
||||||
return { ...state, videoSrc: src };
|
|
||||||
}
|
|
||||||
|
|
||||||
function setViewport(state: State, { viewport }: SetViewportAction): State {
|
|
||||||
const { mediaSet, selection } = state;
|
|
||||||
if (!mediaSet) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
viewport: viewport,
|
|
||||||
viewportCanvas: {
|
|
||||||
x1: Math.round((viewport.start / mediaSet.audioFrames) * CanvasWidth),
|
|
||||||
x2: Math.round((viewport.end / mediaSet.audioFrames) * CanvasWidth),
|
|
||||||
},
|
|
||||||
selectionCanvas: selectionToWaveformCanvasRange(selection, viewport),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleZoomIn(state: State): State {
|
|
||||||
const {
|
|
||||||
mediaSet,
|
|
||||||
viewport,
|
|
||||||
selection,
|
|
||||||
position: { frame },
|
|
||||||
} = state;
|
|
||||||
|
|
||||||
if (!mediaSet) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newViewport = zoomViewportIn(
|
|
||||||
viewport,
|
|
||||||
mediaSet.audioFrames,
|
|
||||||
selection,
|
|
||||||
frame,
|
|
||||||
zoomFactor
|
|
||||||
);
|
|
||||||
|
|
||||||
// TODO: refactoring zoom helpers to use CanvasRange may avoid this step:
|
|
||||||
return setViewport(state, { type: 'setviewport', viewport: newViewport });
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleZoomOut(state: State): State {
|
|
||||||
const {
|
|
||||||
mediaSet,
|
|
||||||
viewport,
|
|
||||||
selection,
|
|
||||||
position: { currentTime },
|
|
||||||
} = state;
|
|
||||||
|
|
||||||
if (!mediaSet) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newViewport = zoomViewportOut(
|
|
||||||
viewport,
|
|
||||||
mediaSet.audioFrames,
|
|
||||||
selection,
|
|
||||||
currentTime,
|
|
||||||
zoomFactor
|
|
||||||
);
|
|
||||||
|
|
||||||
// TODO: refactoring zoom helpers to use CanvasRange may avoid this step:
|
|
||||||
return setViewport(state, { type: 'setviewport', viewport: newViewport });
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleViewportChanged(
|
|
||||||
state: State,
|
|
||||||
{ event: { mode, selection: canvasRange } }: ViewportChangedAction
|
|
||||||
): State {
|
|
||||||
const { mediaSet, selection } = state;
|
|
||||||
if (!mediaSet) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode != SelectionMode.Normal) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newViewport = {
|
|
||||||
start: Math.round(mediaSet.audioFrames * (canvasRange.x1 / CanvasWidth)),
|
|
||||||
end: Math.round(mediaSet.audioFrames * (canvasRange.x2 / CanvasWidth)),
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
viewport: newViewport,
|
|
||||||
selectionCanvas: selectionToWaveformCanvasRange(selection, newViewport),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleWaveformSelectionChanged(
|
|
||||||
state: State,
|
|
||||||
{
|
|
||||||
event: { mode, prevMode, selection: canvasRange },
|
|
||||||
}: WaveformSelectionChangedAction
|
|
||||||
): State {
|
|
||||||
const {
|
|
||||||
mediaSet,
|
|
||||||
playState,
|
|
||||||
viewport,
|
|
||||||
position: { frame: currFrame },
|
|
||||||
} = state;
|
|
||||||
|
|
||||||
if (mediaSet == null) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
const framesPerPixel = (viewport.end - viewport.start) / CanvasWidth;
|
|
||||||
const newSelection = {
|
|
||||||
start: Math.round(viewport.start + canvasRange.x1 * framesPerPixel),
|
|
||||||
end: Math.round(viewport.start + canvasRange.x2 * framesPerPixel),
|
|
||||||
};
|
|
||||||
|
|
||||||
let currentTime = state.currentTime;
|
|
||||||
if (
|
|
||||||
prevMode != SelectionMode.Normal &&
|
|
||||||
mode == SelectionMode.Normal &&
|
|
||||||
(playState == PlayState.Paused ||
|
|
||||||
currFrame < newSelection.start ||
|
|
||||||
currFrame > newSelection.end)
|
|
||||||
) {
|
|
||||||
currentTime = newSelection.start / mediaSet.audioSampleRate;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
selection: newSelection,
|
|
||||||
currentTime: currentTime,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePositionChanged(
|
|
||||||
state: State,
|
|
||||||
{ currentTime }: PositionChangedAction
|
|
||||||
): State {
|
|
||||||
const {
|
|
||||||
mediaSet,
|
|
||||||
selection,
|
|
||||||
position: { frame: prevFrame },
|
|
||||||
} = state;
|
|
||||||
if (mediaSet == null) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
const frame = Math.round(currentTime * mediaSet.audioSampleRate);
|
|
||||||
const percent = (frame / mediaSet.audioFrames) * 100;
|
|
||||||
|
|
||||||
// reset play position and pause if selection end passed.
|
|
||||||
let playState = state.playState;
|
|
||||||
let forceCurrentTime;
|
|
||||||
if (
|
|
||||||
selection.start != selection.end &&
|
|
||||||
prevFrame < selection.end &&
|
|
||||||
frame >= selection.end
|
|
||||||
) {
|
|
||||||
playState = PlayState.Paused;
|
|
||||||
forceCurrentTime = selection.start / mediaSet.audioSampleRate;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
playState,
|
|
||||||
currentTime: forceCurrentTime,
|
|
||||||
position: {
|
|
||||||
currentTime,
|
|
||||||
frame,
|
|
||||||
percent,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function skip(state: State, { currentTime }: SkipAction): State {
|
|
||||||
return { ...state, currentTime: currentTime };
|
|
||||||
}
|
|
||||||
|
|
||||||
function play(state: State): State {
|
|
||||||
return { ...state, playState: PlayState.Playing };
|
|
||||||
}
|
|
||||||
|
|
||||||
function pause(state: State): State {
|
|
||||||
const { mediaSet, selection } = state;
|
|
||||||
if (!mediaSet) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentTime;
|
|
||||||
if (selection.start != selection.end) {
|
|
||||||
currentTime = selection.start / mediaSet.audioSampleRate;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...state, currentTime, playState: PlayState.Paused };
|
|
||||||
}
|
|
||||||
|
|
||||||
// helpers
|
|
||||||
|
|
||||||
function selectionToWaveformCanvasRange(
|
|
||||||
selection: FrameRange,
|
|
||||||
viewport: FrameRange
|
|
||||||
): CanvasRange {
|
|
||||||
const x1 = frameToWaveformCanvasX(selection.start, viewport, CanvasWidth);
|
|
||||||
const x2 = frameToWaveformCanvasX(selection.end, viewport, CanvasWidth);
|
|
||||||
|
|
||||||
if (x1 == x2) {
|
|
||||||
return { x1: 0, x2: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { x1: x1 || 0, x2: x2 || CanvasWidth };
|
|
||||||
}
|
|
|
@ -1,86 +1,34 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { PlayState } from './AppState';
|
|
||||||
import {
|
|
||||||
CloudDownloadIcon,
|
|
||||||
PauseIcon,
|
|
||||||
PlayIcon,
|
|
||||||
ZoomInIcon,
|
|
||||||
ZoomOutIcon,
|
|
||||||
} from '@heroicons/react/solid';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
playState: PlayState;
|
onPlay: () => void;
|
||||||
zoomInEnabled: boolean;
|
onPause: () => void;
|
||||||
zoomOutEnabled: boolean;
|
|
||||||
onTogglePlay: () => void;
|
|
||||||
onClip: () => void;
|
onClip: () => void;
|
||||||
onZoomIn: () => void;
|
|
||||||
onZoomOut: () => void;
|
|
||||||
downloadClipEnabled: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ControlBar: React.FC<Props> = React.memo((props: Props) => {
|
const ControlBar: React.FC<Props> = React.memo((props: Props) => {
|
||||||
const buttonStyle =
|
const styles = { width: '100%', flexGrow: 0 };
|
||||||
'bg-gray-600 hover:bg-gray-500 text-white font-bold py-2 px-4 rounded';
|
const buttonStyles = {
|
||||||
|
cursor: 'pointer',
|
||||||
const disabledButtonStyle =
|
background: 'black',
|
||||||
'bg-gray-700 text-white font-bold py-2 px-4 rounded cursor-auto';
|
outline: 'none',
|
||||||
|
border: 'none',
|
||||||
const downloadButtonStyle = props.downloadClipEnabled
|
color: 'green',
|
||||||
? 'bg-green-600 hover:bg-green-600 text-white font-bold py-2 px-4 rounded absolute right-0'
|
display: 'inline-block',
|
||||||
: 'bg-gray-600 hover:cursor-not-allowed text-gray-500 font-bold py-2 px-4 rounded absolute right-0';
|
margin: '0 2px',
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="relative grow-0 w-full py-2 space-x-2">
|
<div style={styles}>
|
||||||
<button
|
<button style={buttonStyles} onClick={props.onPlay}>
|
||||||
className={buttonStyle}
|
Play
|
||||||
onClick={(evt) => filterMouseEvent(evt, props.onTogglePlay)}
|
|
||||||
>
|
|
||||||
{playPauseComponent}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button style={buttonStyles} onClick={props.onPause}>
|
||||||
className={props.zoomInEnabled ? buttonStyle : disabledButtonStyle}
|
Pause
|
||||||
onClick={(evt) => filterMouseEvent(evt, props.onZoomIn)}
|
|
||||||
>
|
|
||||||
<ZoomInIcon className={iconStyle} />
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button style={buttonStyles} onClick={props.onClip}>
|
||||||
className={props.zoomOutEnabled ? buttonStyle : disabledButtonStyle}
|
Clip
|
||||||
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -1,112 +1,85 @@
|
||||||
import { useEffect, useRef, useReducer, MouseEvent } from 'react';
|
import { useState, useEffect, useRef, useCallback, MouseEvent } from 'react';
|
||||||
import {
|
|
||||||
stateReducer,
|
|
||||||
State,
|
|
||||||
SelectionMode,
|
|
||||||
HoverState,
|
|
||||||
EmptySelectionAction,
|
|
||||||
CanvasRange,
|
|
||||||
} from './HudCanvasState';
|
|
||||||
import constrainNumeric from './helpers/constrainNumeric';
|
|
||||||
export { EmptySelectionAction } from './HudCanvasState';
|
|
||||||
|
|
||||||
interface Styles {
|
interface Styles {
|
||||||
borderLineWidth: number;
|
borderLineWidth: number;
|
||||||
borderStrokeStyle: string;
|
borderStrokeStyle: string;
|
||||||
positionLineWidth: number;
|
positionLineWidth: number;
|
||||||
positionStrokeStyle: string;
|
positionStrokeStyle: string;
|
||||||
hoverPositionStrokeStyle: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
zIndex: number;
|
||||||
emptySelectionAction: EmptySelectionAction;
|
emptySelectionAction: EmptySelectionAction;
|
||||||
styles: Styles;
|
styles: Styles;
|
||||||
position: number | null;
|
position: number | null;
|
||||||
selection: CanvasRange;
|
selection: Selection;
|
||||||
onSelectionChange: (selectionState: SelectionChangeEvent) => void;
|
onSelectionChange: (selection: Selection) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SelectionChangeEvent {
|
enum Mode {
|
||||||
selection: CanvasRange;
|
Normal,
|
||||||
mode: SelectionMode;
|
Selecting,
|
||||||
prevMode: SelectionMode;
|
Dragging,
|
||||||
|
ResizingStart,
|
||||||
|
ResizingEnd,
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptySelection: CanvasRange = { x1: 0, x2: 0 };
|
enum HoverState {
|
||||||
|
Normal,
|
||||||
|
OverSelectionStart,
|
||||||
|
OverSelectionEnd,
|
||||||
|
OverSelection,
|
||||||
|
}
|
||||||
|
|
||||||
const initialState: State = {
|
export enum EmptySelectionAction {
|
||||||
width: 0,
|
SelectNothing,
|
||||||
emptySelectionAction: EmptySelectionAction.SelectNothing,
|
SelectPrevious,
|
||||||
hoverX: 0,
|
}
|
||||||
selection: emptySelection,
|
|
||||||
origSelection: emptySelection,
|
|
||||||
mousedownX: 0,
|
|
||||||
mode: SelectionMode.Normal,
|
|
||||||
prevMode: SelectionMode.Normal,
|
|
||||||
cursorClass: 'cursor-auto',
|
|
||||||
hoverState: HoverState.Normal,
|
|
||||||
shouldPublish: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCanvasX = (evt: MouseEvent<HTMLCanvasElement>): number => {
|
export interface Selection {
|
||||||
const rect = evt.currentTarget.getBoundingClientRect();
|
start: number;
|
||||||
const x = Math.round(
|
end: number;
|
||||||
((evt.clientX - rect.left) / rect.width) * evt.currentTarget.width
|
}
|
||||||
);
|
|
||||||
return constrainNumeric(x, evt.currentTarget.width);
|
const emptySelection: Selection = { start: 0, end: 0 };
|
||||||
};
|
|
||||||
|
|
||||||
export const HudCanvas: React.FC<Props> = ({
|
export const HudCanvas: React.FC<Props> = ({
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
|
zIndex,
|
||||||
emptySelectionAction,
|
emptySelectionAction,
|
||||||
styles: {
|
styles: {
|
||||||
borderLineWidth,
|
borderLineWidth,
|
||||||
borderStrokeStyle,
|
borderStrokeStyle,
|
||||||
positionLineWidth,
|
positionLineWidth,
|
||||||
positionStrokeStyle,
|
positionStrokeStyle,
|
||||||
hoverPositionStrokeStyle,
|
|
||||||
},
|
},
|
||||||
position,
|
position,
|
||||||
selection: selection,
|
selection,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
// selection and newSelection are in canvas logical pixels:
|
||||||
|
const [newSelection, setNewSelection] = useState({
|
||||||
const [state, dispatch] = useReducer(stateReducer, {
|
...emptySelection,
|
||||||
...initialState,
|
|
||||||
width,
|
|
||||||
selection,
|
|
||||||
emptySelectionAction,
|
|
||||||
});
|
});
|
||||||
|
const [mode, setMode] = useState(Mode.Normal);
|
||||||
|
const [hoverState, setHoverState] = useState(HoverState.Normal);
|
||||||
|
const [cursor, setCursor] = useState('auto');
|
||||||
|
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const moveOffsetX = useRef(0);
|
||||||
|
|
||||||
// side effects
|
// side effects
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch({ selection: selection, x: 0, type: 'setselection' });
|
|
||||||
}, [selection]);
|
|
||||||
|
|
||||||
// handle global mouse up
|
// handle global mouse up
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.addEventListener('mouseup', handleMouseUp);
|
window.addEventListener('mouseup', handleMouseUp);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('mouseup', handleMouseUp);
|
window.removeEventListener('mouseup', handleMouseUp);
|
||||||
};
|
};
|
||||||
}, [state]);
|
}, [mode, newSelection]);
|
||||||
|
|
||||||
// trigger onSelectionChange callback.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!state.shouldPublish) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onSelectionChange({
|
|
||||||
selection: state.selection,
|
|
||||||
mode: state.mode,
|
|
||||||
prevMode: state.prevMode,
|
|
||||||
});
|
|
||||||
}, [state]);
|
|
||||||
|
|
||||||
// draw the overview HUD
|
// draw the overview HUD
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -125,38 +98,32 @@ export const HudCanvas: React.FC<Props> = ({
|
||||||
|
|
||||||
// draw selection
|
// draw selection
|
||||||
|
|
||||||
const currentSelection = state.selection;
|
let currentSelection: Selection;
|
||||||
|
if (
|
||||||
|
mode == Mode.Selecting ||
|
||||||
|
mode == Mode.Dragging ||
|
||||||
|
mode == Mode.ResizingStart ||
|
||||||
|
mode == Mode.ResizingEnd
|
||||||
|
) {
|
||||||
|
currentSelection = newSelection;
|
||||||
|
} else {
|
||||||
|
currentSelection = selection;
|
||||||
|
}
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.strokeStyle = borderStrokeStyle;
|
ctx.strokeStyle = borderStrokeStyle;
|
||||||
ctx.lineWidth = borderLineWidth;
|
ctx.lineWidth = borderLineWidth;
|
||||||
const alpha =
|
const alpha = hoverState == HoverState.OverSelection ? '0.15' : '0.13';
|
||||||
state.hoverState == HoverState.OverSelection ? '0.15' : '0.13';
|
|
||||||
ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`;
|
ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`;
|
||||||
ctx.rect(
|
ctx.rect(
|
||||||
currentSelection.x1,
|
currentSelection.start,
|
||||||
borderLineWidth,
|
borderLineWidth,
|
||||||
currentSelection.x2 - currentSelection.x1,
|
currentSelection.end - currentSelection.start,
|
||||||
canvas.height - borderLineWidth * 2
|
canvas.height - borderLineWidth * 2
|
||||||
);
|
);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.stroke();
|
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
|
// draw position marker
|
||||||
|
|
||||||
if (position == null) {
|
if (position == null) {
|
||||||
|
@ -167,41 +134,188 @@ export const HudCanvas: React.FC<Props> = ({
|
||||||
ctx.strokeStyle = positionStrokeStyle;
|
ctx.strokeStyle = positionStrokeStyle;
|
||||||
ctx.lineWidth = positionLineWidth;
|
ctx.lineWidth = positionLineWidth;
|
||||||
ctx.moveTo(position, 0);
|
ctx.moveTo(position, 0);
|
||||||
ctx.lineWidth = position == 0 ? 8 : 4;
|
ctx.lineWidth = 4;
|
||||||
ctx.lineTo(position, canvas.height);
|
ctx.lineTo(position, canvas.height - 4);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
});
|
});
|
||||||
}, [state, position]);
|
}, [selection, newSelection, position]);
|
||||||
|
|
||||||
|
// handlers
|
||||||
|
|
||||||
|
const hoverOffset = 10;
|
||||||
|
|
||||||
|
const isHoveringSelectionStart = (x: number): boolean => {
|
||||||
|
return (
|
||||||
|
x > selection.start - hoverOffset && x < selection.start + hoverOffset
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isHoveringSelectionEnd = (x: number): boolean => {
|
||||||
|
return x > selection.end - hoverOffset && x < selection.end + hoverOffset;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isHoveringSelection = (x: number): boolean => {
|
||||||
|
return x >= selection.start && x <= selection.end;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCanvasX = (evt: MouseEvent<HTMLCanvasElement>): number => {
|
||||||
|
const rect = evt.currentTarget.getBoundingClientRect();
|
||||||
|
const x = Math.round(((evt.clientX - rect.left) / rect.width) * width);
|
||||||
|
return constrainXToCanvas(x);
|
||||||
|
};
|
||||||
|
|
||||||
|
const constrainXToCanvas = (x: number): number => {
|
||||||
|
if (x < 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (x > width) {
|
||||||
|
return width;
|
||||||
|
}
|
||||||
|
return x;
|
||||||
|
};
|
||||||
|
|
||||||
const handleMouseDown = (evt: MouseEvent<HTMLCanvasElement>) => {
|
const handleMouseDown = (evt: MouseEvent<HTMLCanvasElement>) => {
|
||||||
if (state.mode != SelectionMode.Normal) {
|
if (mode != Mode.Normal) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dispatch({ x: getCanvasX(evt), type: 'mousedown' });
|
|
||||||
|
const x = getCanvasX(evt);
|
||||||
|
|
||||||
|
if (isHoveringSelectionStart(x)) {
|
||||||
|
setMode(Mode.ResizingStart);
|
||||||
|
moveOffsetX.current = x;
|
||||||
|
} else if (isHoveringSelectionEnd(x)) {
|
||||||
|
setMode(Mode.ResizingEnd);
|
||||||
|
moveOffsetX.current = x;
|
||||||
|
} else if (isHoveringSelection(x)) {
|
||||||
|
setMode(Mode.Dragging);
|
||||||
|
setCursor('pointer');
|
||||||
|
moveOffsetX.current = x;
|
||||||
|
} else {
|
||||||
|
setMode(Mode.Selecting);
|
||||||
|
setCursor('col-resize');
|
||||||
|
moveOffsetX.current = x;
|
||||||
|
setNewSelection({ start: x, end: x });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseMove = (evt: MouseEvent<HTMLCanvasElement>) => {
|
const handleMouseMove = (evt: MouseEvent<HTMLCanvasElement>) => {
|
||||||
dispatch({ x: getCanvasX(evt), type: 'mousemove' });
|
const x = getCanvasX(evt);
|
||||||
|
|
||||||
|
switch (mode) {
|
||||||
|
case Mode.Normal: {
|
||||||
|
if (isHoveringSelectionStart(x)) {
|
||||||
|
setHoverState(HoverState.OverSelectionStart);
|
||||||
|
setCursor('col-resize');
|
||||||
|
} else if (isHoveringSelectionEnd(x)) {
|
||||||
|
setHoverState(HoverState.OverSelectionEnd);
|
||||||
|
setCursor('col-resize');
|
||||||
|
} else if (isHoveringSelection(x)) {
|
||||||
|
setHoverState(HoverState.OverSelection);
|
||||||
|
setCursor('pointer');
|
||||||
|
} else {
|
||||||
|
setCursor('auto');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case Mode.ResizingStart: {
|
||||||
|
const diff = x - moveOffsetX.current;
|
||||||
|
const start = constrainXToCanvas(selection.start + diff);
|
||||||
|
|
||||||
|
if (start > selection.end) {
|
||||||
|
setNewSelection({ start: selection.end, end: start });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
setNewSelection({ ...selection, start: start });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case Mode.ResizingEnd: {
|
||||||
|
const diff = x - moveOffsetX.current;
|
||||||
|
const end = constrainXToCanvas(selection.end + diff);
|
||||||
|
|
||||||
|
if (end < selection.start) {
|
||||||
|
setNewSelection({ start: Math.max(0, end), end: selection.start });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
setNewSelection({ ...selection, end: end });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case Mode.Dragging: {
|
||||||
|
const diff = x - moveOffsetX.current;
|
||||||
|
const selectionWidth = selection.end - selection.start;
|
||||||
|
let start = Math.max(0, selection.start + diff);
|
||||||
|
let end = start + selectionWidth;
|
||||||
|
if (end > width) {
|
||||||
|
end = width;
|
||||||
|
start = end - selectionWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
setNewSelection({ start: start, end: end });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case Mode.Selecting: {
|
||||||
|
if (x < moveOffsetX.current) {
|
||||||
|
setNewSelection({
|
||||||
|
start: x,
|
||||||
|
end: moveOffsetX.current,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setNewSelection({ start: moveOffsetX.current, end: x });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
if (state.mode == SelectionMode.Normal) {
|
if (mode == Mode.Normal) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dispatch({ x: state.hoverX, type: 'mouseup' });
|
|
||||||
|
setMode(Mode.Normal);
|
||||||
|
setCursor('auto');
|
||||||
|
|
||||||
|
if (newSelection.start == newSelection.end) {
|
||||||
|
handleEmptySelectionAction();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelectionChange({ ...newSelection });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
const handleEmptySelectionAction = useCallback(() => {
|
||||||
dispatch({ x: state.hoverX, type: 'mouseleave' });
|
switch (emptySelectionAction) {
|
||||||
|
case EmptySelectionAction.SelectPrevious:
|
||||||
|
setNewSelection({ ...selection });
|
||||||
|
break;
|
||||||
|
case EmptySelectionAction.SelectNothing:
|
||||||
|
onSelectionChange({ start: 0, end: 0 });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, [selection]);
|
||||||
|
|
||||||
|
const handleMouseLeave = (_evt: MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
setHoverState(HoverState.Normal);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const canvasStyles = {
|
||||||
|
display: 'block',
|
||||||
|
position: 'absolute',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
zIndex: zIndex,
|
||||||
|
cursor: cursor,
|
||||||
|
} as React.CSSProperties;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className={`block absolute w-full h-full ${state.cursorClass} z-20`}
|
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
|
style={canvasStyles}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
|
|
|
@ -1,294 +0,0 @@
|
||||||
import {
|
|
||||||
stateReducer,
|
|
||||||
SelectionMode,
|
|
||||||
EmptySelectionAction,
|
|
||||||
HoverState,
|
|
||||||
} from './HudCanvasState';
|
|
||||||
|
|
||||||
const initialState = {
|
|
||||||
width: 5000,
|
|
||||||
emptySelectionAction: EmptySelectionAction.SelectNothing,
|
|
||||||
hoverX: 0,
|
|
||||||
selection: { x1: 0, x2: 0 },
|
|
||||||
origSelection: { x1: 0, x2: 0 },
|
|
||||||
mousedownX: 0,
|
|
||||||
mode: SelectionMode.Normal,
|
|
||||||
prevMode: SelectionMode.Normal,
|
|
||||||
cursorClass: 'cursor-auto',
|
|
||||||
hoverState: HoverState.Normal,
|
|
||||||
shouldPublish: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('stateReducer', () => {
|
|
||||||
describe('setselection', () => {
|
|
||||||
it('sets the selection', () => {
|
|
||||||
const state = stateReducer(
|
|
||||||
{ ...initialState },
|
|
||||||
{ type: 'setselection', x: 0, selection: { x1: 100, x2: 200 } }
|
|
||||||
);
|
|
||||||
expect(state.selection).toEqual({ x1: 100, x2: 200 });
|
|
||||||
expect(state.shouldPublish).toBeFalsy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe.each([
|
|
||||||
{
|
|
||||||
name: 'entering resizing start',
|
|
||||||
x: 995,
|
|
||||||
selection: { x1: 1000, x2: 2000 },
|
|
||||||
wantMode: SelectionMode.ResizingStart,
|
|
||||||
wantSelection: { x1: 1000, x2: 2000 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'entering resizing end',
|
|
||||||
x: 2003,
|
|
||||||
selection: { x1: 1000, x2: 2000 },
|
|
||||||
wantMode: SelectionMode.ResizingEnd,
|
|
||||||
wantSelection: { x1: 1000, x2: 2000 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'entering dragging',
|
|
||||||
x: 1500,
|
|
||||||
selection: { x1: 1000, x2: 2000 },
|
|
||||||
wantMode: SelectionMode.Dragging,
|
|
||||||
wantSelection: { x1: 1000, x2: 2000 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'entering selecting',
|
|
||||||
x: 10,
|
|
||||||
selection: { x1: 1000, x2: 2000 },
|
|
||||||
wantMode: SelectionMode.Selecting,
|
|
||||||
wantSelection: { x1: 10, x2: 10 },
|
|
||||||
},
|
|
||||||
])('mousedown', ({ name, x, selection, wantMode, wantSelection }) => {
|
|
||||||
test(`${name} generates the expected state`, () => {
|
|
||||||
const state = stateReducer(
|
|
||||||
{ ...initialState, selection: selection },
|
|
||||||
{ type: 'mousedown', x: x }
|
|
||||||
);
|
|
||||||
expect(state.mode).toEqual(wantMode);
|
|
||||||
expect(state.selection).toEqual(wantSelection);
|
|
||||||
expect(state.mousedownX).toEqual(x);
|
|
||||||
expect(state.shouldPublish).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe.each([
|
|
||||||
{
|
|
||||||
name: 're-entering normal mode',
|
|
||||||
x: 1200,
|
|
||||||
selection: { x1: 1000, x2: 2000 },
|
|
||||||
emptySelectionAction: EmptySelectionAction.SelectNothing,
|
|
||||||
wantSelection: { x1: 1000, x2: 2000 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'when nothing is selected and emptySelectionAction is SelectNothing',
|
|
||||||
x: 1200,
|
|
||||||
selection: { x1: 1000, x2: 1000 },
|
|
||||||
emptySelectionAction: EmptySelectionAction.SelectNothing,
|
|
||||||
wantSelection: { x1: 1200, x2: 1200 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// TODO: broken
|
|
||||||
name: 'when nothing is selected and emptySelectionAction is SelectPrevious',
|
|
||||||
x: 1200,
|
|
||||||
selection: { x1: 1000, x2: 2000 },
|
|
||||||
emptySelectionAction: EmptySelectionAction.SelectPrevious,
|
|
||||||
wantSelection: { x1: 1000, x2: 2000 },
|
|
||||||
},
|
|
||||||
])(
|
|
||||||
'mouseup',
|
|
||||||
({ name, x, selection, emptySelectionAction, wantSelection }) => {
|
|
||||||
test(`${name} generates the expected state`, () => {
|
|
||||||
const state = stateReducer(
|
|
||||||
{
|
|
||||||
...initialState,
|
|
||||||
selection: selection,
|
|
||||||
mode: SelectionMode.Selecting,
|
|
||||||
emptySelectionAction: emptySelectionAction,
|
|
||||||
},
|
|
||||||
{ type: 'mouseup', x: x }
|
|
||||||
);
|
|
||||||
expect(state.mode).toEqual(SelectionMode.Normal);
|
|
||||||
expect(state.prevMode).toEqual(SelectionMode.Selecting);
|
|
||||||
expect(state.selection).toEqual(wantSelection);
|
|
||||||
expect(state.shouldPublish).toBeTruthy();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
describe('mouseleave', () => {
|
|
||||||
it('sets the state', () => {
|
|
||||||
const state = stateReducer(
|
|
||||||
{
|
|
||||||
...initialState,
|
|
||||||
selection: { x1: 2000, x2: 3000 },
|
|
||||||
mode: SelectionMode.Dragging,
|
|
||||||
mousedownX: 475,
|
|
||||||
},
|
|
||||||
{ type: 'mouseleave', x: 500 }
|
|
||||||
);
|
|
||||||
expect(state.mode).toEqual(SelectionMode.Dragging);
|
|
||||||
expect(state.selection).toEqual({ x1: 2000, x2: 3000 });
|
|
||||||
expect(state.mousedownX).toEqual(475);
|
|
||||||
expect(state.hoverX).toEqual(0);
|
|
||||||
expect(state.shouldPublish).toBeFalsy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('mousemove', () => {
|
|
||||||
describe.each([
|
|
||||||
// Normal mode
|
|
||||||
{
|
|
||||||
name: 'hovering over selection start',
|
|
||||||
mode: SelectionMode.Normal,
|
|
||||||
x: 997,
|
|
||||||
selection: { x1: 1000, x2: 3000 },
|
|
||||||
wantHoverState: HoverState.OverSelectionStart,
|
|
||||||
shouldPublish: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'hovering over selection end',
|
|
||||||
mode: SelectionMode.Normal,
|
|
||||||
x: 3009,
|
|
||||||
selection: { x1: 1000, x2: 3000 },
|
|
||||||
wantHoverState: HoverState.OverSelectionEnd,
|
|
||||||
shouldPublish: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'hovering over selection',
|
|
||||||
mode: SelectionMode.Normal,
|
|
||||||
x: 1200,
|
|
||||||
selection: { x1: 1000, x2: 3000 },
|
|
||||||
wantHoverState: HoverState.OverSelection,
|
|
||||||
shouldPublish: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'hovering elsewhere',
|
|
||||||
mode: SelectionMode.Normal,
|
|
||||||
x: 300,
|
|
||||||
selection: { x1: 1000, x2: 3000 },
|
|
||||||
wantHoverState: HoverState.Normal,
|
|
||||||
shouldPublish: false,
|
|
||||||
},
|
|
||||||
// Selecting mode
|
|
||||||
{
|
|
||||||
name: 'when not crossing over',
|
|
||||||
mode: SelectionMode.Selecting,
|
|
||||||
x: 3005,
|
|
||||||
mousedownX: 2000,
|
|
||||||
selection: { x1: 2000, x2: 3000 },
|
|
||||||
wantSelection: { x1: 2000, x2: 3005 },
|
|
||||||
shouldPublish: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'when crossing over',
|
|
||||||
mode: SelectionMode.Selecting,
|
|
||||||
x: 1995,
|
|
||||||
mousedownX: 2000,
|
|
||||||
selection: { x1: 2000, x2: 2002 },
|
|
||||||
wantSelection: { x1: 1995, x2: 2000 },
|
|
||||||
shouldPublish: true,
|
|
||||||
},
|
|
||||||
// Dragging mode
|
|
||||||
{
|
|
||||||
name: 'in the middle of the canvas',
|
|
||||||
mode: SelectionMode.Dragging,
|
|
||||||
x: 1220,
|
|
||||||
mousedownX: 1200,
|
|
||||||
selection: { x1: 1000, x2: 1500 },
|
|
||||||
origSelection: { x1: 1000, x2: 1500 },
|
|
||||||
wantSelection: { x1: 1020, x2: 1520 },
|
|
||||||
shouldPublish: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'at the start of the canvas',
|
|
||||||
mode: SelectionMode.Dragging,
|
|
||||||
x: 30,
|
|
||||||
mousedownX: 50,
|
|
||||||
selection: { x1: 10, x2: 210 },
|
|
||||||
origSelection: { x1: 10, x2: 210 },
|
|
||||||
wantSelection: { x1: 0, x2: 200 },
|
|
||||||
shouldPublish: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'at the end of the canvas',
|
|
||||||
mode: SelectionMode.Dragging,
|
|
||||||
x: 1400,
|
|
||||||
mousedownX: 1250,
|
|
||||||
selection: { x1: 4800, x2: 4900 },
|
|
||||||
origSelection: { x1: 4800, x2: 4900 },
|
|
||||||
wantSelection: { x1: 4900, x2: 5000 },
|
|
||||||
shouldPublish: true,
|
|
||||||
},
|
|
||||||
// ResizingStart mode
|
|
||||||
{
|
|
||||||
name: 'when not crossing over',
|
|
||||||
mode: SelectionMode.ResizingStart,
|
|
||||||
x: 2020,
|
|
||||||
selection: { x1: 2000, x2: 3000 },
|
|
||||||
origSelection: { x1: 2000, x2: 3000 },
|
|
||||||
wantSelection: { x1: 2020, x2: 3000 },
|
|
||||||
shouldPublish: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'when crossing over',
|
|
||||||
mode: SelectionMode.ResizingStart,
|
|
||||||
x: 2010,
|
|
||||||
selection: { x1: 2000, x2: 2002 },
|
|
||||||
origSelection: { x1: 2000, x2: 2002 },
|
|
||||||
wantSelection: { x1: 2002, x2: 2010 },
|
|
||||||
shouldPublish: true,
|
|
||||||
},
|
|
||||||
// ResizingEnd mode
|
|
||||||
{
|
|
||||||
name: 'when not crossing over',
|
|
||||||
mode: SelectionMode.ResizingEnd,
|
|
||||||
x: 2007,
|
|
||||||
selection: { x1: 1000, x2: 2000 },
|
|
||||||
origSelection: { x1: 1000, x2: 2000 },
|
|
||||||
wantSelection: { x1: 1000, x2: 2007 },
|
|
||||||
shouldPublish: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'when crossing over',
|
|
||||||
mode: SelectionMode.ResizingEnd,
|
|
||||||
x: 1995,
|
|
||||||
selection: { x1: 2000, x2: 2002 },
|
|
||||||
origSelection: { x1: 2000, x2: 2002 },
|
|
||||||
wantSelection: { x1: 1995, x2: 2000 },
|
|
||||||
shouldPublish: true,
|
|
||||||
},
|
|
||||||
])(
|
|
||||||
'mousemove',
|
|
||||||
({
|
|
||||||
name,
|
|
||||||
mode,
|
|
||||||
x,
|
|
||||||
mousedownX = 0,
|
|
||||||
selection,
|
|
||||||
origSelection = { x1: 0, x2: 0 },
|
|
||||||
wantSelection = selection,
|
|
||||||
wantHoverState = HoverState.Normal,
|
|
||||||
shouldPublish,
|
|
||||||
}) => {
|
|
||||||
test(`${SelectionMode[mode]} mode: ${name} generates the expected state`, () => {
|
|
||||||
const state = stateReducer(
|
|
||||||
{
|
|
||||||
...initialState,
|
|
||||||
selection: selection,
|
|
||||||
origSelection: origSelection,
|
|
||||||
mode: mode,
|
|
||||||
mousedownX: mousedownX,
|
|
||||||
},
|
|
||||||
{ type: 'mousemove', x: x }
|
|
||||||
);
|
|
||||||
expect(state.mode).toEqual(mode);
|
|
||||||
expect(state.selection).toEqual(wantSelection);
|
|
||||||
expect(state.hoverState).toEqual(wantHoverState);
|
|
||||||
expect(state.shouldPublish).toEqual(shouldPublish);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,266 +0,0 @@
|
||||||
import constrainNumeric from './helpers/constrainNumeric';
|
|
||||||
|
|
||||||
export const CanvasWidth = 2000;
|
|
||||||
export const CanvasHeight = 500;
|
|
||||||
|
|
||||||
export interface CanvasRange {
|
|
||||||
x1: number;
|
|
||||||
x2: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum HoverState {
|
|
||||||
Normal,
|
|
||||||
OverSelectionStart,
|
|
||||||
OverSelectionEnd,
|
|
||||||
OverSelection,
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum EmptySelectionAction {
|
|
||||||
SelectNothing,
|
|
||||||
SelectPrevious,
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum SelectionMode {
|
|
||||||
Normal,
|
|
||||||
Selecting,
|
|
||||||
Dragging,
|
|
||||||
ResizingStart,
|
|
||||||
ResizingEnd,
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface State {
|
|
||||||
width: number;
|
|
||||||
emptySelectionAction: EmptySelectionAction;
|
|
||||||
hoverX: number;
|
|
||||||
selection: CanvasRange;
|
|
||||||
origSelection: CanvasRange;
|
|
||||||
mousedownX: number;
|
|
||||||
mode: SelectionMode;
|
|
||||||
prevMode: SelectionMode;
|
|
||||||
cursorClass: string;
|
|
||||||
hoverState: HoverState;
|
|
||||||
shouldPublish: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SelectionAction {
|
|
||||||
type: string;
|
|
||||||
x: number;
|
|
||||||
// TODO: selection is only used for the setselection SelectionAction. Improve
|
|
||||||
// the typing here.
|
|
||||||
selection?: CanvasRange;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const stateReducer = (
|
|
||||||
{
|
|
||||||
selection: prevSelection,
|
|
||||||
origSelection,
|
|
||||||
mousedownX: prevMousedownX,
|
|
||||||
mode: prevMode,
|
|
||||||
width,
|
|
||||||
emptySelectionAction,
|
|
||||||
}: State,
|
|
||||||
{ x, type, selection: selectionToSet }: SelectionAction
|
|
||||||
): State => {
|
|
||||||
let mode: SelectionMode;
|
|
||||||
let newSelection: CanvasRange;
|
|
||||||
let hoverX: number;
|
|
||||||
let mousedownX: number;
|
|
||||||
let cursorClass: string;
|
|
||||||
let hoverState: HoverState;
|
|
||||||
let shouldPublish: boolean | null = null;
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'setselection':
|
|
||||||
newSelection = selectionToSet || { x1: 0, x2: 0 };
|
|
||||||
mousedownX = prevMousedownX;
|
|
||||||
mode = SelectionMode.Normal;
|
|
||||||
cursorClass = 'cursor-auto';
|
|
||||||
hoverX = x;
|
|
||||||
hoverState = HoverState.Normal;
|
|
||||||
shouldPublish = false;
|
|
||||||
|
|
||||||
break;
|
|
||||||
case 'mousedown':
|
|
||||||
mousedownX = x;
|
|
||||||
cursorClass = 'cursor-auto';
|
|
||||||
hoverX = x;
|
|
||||||
hoverState = HoverState.Normal;
|
|
||||||
|
|
||||||
if (isHoveringSelectionStart(x, prevSelection)) {
|
|
||||||
newSelection = prevSelection;
|
|
||||||
mode = SelectionMode.ResizingStart;
|
|
||||||
} else if (isHoveringSelectionEnd(x, prevSelection)) {
|
|
||||||
newSelection = prevSelection;
|
|
||||||
mode = SelectionMode.ResizingEnd;
|
|
||||||
} else if (isHoveringSelection(x, prevSelection)) {
|
|
||||||
newSelection = prevSelection;
|
|
||||||
mode = SelectionMode.Dragging;
|
|
||||||
cursorClass = 'cursor-move';
|
|
||||||
} else {
|
|
||||||
newSelection = { x1: x, x2: x };
|
|
||||||
mode = SelectionMode.Selecting;
|
|
||||||
cursorClass = 'cursor-col-resize';
|
|
||||||
}
|
|
||||||
|
|
||||||
origSelection = newSelection;
|
|
||||||
|
|
||||||
break;
|
|
||||||
case 'mouseup':
|
|
||||||
newSelection = prevSelection;
|
|
||||||
mousedownX = prevMousedownX;
|
|
||||||
mode = SelectionMode.Normal;
|
|
||||||
cursorClass = 'cursor-auto';
|
|
||||||
hoverX = x;
|
|
||||||
hoverState = HoverState.Normal;
|
|
||||||
|
|
||||||
if (
|
|
||||||
newSelection.x1 == newSelection.x2 &&
|
|
||||||
emptySelectionAction == EmptySelectionAction.SelectNothing
|
|
||||||
) {
|
|
||||||
newSelection = { x1: x, x2: x };
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
case 'mouseleave':
|
|
||||||
newSelection = prevSelection;
|
|
||||||
mousedownX = prevMousedownX;
|
|
||||||
mode = prevMode;
|
|
||||||
cursorClass = 'cursor-auto';
|
|
||||||
hoverX = 0;
|
|
||||||
hoverState = HoverState.Normal;
|
|
||||||
|
|
||||||
break;
|
|
||||||
case 'mousemove':
|
|
||||||
mousedownX = prevMousedownX;
|
|
||||||
hoverX = x;
|
|
||||||
hoverState = HoverState.Normal;
|
|
||||||
|
|
||||||
switch (prevMode) {
|
|
||||||
case SelectionMode.Normal: {
|
|
||||||
newSelection = prevSelection;
|
|
||||||
mode = SelectionMode.Normal;
|
|
||||||
|
|
||||||
if (isHoveringSelectionStart(x, prevSelection)) {
|
|
||||||
cursorClass = 'cursor-col-resize';
|
|
||||||
hoverState = HoverState.OverSelectionStart;
|
|
||||||
} else if (isHoveringSelectionEnd(x, prevSelection)) {
|
|
||||||
cursorClass = 'cursor-col-resize';
|
|
||||||
hoverState = HoverState.OverSelectionEnd;
|
|
||||||
} else if (isHoveringSelection(x, prevSelection)) {
|
|
||||||
cursorClass = 'cursor-move';
|
|
||||||
hoverState = HoverState.OverSelection;
|
|
||||||
} else {
|
|
||||||
cursorClass = 'cursor-auto';
|
|
||||||
hoverState = HoverState.Normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case SelectionMode.Selecting: {
|
|
||||||
cursorClass = 'cursor-col-resize';
|
|
||||||
mode = SelectionMode.Selecting;
|
|
||||||
if (x < prevMousedownX) {
|
|
||||||
newSelection = {
|
|
||||||
x1: x,
|
|
||||||
x2: prevMousedownX,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
newSelection = {
|
|
||||||
x1: prevMousedownX,
|
|
||||||
x2: x,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case SelectionMode.Dragging: {
|
|
||||||
mode = SelectionMode.Dragging;
|
|
||||||
cursorClass = 'cursor-move';
|
|
||||||
|
|
||||||
const diff = x - prevMousedownX;
|
|
||||||
const selectionWidth = origSelection.x2 - origSelection.x1;
|
|
||||||
|
|
||||||
let start = Math.max(0, origSelection.x1 + diff);
|
|
||||||
let end = start + selectionWidth;
|
|
||||||
if (end > width) {
|
|
||||||
end = width;
|
|
||||||
start = end - selectionWidth;
|
|
||||||
}
|
|
||||||
newSelection = { x1: start, x2: end };
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case SelectionMode.ResizingStart: {
|
|
||||||
mode = SelectionMode.ResizingStart;
|
|
||||||
cursorClass = 'cursor-col-resize';
|
|
||||||
|
|
||||||
const start = constrainNumeric(x, width);
|
|
||||||
if (start > origSelection.x2) {
|
|
||||||
newSelection = { x1: origSelection.x2, x2: start };
|
|
||||||
} else {
|
|
||||||
newSelection = { ...origSelection, x1: start };
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case SelectionMode.ResizingEnd: {
|
|
||||||
mode = SelectionMode.ResizingEnd;
|
|
||||||
cursorClass = 'cursor-col-resize';
|
|
||||||
|
|
||||||
const end = constrainNumeric(x, width);
|
|
||||||
if (end < origSelection.x1) {
|
|
||||||
newSelection = {
|
|
||||||
x1: end,
|
|
||||||
x2: origSelection.x1,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
newSelection = { ...origSelection, x2: x };
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
throw new Error();
|
|
||||||
}
|
|
||||||
|
|
||||||
// by default, only trigger the callback if the selection or mode has changed.
|
|
||||||
if (shouldPublish == null) {
|
|
||||||
shouldPublish = newSelection != prevSelection || mode != prevMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
width: width,
|
|
||||||
emptySelectionAction: emptySelectionAction,
|
|
||||||
hoverX: hoverX,
|
|
||||||
selection: newSelection,
|
|
||||||
origSelection: origSelection,
|
|
||||||
mousedownX: mousedownX,
|
|
||||||
mode: mode,
|
|
||||||
prevMode: prevMode,
|
|
||||||
cursorClass: cursorClass,
|
|
||||||
hoverState: hoverState,
|
|
||||||
shouldPublish: shouldPublish,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// helpers
|
|
||||||
|
|
||||||
const hoverOffset = 10;
|
|
||||||
|
|
||||||
const isHoveringSelectionStart = (
|
|
||||||
x: number,
|
|
||||||
selection: CanvasRange
|
|
||||||
): boolean => {
|
|
||||||
return x > selection.x1 - hoverOffset && x < selection.x1 + hoverOffset;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isHoveringSelectionEnd = (x: number, selection: CanvasRange): boolean => {
|
|
||||||
return x > selection.x2 - hoverOffset && x < selection.x2 + hoverOffset;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isHoveringSelection = (x: number, selection: CanvasRange): boolean => {
|
|
||||||
return x >= selection.x1 && x <= selection.x2;
|
|
||||||
};
|
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { MediaSet } from './generated/media_set';
|
||||||
|
import { WaveformCanvas } from './WaveformCanvas';
|
||||||
|
import { HudCanvas, EmptySelectionAction } from './HudCanvas';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
export interface Selection {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
peaks: Observable<number[]>;
|
||||||
|
mediaSet: MediaSet;
|
||||||
|
height: number;
|
||||||
|
offsetPixels: number;
|
||||||
|
position: VideoPosition;
|
||||||
|
viewport: Frames;
|
||||||
|
onSelectionChange: (selection: Selection) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CanvasLogicalWidth = 2_000;
|
||||||
|
export const CanvasLogicalHeight = 500;
|
||||||
|
|
||||||
|
export const Overview: React.FC<Props> = ({
|
||||||
|
peaks,
|
||||||
|
mediaSet,
|
||||||
|
height,
|
||||||
|
offsetPixels,
|
||||||
|
position,
|
||||||
|
viewport,
|
||||||
|
onSelectionChange,
|
||||||
|
}: Props) => {
|
||||||
|
const [selectedPixels, setSelectedPixels] = useState({ start: 0, end: 0 });
|
||||||
|
const [positionPixels, setPositionPixels] = useState(0);
|
||||||
|
|
||||||
|
// side effects
|
||||||
|
|
||||||
|
// convert viewport from frames to canvas pixels.
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedPixels({
|
||||||
|
start: Math.round(
|
||||||
|
(viewport.start / mediaSet.audioFrames) * CanvasLogicalWidth
|
||||||
|
),
|
||||||
|
end: Math.round(
|
||||||
|
(viewport.end / mediaSet.audioFrames) * CanvasLogicalWidth
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [viewport, mediaSet]);
|
||||||
|
|
||||||
|
// convert position from frames to canvas pixels:
|
||||||
|
useEffect(() => {
|
||||||
|
const ratio =
|
||||||
|
position.currentTime / (mediaSet.audioFrames / mediaSet.audioSampleRate);
|
||||||
|
setPositionPixels(Math.round(ratio * CanvasLogicalWidth));
|
||||||
|
frames;
|
||||||
|
}, [mediaSet, position]);
|
||||||
|
|
||||||
|
// handlers
|
||||||
|
|
||||||
|
// convert selection change from canvas pixels to frames, and trigger callback.
|
||||||
|
const handleSelectionChange = useCallback(
|
||||||
|
({ start, end }: Selection) => {
|
||||||
|
onSelectionChange({
|
||||||
|
start: Math.round((start / CanvasLogicalWidth) * mediaSet.audioFrames),
|
||||||
|
end: Math.round((end / CanvasLogicalWidth) * mediaSet.audioFrames),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[mediaSet]
|
||||||
|
);
|
||||||
|
|
||||||
|
// render component
|
||||||
|
|
||||||
|
const containerStyles = {
|
||||||
|
flexGrow: 0,
|
||||||
|
position: 'relative',
|
||||||
|
margin: `0 ${offsetPixels}px`,
|
||||||
|
height: `${height}px`,
|
||||||
|
} as React.CSSProperties;
|
||||||
|
|
||||||
|
const hudStyles = {
|
||||||
|
borderLineWidth: 4,
|
||||||
|
borderStrokeStyle: 'red',
|
||||||
|
positionLineWidth: 4,
|
||||||
|
positionStrokeStyle: 'red',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={containerStyles}>
|
||||||
|
<WaveformCanvas
|
||||||
|
peaks={peaks}
|
||||||
|
channels={mediaSet.audioChannels}
|
||||||
|
width={CanvasLogicalWidth}
|
||||||
|
height={CanvasLogicalHeight}
|
||||||
|
strokeStyle="black"
|
||||||
|
fillStyle="#003300"
|
||||||
|
zIndex={1}
|
||||||
|
alpha={1}
|
||||||
|
></WaveformCanvas>
|
||||||
|
<HudCanvas
|
||||||
|
width={CanvasLogicalWidth}
|
||||||
|
height={CanvasLogicalHeight}
|
||||||
|
zIndex={1}
|
||||||
|
emptySelectionAction={EmptySelectionAction.SelectPrevious}
|
||||||
|
styles={hudStyles}
|
||||||
|
position={positionPixels}
|
||||||
|
selection={selectedPixels}
|
||||||
|
onSelectionChange={handleSelectionChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,155 +0,0 @@
|
||||||
import { MediaSet, MediaSetServiceClientImpl } from './generated/media_set';
|
|
||||||
import { newRPC } from './App';
|
|
||||||
import { PlayState } from './AppState';
|
|
||||||
import { useEffect, useRef } from 'react';
|
|
||||||
import millisFromDuration from './helpers/millisFromDuration';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
mediaSet: MediaSet;
|
|
||||||
playState: PlayState;
|
|
||||||
audioSrc: string;
|
|
||||||
videoSrc: string;
|
|
||||||
// used to jump to a new position:
|
|
||||||
currentTime?: number;
|
|
||||||
onPositionChanged: (currentTime: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const triggerCallbackIntervalMillis = 20;
|
|
||||||
|
|
||||||
// eslint is complaining about prop validation which doesn't make much sense.
|
|
||||||
/* eslint-disable react/prop-types */
|
|
||||||
export const Player: React.FC<Props> = ({
|
|
||||||
mediaSet,
|
|
||||||
playState,
|
|
||||||
audioSrc,
|
|
||||||
videoSrc,
|
|
||||||
currentTime,
|
|
||||||
onPositionChanged,
|
|
||||||
}) => {
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
|
|
||||||
const audioRef = useRef(new Audio());
|
|
||||||
const videoRef = useRef(document.createElement('video'));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setInterval(() => {
|
|
||||||
if (audioRef.current.paused) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onPositionChanged(audioRef.current.currentTime);
|
|
||||||
}, triggerCallbackIntervalMillis);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (playState == PlayState.Paused && !audioRef.current.paused) {
|
|
||||||
audioRef.current.pause();
|
|
||||||
videoRef.current.pause();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playState == PlayState.Playing && audioRef.current.paused) {
|
|
||||||
audioRef.current.play();
|
|
||||||
videoRef.current.play();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}, [playState]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (audioSrc == '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
audioRef.current.src = audioSrc;
|
|
||||||
console.log('set audio src', audioSrc);
|
|
||||||
}, [audioSrc]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (videoSrc == '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
videoRef.current.src = videoSrc;
|
|
||||||
console.log('set video src', videoSrc);
|
|
||||||
}, [videoSrc]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentTime == undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
audioRef.current.currentTime = currentTime;
|
|
||||||
videoRef.current.currentTime = currentTime;
|
|
||||||
onPositionChanged(currentTime);
|
|
||||||
}, [currentTime]);
|
|
||||||
|
|
||||||
// render canvas
|
|
||||||
useEffect(() => {
|
|
||||||
// TODO: not sure if requestAnimationFrame is recommended here.
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
(async function () {
|
|
||||||
if (!mediaSet) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
if (canvas == null) {
|
|
||||||
console.error('no canvas ref available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (ctx == null) {
|
|
||||||
console.error('no 2d context available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set aspect ratio.
|
|
||||||
canvas.width =
|
|
||||||
canvas.height * (canvas.clientWidth / canvas.clientHeight);
|
|
||||||
|
|
||||||
// If the required position is 0, display the thumbnail instead of
|
|
||||||
// trying to render the video. The most important use case is before a
|
|
||||||
// click event has happened, when autoplay restrictions will prevent
|
|
||||||
// the video being rendered to canvas.
|
|
||||||
if (videoRef.current.currentTime == 0) {
|
|
||||||
const service = new MediaSetServiceClientImpl(newRPC());
|
|
||||||
const thumbnail = await service.GetVideoThumbnail({
|
|
||||||
id: mediaSet.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: avoid fetching the image every re-render:
|
|
||||||
const url = URL.createObjectURL(
|
|
||||||
new Blob([thumbnail.image], { type: 'image/jpeg' })
|
|
||||||
);
|
|
||||||
const img = new Image(thumbnail.width, thumbnail.height);
|
|
||||||
|
|
||||||
img.src = url;
|
|
||||||
img.onload = () => ctx.drawImage(img, 0, 0, 177, 100);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// otherwise, render the video, which (should) work now.
|
|
||||||
const duration = millisFromDuration(mediaSet.videoDuration);
|
|
||||||
const durSecs = duration / 1000;
|
|
||||||
const ratio = videoRef.current.currentTime / durSecs;
|
|
||||||
const x = (canvas.width - 177) * ratio;
|
|
||||||
ctx.clearRect(0, 0, x, canvas.height);
|
|
||||||
ctx.clearRect(x + 177, 0, canvas.width - 177 - x, canvas.height);
|
|
||||||
ctx.drawImage(videoRef.current, x, 0, 177, 100);
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
}, [mediaSet, videoRef.current.currentTime]);
|
|
||||||
|
|
||||||
// render component
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={`relative grow-0 h-[100px]`}>
|
|
||||||
<canvas
|
|
||||||
className="absolute block w-full h-full"
|
|
||||||
width="500"
|
|
||||||
height="100"
|
|
||||||
ref={canvasRef}
|
|
||||||
></canvas>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -24,7 +24,7 @@ export const SeekBar: React.FC<Props> = ({
|
||||||
onPositionChanged,
|
onPositionChanged,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [mode, setMode] = useState(Mode.Normal);
|
const [mode, setMode] = useState(Mode.Normal);
|
||||||
const [cursor, setCursor] = useState('cursor-auto');
|
const [cursor, setCursor] = useState('auto');
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
// render canvas
|
// render canvas
|
||||||
|
@ -45,11 +45,12 @@ export const SeekBar: React.FC<Props> = ({
|
||||||
canvas.width = canvas.height * (canvas.clientWidth / canvas.clientHeight);
|
canvas.width = canvas.height * (canvas.clientWidth / canvas.clientHeight);
|
||||||
|
|
||||||
// background
|
// background
|
||||||
ctx.fillStyle = 'transparent';
|
ctx.fillStyle = '#444444';
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
// seek bar
|
// seek bar
|
||||||
const offset = offsetCanvas(canvas);
|
const pixelRatio = canvas.width / canvas.clientWidth;
|
||||||
|
const offset = offsetPixels * pixelRatio;
|
||||||
const width = canvas.width - offset * 2;
|
const width = canvas.width - offset * 2;
|
||||||
ctx.fillStyle = 'black';
|
ctx.fillStyle = 'black';
|
||||||
ctx.fillRect(offset, InnerMargin, width, canvas.height - InnerMargin * 2);
|
ctx.fillRect(offset, InnerMargin, width, canvas.height - InnerMargin * 2);
|
||||||
|
@ -67,33 +68,23 @@ export const SeekBar: React.FC<Props> = ({
|
||||||
|
|
||||||
// helpers
|
// helpers
|
||||||
|
|
||||||
const emitPositionEvent = (x: number, canvas: HTMLCanvasElement) => {
|
const emitPositionEvent = (evt: MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
const canvas = evt.currentTarget;
|
||||||
|
const { x } = mouseEventToCanvasPoint(evt);
|
||||||
const pixelRatio = canvas.width / canvas.clientWidth;
|
const pixelRatio = canvas.width / canvas.clientWidth;
|
||||||
const offset = offsetPixels * pixelRatio;
|
const offset = offsetPixels * pixelRatio;
|
||||||
const ratio = (x - offset) / (canvas.width - offset * 2);
|
const ratio = (x - offset) / (canvas.width - offset * 2);
|
||||||
onPositionChanged(ratio * duration);
|
onPositionChanged(ratio * duration);
|
||||||
};
|
};
|
||||||
|
|
||||||
const offsetCanvas = (canvas: HTMLCanvasElement): number => {
|
|
||||||
return Math.round(offsetPixels * (canvas.width / canvas.clientWidth));
|
|
||||||
};
|
|
||||||
|
|
||||||
// handlers
|
// handlers
|
||||||
|
|
||||||
const handleMouseDown = (evt: MouseEvent<HTMLCanvasElement>) => {
|
const handleMouseDown = (evt: MouseEvent<HTMLCanvasElement>) => {
|
||||||
if (mode != Mode.Normal) return;
|
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);
|
setMode(Mode.Dragging);
|
||||||
|
|
||||||
emitPositionEvent(x, canvas);
|
emitPositionEvent(evt);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
|
@ -103,35 +94,18 @@ export const SeekBar: React.FC<Props> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseMove = (evt: MouseEvent<HTMLCanvasElement>) => {
|
const handleMouseMove = (evt: MouseEvent<HTMLCanvasElement>) => {
|
||||||
const canvas = evt.currentTarget;
|
const { y } = mouseEventToCanvasPoint(evt);
|
||||||
const offset = offsetCanvas(canvas);
|
|
||||||
|
|
||||||
const coords = mouseEventToCanvasPoint(evt);
|
|
||||||
const { y } = coords;
|
|
||||||
let { x } = coords;
|
|
||||||
|
|
||||||
// TODO: improve mouse detection around knob.
|
// TODO: improve mouse detection around knob.
|
||||||
if (
|
if (y > InnerMargin && y < LogicalHeight - InnerMargin) {
|
||||||
x >= offset &&
|
setCursor('pointer');
|
||||||
x < canvas.width - offset &&
|
|
||||||
y > InnerMargin &&
|
|
||||||
y < LogicalHeight - InnerMargin
|
|
||||||
) {
|
|
||||||
setCursor('cursor-pointer');
|
|
||||||
} else {
|
} else {
|
||||||
setCursor('cursor-auto');
|
setCursor('auto');
|
||||||
}
|
|
||||||
|
|
||||||
if (x < offset) {
|
|
||||||
x = offset;
|
|
||||||
}
|
|
||||||
if (x > canvas.width - offset) {
|
|
||||||
x = canvas.width - offset;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode == Mode.Normal) return;
|
if (mode == Mode.Normal) return;
|
||||||
|
|
||||||
emitPositionEvent(x, canvas);
|
emitPositionEvent(evt);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseEnter = () => {
|
const handleMouseEnter = () => {
|
||||||
|
@ -142,10 +116,17 @@ export const SeekBar: React.FC<Props> = ({
|
||||||
|
|
||||||
// render component
|
// render component
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
width: '100%',
|
||||||
|
height: '30px',
|
||||||
|
margin: '0 auto',
|
||||||
|
cursor: cursor,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<canvas
|
<canvas
|
||||||
className={`w-full bg-gray-700 h-10 mx-0 my-auto ${cursor}`}
|
style={styles}
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
width={LogicalWidth}
|
width={LogicalWidth}
|
||||||
height={LogicalHeight}
|
height={LogicalHeight}
|
||||||
|
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { MediaSet, MediaSetServiceClientImpl } from './generated/media_set';
|
||||||
|
import { newRPC } from './App';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mediaSet: MediaSet;
|
||||||
|
position: VideoPosition;
|
||||||
|
duration: number;
|
||||||
|
height: number;
|
||||||
|
video: HTMLVideoElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VideoPreview: React.FC<Props> = ({
|
||||||
|
mediaSet,
|
||||||
|
position,
|
||||||
|
duration,
|
||||||
|
height,
|
||||||
|
video,
|
||||||
|
}: Props) => {
|
||||||
|
const videoCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
// effects
|
||||||
|
|
||||||
|
// render canvas
|
||||||
|
useEffect(() => {
|
||||||
|
// TODO: not sure if requestAnimationFrame is recommended here.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
(async function () {
|
||||||
|
const canvas = videoCanvasRef.current;
|
||||||
|
if (canvas == null) {
|
||||||
|
console.error('no canvas ref available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (ctx == null) {
|
||||||
|
console.error('no 2d context available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set aspect ratio.
|
||||||
|
canvas.width =
|
||||||
|
canvas.height * (canvas.clientWidth / canvas.clientHeight);
|
||||||
|
|
||||||
|
// If the required position is 0, display the thumbnail instead of
|
||||||
|
// trying to render the video. The most important use case is before a
|
||||||
|
// click event has happened, when autoplay restrictions will prevent
|
||||||
|
// the video being rendered to canvas.
|
||||||
|
if (position.currentTime == 0) {
|
||||||
|
const service = new MediaSetServiceClientImpl(newRPC());
|
||||||
|
const thumbnail = await service.GetVideoThumbnail({
|
||||||
|
id: mediaSet.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: avoid fetching the image every re-render:
|
||||||
|
const url = URL.createObjectURL(
|
||||||
|
new Blob([thumbnail.image], { type: 'image/jpeg' })
|
||||||
|
);
|
||||||
|
const img = new Image(thumbnail.width, thumbnail.height);
|
||||||
|
|
||||||
|
img.src = url;
|
||||||
|
img.onload = () => ctx.drawImage(img, 0, 0, 177, 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// otherwise, render the video, which (should) work now.
|
||||||
|
const durSecs = duration / 1000;
|
||||||
|
const ratio = position.currentTime / durSecs;
|
||||||
|
const x = (canvas.width - 177) * ratio;
|
||||||
|
ctx.clearRect(0, 0, x, canvas.height);
|
||||||
|
ctx.clearRect(x + 177, 0, canvas.width - 177 - x, canvas.height);
|
||||||
|
ctx.drawImage(video, x, 0, 177, 100);
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}, [mediaSet, position.currentTime]);
|
||||||
|
|
||||||
|
// render component
|
||||||
|
|
||||||
|
const containerStyles = {
|
||||||
|
height: height + 'px',
|
||||||
|
position: 'relative',
|
||||||
|
flexGrow: 0,
|
||||||
|
} as React.CSSProperties;
|
||||||
|
|
||||||
|
const canvasStyles = {
|
||||||
|
position: 'absolute',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'block',
|
||||||
|
zIndex: 1,
|
||||||
|
} as React.CSSProperties;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={containerStyles}>
|
||||||
|
<canvas
|
||||||
|
width="500"
|
||||||
|
height="100"
|
||||||
|
ref={videoCanvasRef}
|
||||||
|
style={canvasStyles}
|
||||||
|
></canvas>
|
||||||
|
<canvas style={canvasStyles}></canvas>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,161 @@
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { newRPC } from "./App";
|
||||||
|
import { MediaSetServiceClientImpl, MediaSet } from "./generated/media_set";
|
||||||
|
import { WaveformCanvas } from "./WaveformCanvas";
|
||||||
|
import { Selection, HudCanvas, EmptySelectionAction } from "./HudCanvas";
|
||||||
|
import { from, Observable } from "rxjs";
|
||||||
|
import { bufferCount } from "rxjs/operators";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mediaSet: MediaSet;
|
||||||
|
position: VideoPosition;
|
||||||
|
viewport: Frames;
|
||||||
|
offsetPixels: number;
|
||||||
|
onSelectionChange: (selection: Selection) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CanvasLogicalWidth = 2000;
|
||||||
|
export const CanvasLogicalHeight = 500;
|
||||||
|
|
||||||
|
export const Waveform: React.FC<Props> = ({
|
||||||
|
mediaSet,
|
||||||
|
position,
|
||||||
|
viewport,
|
||||||
|
offsetPixels,
|
||||||
|
onSelectionChange,
|
||||||
|
}: Props) => {
|
||||||
|
const [peaks, setPeaks] = useState<Observable<number[]>>(from([]));
|
||||||
|
const [selectedFrames, setSelectedFrames] = useState({ start: 0, end: 0 });
|
||||||
|
const [selectedPixels, setSelectedPixels] = useState({
|
||||||
|
start: 0,
|
||||||
|
end: 0,
|
||||||
|
});
|
||||||
|
const [positionPixels, setPositionPixels] = useState<number | null>(0);
|
||||||
|
|
||||||
|
// effects
|
||||||
|
|
||||||
|
// load peaks on MediaSet change
|
||||||
|
useEffect(() => {
|
||||||
|
(async function () {
|
||||||
|
if (mediaSet == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewport.start >= viewport.end) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("fetch audio segment, frames", viewport);
|
||||||
|
|
||||||
|
const service = new MediaSetServiceClientImpl(newRPC());
|
||||||
|
const segment = await service.GetPeaksForSegment({
|
||||||
|
id: mediaSet.id,
|
||||||
|
numBins: CanvasLogicalWidth,
|
||||||
|
startFrame: viewport.start,
|
||||||
|
endFrame: viewport.end,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("got segment", segment);
|
||||||
|
|
||||||
|
const peaks = from(segment.peaks).pipe(
|
||||||
|
bufferCount(mediaSet.audioChannels)
|
||||||
|
);
|
||||||
|
setPeaks(peaks);
|
||||||
|
})();
|
||||||
|
}, [viewport]);
|
||||||
|
|
||||||
|
// convert position to canvas pixels
|
||||||
|
useEffect(() => {
|
||||||
|
const frame = Math.round(position.currentTime * mediaSet.audioSampleRate);
|
||||||
|
if (frame < viewport.start || frame > viewport.end) {
|
||||||
|
setPositionPixels(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pixelsPerFrame = CanvasLogicalWidth / (viewport.end - viewport.start);
|
||||||
|
const positionPixels = (frame - viewport.start) * pixelsPerFrame;
|
||||||
|
setPositionPixels(positionPixels);
|
||||||
|
}, [mediaSet, position, viewport]);
|
||||||
|
|
||||||
|
// update selectedPixels on viewport change
|
||||||
|
useEffect(() => {
|
||||||
|
const start = Math.max(frameToCanvasX(selectedFrames.start), 0);
|
||||||
|
const end = Math.min(
|
||||||
|
frameToCanvasX(selectedFrames.end),
|
||||||
|
CanvasLogicalWidth
|
||||||
|
);
|
||||||
|
setSelectedPixels({ start, end });
|
||||||
|
}, [viewport, selectedFrames]);
|
||||||
|
|
||||||
|
// handlers
|
||||||
|
|
||||||
|
const handleSelectionChange = useCallback(
|
||||||
|
(selection: Selection) => {
|
||||||
|
setSelectedPixels(selection);
|
||||||
|
|
||||||
|
const framesPerPixel =
|
||||||
|
(viewport.end - viewport.start) / CanvasLogicalWidth;
|
||||||
|
const selectedFrames = {
|
||||||
|
start: Math.round(viewport.start + selection.start * framesPerPixel),
|
||||||
|
end: Math.round(viewport.start + selection.end * framesPerPixel),
|
||||||
|
};
|
||||||
|
|
||||||
|
setSelectedFrames(selectedFrames);
|
||||||
|
onSelectionChange(selectedFrames);
|
||||||
|
},
|
||||||
|
[viewport, selectedFrames]
|
||||||
|
);
|
||||||
|
|
||||||
|
// helpers
|
||||||
|
|
||||||
|
const frameToCanvasX = useCallback(
|
||||||
|
(frame: number): number => {
|
||||||
|
const pixelsPerFrame =
|
||||||
|
CanvasLogicalWidth / (viewport.end - viewport.start);
|
||||||
|
return Math.round((frame - viewport.start) * pixelsPerFrame);
|
||||||
|
},
|
||||||
|
[viewport]
|
||||||
|
);
|
||||||
|
|
||||||
|
// render component
|
||||||
|
|
||||||
|
const containerStyles = {
|
||||||
|
background: "black",
|
||||||
|
margin: "0 " + offsetPixels + "px",
|
||||||
|
flexGrow: 1,
|
||||||
|
position: "relative",
|
||||||
|
} as React.CSSProperties;
|
||||||
|
|
||||||
|
const hudStyles = {
|
||||||
|
borderLineWidth: 0,
|
||||||
|
borderStrokeStyle: "transparent",
|
||||||
|
positionLineWidth: 6,
|
||||||
|
positionStrokeStyle: "red",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={containerStyles}>
|
||||||
|
<WaveformCanvas
|
||||||
|
peaks={peaks}
|
||||||
|
channels={mediaSet.audioChannels}
|
||||||
|
width={CanvasLogicalWidth}
|
||||||
|
height={CanvasLogicalHeight}
|
||||||
|
strokeStyle="green"
|
||||||
|
fillStyle="black"
|
||||||
|
zIndex={0}
|
||||||
|
alpha={1}
|
||||||
|
></WaveformCanvas>
|
||||||
|
<HudCanvas
|
||||||
|
width={CanvasLogicalWidth}
|
||||||
|
height={CanvasLogicalHeight}
|
||||||
|
zIndex={1}
|
||||||
|
emptySelectionAction={EmptySelectionAction.SelectNothing}
|
||||||
|
styles={hudStyles}
|
||||||
|
position={positionPixels}
|
||||||
|
selection={selectedPixels}
|
||||||
|
onSelectionChange={handleSelectionChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -10,10 +10,18 @@ interface Props {
|
||||||
channels: number;
|
channels: number;
|
||||||
strokeStyle: string;
|
strokeStyle: string;
|
||||||
fillStyle: string;
|
fillStyle: string;
|
||||||
|
zIndex: number;
|
||||||
alpha: number;
|
alpha: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Canvas is a generic component that renders a waveform to a canvas.
|
// 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 WaveformCanvas: React.FC<Props> = React.memo((props: Props) => {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
@ -63,13 +71,21 @@ const WaveformCanvas: React.FC<Props> = React.memo((props: Props) => {
|
||||||
})();
|
})();
|
||||||
}, [props.peaks]);
|
}, [props.peaks]);
|
||||||
|
|
||||||
|
const canvasStyles = {
|
||||||
|
display: 'block',
|
||||||
|
position: 'absolute',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
zIndex: props.zIndex,
|
||||||
|
} as React.CSSProperties;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className={`block absolute w-full h-full z-10`}
|
|
||||||
width={props.width}
|
width={props.width}
|
||||||
height={props.height}
|
height={props.height}
|
||||||
|
style={canvasStyles}
|
||||||
></canvas>
|
></canvas>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { ChangeEventHandler, MouseEventHandler, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
const extractVideoIDFromURL = (input: string): string | null => {
|
||||||
|
const { searchParams } = new URL(input);
|
||||||
|
return searchParams.get("v");
|
||||||
|
};
|
||||||
|
|
||||||
|
function HomePage(): JSX.Element {
|
||||||
|
const [input, setInput] = useState<string>("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||||
|
setInput(event.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit: MouseEventHandler<HTMLButtonElement> = () => {
|
||||||
|
try {
|
||||||
|
const videoId = extractVideoIDFromURL(input);
|
||||||
|
if (videoId === null) {
|
||||||
|
setError("URL not valid, please enter a valid YouTube URL");
|
||||||
|
} else {
|
||||||
|
navigate(`/video/${videoId}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError("URL not valid, please enter a valid YouTube URL");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<input value={input} onChange={handleChange} />
|
||||||
|
<button onClick={handleSubmit}>Submit</button>
|
||||||
|
{Boolean(error) && <div>{error}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HomePage;
|
|
@ -0,0 +1,391 @@
|
||||||
|
import {
|
||||||
|
MediaSet,
|
||||||
|
MediaSetServiceClientImpl,
|
||||||
|
GetVideoProgress,
|
||||||
|
GetPeaksProgress,
|
||||||
|
} from '../generated/media_set';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { AudioFormat } from '../generated/media_set';
|
||||||
|
import { VideoPreview } from '../VideoPreview';
|
||||||
|
import { Overview, CanvasLogicalWidth } from '../Overview';
|
||||||
|
import { Waveform } from '../Waveform';
|
||||||
|
import { ControlBar } from '../ControlBar';
|
||||||
|
import { SeekBar } from '../SeekBar';
|
||||||
|
import { Duration } from '../generated/google/protobuf/duration';
|
||||||
|
import { firstValueFrom, from, Observable } from 'rxjs';
|
||||||
|
import { first, map } from 'rxjs/operators';
|
||||||
|
import { newRPC } from '../App';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
// ported from backend, where should they live?
|
||||||
|
const thumbnailWidth = 177;
|
||||||
|
const thumbnailHeight = 100;
|
||||||
|
|
||||||
|
const initialViewportCanvasPixels = 100;
|
||||||
|
|
||||||
|
// Frames represents a range of audio frames.
|
||||||
|
|
||||||
|
const video = document.createElement('video');
|
||||||
|
const audio = document.createElement('audio');
|
||||||
|
|
||||||
|
type VideoPageParams = {
|
||||||
|
videoId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VideoPage(): JSX.Element {
|
||||||
|
const [mediaSet, setMediaSet] = useState<MediaSet | null>(null);
|
||||||
|
const [viewport, setViewport] = useState<Frames>({ start: 0, end: 0 });
|
||||||
|
const [selection, setSelection] = useState<Frames>({ start: 0, end: 0 });
|
||||||
|
const [overviewPeaks, setOverviewPeaks] = useState<Observable<number[]>>(
|
||||||
|
from([])
|
||||||
|
);
|
||||||
|
const { videoId } = useParams<VideoPageParams>();
|
||||||
|
|
||||||
|
// position stores the current playback position. positionRef makes it
|
||||||
|
// available inside a setInterval callback.
|
||||||
|
const [position, setPosition] = useState({ currentTime: 0, percent: 0 });
|
||||||
|
const positionRef = useRef(position);
|
||||||
|
positionRef.current = position;
|
||||||
|
|
||||||
|
// effects
|
||||||
|
|
||||||
|
// TODO: error handling
|
||||||
|
|
||||||
|
if (videoId == null) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch mediaset on page load:
|
||||||
|
useEffect(() => {
|
||||||
|
(async function () {
|
||||||
|
const rpc = newRPC();
|
||||||
|
const service = new MediaSetServiceClientImpl(rpc);
|
||||||
|
const mediaSet = await service.Get({ youtubeId: videoId });
|
||||||
|
|
||||||
|
console.log('got media set:', mediaSet);
|
||||||
|
setMediaSet(mediaSet);
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updatePlayerPositionIntevalMillis = 30;
|
||||||
|
|
||||||
|
// setup player on first page load only:
|
||||||
|
useEffect(() => {
|
||||||
|
if (mediaSet == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervalID = setInterval(() => {
|
||||||
|
const currTime = audio.currentTime;
|
||||||
|
if (currTime == positionRef.current.currentTime) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const duration = mediaSet.audioFrames / mediaSet.audioSampleRate;
|
||||||
|
const percent = (currTime / duration) * 100;
|
||||||
|
|
||||||
|
// check if the end of selection has been passed, and pause if so:
|
||||||
|
if (
|
||||||
|
currentTimeToFrame(position.currentTime) < selection.end &&
|
||||||
|
currentTimeToFrame(currTime) >= selection.end
|
||||||
|
) {
|
||||||
|
handlePause();
|
||||||
|
}
|
||||||
|
|
||||||
|
// update the current position
|
||||||
|
setPosition({ currentTime: audio.currentTime, percent: percent });
|
||||||
|
}, updatePlayerPositionIntevalMillis);
|
||||||
|
|
||||||
|
return () => clearInterval(intervalID);
|
||||||
|
}, [mediaSet, selection]);
|
||||||
|
|
||||||
|
// bind to keypress handler.
|
||||||
|
// selection is a dependency of the handleKeyPress handler, and must be
|
||||||
|
// included here.
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener('keypress', handleKeyPress);
|
||||||
|
return () => document.removeEventListener('keypress', handleKeyPress);
|
||||||
|
}, [selection]);
|
||||||
|
|
||||||
|
// load audio when MediaSet is loaded:
|
||||||
|
useEffect(() => {
|
||||||
|
(async function () {
|
||||||
|
if (mediaSet == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('fetching audio...');
|
||||||
|
const service = new MediaSetServiceClientImpl(newRPC());
|
||||||
|
const audioProgressStream = service.GetPeaks({
|
||||||
|
id: mediaSet.id,
|
||||||
|
numBins: CanvasLogicalWidth,
|
||||||
|
});
|
||||||
|
const peaks = audioProgressStream.pipe(map((progress) => progress.peaks));
|
||||||
|
setOverviewPeaks(peaks);
|
||||||
|
|
||||||
|
const pipe = audioProgressStream.pipe(
|
||||||
|
first((progress: GetPeaksProgress) => progress.url != '')
|
||||||
|
);
|
||||||
|
const progressWithURL = await firstValueFrom(pipe);
|
||||||
|
|
||||||
|
audio.src = progressWithURL.url;
|
||||||
|
audio.muted = false;
|
||||||
|
audio.volume = 1;
|
||||||
|
console.log('set audio src', progressWithURL.url);
|
||||||
|
})();
|
||||||
|
}, [mediaSet]);
|
||||||
|
|
||||||
|
// load video when MediaSet is loaded:
|
||||||
|
useEffect(() => {
|
||||||
|
(async function () {
|
||||||
|
if (mediaSet == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('fetching video...');
|
||||||
|
const service = new MediaSetServiceClientImpl(newRPC());
|
||||||
|
const videoProgressStream = service.GetVideo({ id: mediaSet.id });
|
||||||
|
const pipe = videoProgressStream.pipe(
|
||||||
|
first((progress: GetVideoProgress) => progress.url != '')
|
||||||
|
);
|
||||||
|
const progressWithURL = await firstValueFrom(pipe);
|
||||||
|
|
||||||
|
video.src = progressWithURL.url;
|
||||||
|
console.log('set video src', progressWithURL.url);
|
||||||
|
})();
|
||||||
|
}, [mediaSet]);
|
||||||
|
|
||||||
|
// set viewport when MediaSet is loaded:
|
||||||
|
useEffect(() => {
|
||||||
|
if (mediaSet == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numFrames = Math.min(
|
||||||
|
Math.round(mediaSet.audioFrames / CanvasLogicalWidth) *
|
||||||
|
initialViewportCanvasPixels,
|
||||||
|
mediaSet.audioFrames
|
||||||
|
);
|
||||||
|
|
||||||
|
setViewport({ start: 0, end: numFrames });
|
||||||
|
}, [mediaSet]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.debug('viewport updated', viewport);
|
||||||
|
}, [viewport]);
|
||||||
|
|
||||||
|
// handlers
|
||||||
|
|
||||||
|
const handleKeyPress = useCallback(
|
||||||
|
(evt: KeyboardEvent) => {
|
||||||
|
if (evt.code != 'Space') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audio.paused) {
|
||||||
|
handlePlay();
|
||||||
|
} else {
|
||||||
|
handlePause();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selection]
|
||||||
|
);
|
||||||
|
|
||||||
|
// handler called when the selection in the overview (zoom setting) is changed.
|
||||||
|
const handleOverviewSelectionChange = useCallback(
|
||||||
|
(newViewport: Frames) => {
|
||||||
|
if (mediaSet == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('set new viewport', newViewport);
|
||||||
|
setViewport({ ...newViewport });
|
||||||
|
|
||||||
|
if (!audio.paused) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPositionFromFrame(newViewport.start);
|
||||||
|
},
|
||||||
|
[mediaSet, audio, video, selection]
|
||||||
|
);
|
||||||
|
|
||||||
|
// handler called when the selection in the main waveform view is changed.
|
||||||
|
const handleWaveformSelectionChange = useCallback(
|
||||||
|
(newSelection: Frames) => {
|
||||||
|
setSelection(newSelection);
|
||||||
|
|
||||||
|
if (mediaSet == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// move playback position to start of selection
|
||||||
|
const ratio = newSelection.start / mediaSet.audioFrames;
|
||||||
|
const currentTime =
|
||||||
|
(mediaSet.audioFrames / mediaSet.audioSampleRate) * ratio;
|
||||||
|
audio.currentTime = currentTime;
|
||||||
|
video.currentTime = currentTime;
|
||||||
|
},
|
||||||
|
[mediaSet, audio, video, selection]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePlay = useCallback(() => {
|
||||||
|
audio.play();
|
||||||
|
video.play();
|
||||||
|
}, [audio, video]);
|
||||||
|
|
||||||
|
const handlePause = useCallback(() => {
|
||||||
|
video.pause();
|
||||||
|
audio.pause();
|
||||||
|
|
||||||
|
if (selection.start != selection.end) {
|
||||||
|
setPositionFromFrame(selection.start);
|
||||||
|
}
|
||||||
|
}, [audio, video, selection]);
|
||||||
|
|
||||||
|
const handleClip = useCallback(() => {
|
||||||
|
(async function () {
|
||||||
|
console.debug('clip', selection);
|
||||||
|
|
||||||
|
if (mediaSet == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: support File System Access API fallback
|
||||||
|
const h = await window.showSaveFilePicker({ suggestedName: 'clip.mp3' });
|
||||||
|
const fileStream = await h.createWritable();
|
||||||
|
|
||||||
|
const rpc = newRPC();
|
||||||
|
const service = new MediaSetServiceClientImpl(rpc);
|
||||||
|
const stream = service.GetAudioSegment({
|
||||||
|
id: mediaSet.id,
|
||||||
|
format: AudioFormat.MP3,
|
||||||
|
startFrame: selection.start,
|
||||||
|
endFrame: selection.end,
|
||||||
|
});
|
||||||
|
|
||||||
|
await stream.forEach((p) => fileStream.write(p.audioData));
|
||||||
|
console.debug('finished writing stream');
|
||||||
|
|
||||||
|
await fileStream.close();
|
||||||
|
console.debug('closed stream');
|
||||||
|
})();
|
||||||
|
}, [mediaSet, selection]);
|
||||||
|
|
||||||
|
const setPositionFromFrame = useCallback(
|
||||||
|
(frame: number) => {
|
||||||
|
if (mediaSet == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ratio = frame / mediaSet.audioFrames;
|
||||||
|
const currentTime =
|
||||||
|
(mediaSet.audioFrames / mediaSet.audioSampleRate) * ratio;
|
||||||
|
audio.currentTime = currentTime;
|
||||||
|
video.currentTime = currentTime;
|
||||||
|
},
|
||||||
|
[mediaSet, audio, video]
|
||||||
|
);
|
||||||
|
|
||||||
|
// helpers
|
||||||
|
|
||||||
|
const currentTimeToFrame = useCallback(
|
||||||
|
(currentTime: number): number => {
|
||||||
|
if (mediaSet == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const dur = mediaSet.audioFrames / mediaSet.audioSampleRate;
|
||||||
|
const ratio = currentTime / dur;
|
||||||
|
return Math.round(mediaSet.audioFrames * ratio);
|
||||||
|
},
|
||||||
|
[mediaSet]
|
||||||
|
);
|
||||||
|
|
||||||
|
// render component
|
||||||
|
|
||||||
|
const containerStyles = {
|
||||||
|
border: '1px solid black',
|
||||||
|
width: '90%',
|
||||||
|
margin: '1em auto',
|
||||||
|
minHeight: '500px',
|
||||||
|
height: '700px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
} as React.CSSProperties;
|
||||||
|
|
||||||
|
const offsetPixels = Math.floor(thumbnailWidth / 2);
|
||||||
|
|
||||||
|
if (mediaSet == null) {
|
||||||
|
// TODO: improve
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="App">
|
||||||
|
<div style={containerStyles}>
|
||||||
|
<ControlBar
|
||||||
|
onPlay={handlePlay}
|
||||||
|
onPause={handlePause}
|
||||||
|
onClip={handleClip}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Overview
|
||||||
|
peaks={overviewPeaks}
|
||||||
|
mediaSet={mediaSet}
|
||||||
|
offsetPixels={offsetPixels}
|
||||||
|
height={80}
|
||||||
|
viewport={viewport}
|
||||||
|
position={position}
|
||||||
|
onSelectionChange={handleOverviewSelectionChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Waveform
|
||||||
|
mediaSet={mediaSet}
|
||||||
|
position={position}
|
||||||
|
viewport={viewport}
|
||||||
|
offsetPixels={offsetPixels}
|
||||||
|
onSelectionChange={handleWaveformSelectionChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SeekBar
|
||||||
|
position={video.currentTime}
|
||||||
|
duration={mediaSet.audioFrames / mediaSet.audioSampleRate}
|
||||||
|
offsetPixels={offsetPixels}
|
||||||
|
onPositionChanged={(position: number) => {
|
||||||
|
video.currentTime = position;
|
||||||
|
audio.currentTime = position;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<VideoPreview
|
||||||
|
mediaSet={mediaSet}
|
||||||
|
video={video}
|
||||||
|
position={position}
|
||||||
|
duration={millisFromDuration(mediaSet.videoDuration)}
|
||||||
|
height={thumbnailHeight}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ul style={{ listStyleType: 'none' } as React.CSSProperties}>
|
||||||
|
<li>Frames: {mediaSet.audioFrames}</li>
|
||||||
|
<li>
|
||||||
|
Viewport (frames): {viewport.start} to {viewport.end}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Selection (frames): {selection.start} to {selection.end}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Position (frames):{' '}
|
||||||
|
{Math.round(mediaSet.audioFrames * (position.percent / 100))}
|
||||||
|
</li>
|
||||||
|
<li>Position (seconds): {position.currentTime}</li>
|
||||||
|
<li></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VideoPage;
|
||||||
|
|
||||||
|
function millisFromDuration(dur?: Duration): number {
|
||||||
|
if (dur == undefined) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.floor(dur.seconds * 1000.0 + dur.nanos / 1000.0 / 1000.0);
|
||||||
|
}
|
|
@ -82,9 +82,7 @@ export interface Duration {
|
||||||
nanos: number;
|
nanos: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createBaseDuration(): Duration {
|
const baseDuration: object = { seconds: 0, nanos: 0 };
|
||||||
return { seconds: 0, nanos: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Duration = {
|
export const Duration = {
|
||||||
encode(
|
encode(
|
||||||
|
@ -103,7 +101,7 @@ export const Duration = {
|
||||||
decode(input: _m0.Reader | Uint8Array, length?: number): Duration {
|
decode(input: _m0.Reader | Uint8Array, length?: number): Duration {
|
||||||
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
|
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
|
||||||
let end = length === undefined ? reader.len : reader.pos + length;
|
let end = length === undefined ? reader.len : reader.pos + length;
|
||||||
const message = createBaseDuration();
|
const message = { ...baseDuration } as Duration;
|
||||||
while (reader.pos < end) {
|
while (reader.pos < end) {
|
||||||
const tag = reader.uint32();
|
const tag = reader.uint32();
|
||||||
switch (tag >>> 3) {
|
switch (tag >>> 3) {
|
||||||
|
@ -122,22 +120,27 @@ export const Duration = {
|
||||||
},
|
},
|
||||||
|
|
||||||
fromJSON(object: any): Duration {
|
fromJSON(object: any): Duration {
|
||||||
return {
|
const message = { ...baseDuration } as Duration;
|
||||||
seconds: isSet(object.seconds) ? Number(object.seconds) : 0,
|
message.seconds =
|
||||||
nanos: isSet(object.nanos) ? Number(object.nanos) : 0,
|
object.seconds !== undefined && object.seconds !== null
|
||||||
};
|
? Number(object.seconds)
|
||||||
|
: 0;
|
||||||
|
message.nanos =
|
||||||
|
object.nanos !== undefined && object.nanos !== null
|
||||||
|
? Number(object.nanos)
|
||||||
|
: 0;
|
||||||
|
return message;
|
||||||
},
|
},
|
||||||
|
|
||||||
toJSON(message: Duration): unknown {
|
toJSON(message: Duration): unknown {
|
||||||
const obj: any = {};
|
const obj: any = {};
|
||||||
message.seconds !== undefined &&
|
message.seconds !== undefined && (obj.seconds = message.seconds);
|
||||||
(obj.seconds = Math.round(message.seconds));
|
message.nanos !== undefined && (obj.nanos = message.nanos);
|
||||||
message.nanos !== undefined && (obj.nanos = Math.round(message.nanos));
|
|
||||||
return obj;
|
return obj;
|
||||||
},
|
},
|
||||||
|
|
||||||
fromPartial<I extends Exact<DeepPartial<Duration>, I>>(object: I): Duration {
|
fromPartial<I extends Exact<DeepPartial<Duration>, I>>(object: I): Duration {
|
||||||
const message = createBaseDuration();
|
const message = { ...baseDuration } as Duration;
|
||||||
message.seconds = object.seconds ?? 0;
|
message.seconds = object.seconds ?? 0;
|
||||||
message.nanos = object.nanos ?? 0;
|
message.nanos = object.nanos ?? 0;
|
||||||
return message;
|
return message;
|
||||||
|
@ -193,7 +196,3 @@ if (_m0.util.Long !== Long) {
|
||||||
_m0.util.Long = Long as any;
|
_m0.util.Long = Long as any;
|
||||||
_m0.configure();
|
_m0.configure();
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSet(value: any): boolean {
|
|
||||||
return value !== null && value !== undefined;
|
|
||||||
}
|
|
||||||
|
|
|
@ -44,9 +44,6 @@ export function audioFormatToJSON(object: AudioFormat): string {
|
||||||
export interface MediaSet {
|
export interface MediaSet {
|
||||||
id: string;
|
id: string;
|
||||||
youtubeId: string;
|
youtubeId: string;
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
author: string;
|
|
||||||
audioChannels: number;
|
audioChannels: number;
|
||||||
audioApproxFrames: number;
|
audioApproxFrames: number;
|
||||||
audioFrames: number;
|
audioFrames: number;
|
||||||
|
@ -71,7 +68,6 @@ export interface GetPeaksProgress {
|
||||||
peaks: number[];
|
peaks: number[];
|
||||||
percentComplete: number;
|
percentComplete: number;
|
||||||
url: string;
|
url: string;
|
||||||
audioFrames: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetPeaksForSegmentRequest {
|
export interface GetPeaksForSegmentRequest {
|
||||||
|
@ -93,6 +89,8 @@ export interface GetAudioSegmentRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetAudioSegmentProgress {
|
export interface GetAudioSegmentProgress {
|
||||||
|
mimeType: string;
|
||||||
|
message: string;
|
||||||
percentComplete: number;
|
percentComplete: number;
|
||||||
audioData: Uint8Array;
|
audioData: Uint8Array;
|
||||||
}
|
}
|
||||||
|
@ -116,24 +114,18 @@ export interface GetVideoThumbnailResponse {
|
||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createBaseMediaSet(): MediaSet {
|
const baseMediaSet: object = {
|
||||||
return {
|
id: "",
|
||||||
id: "",
|
youtubeId: "",
|
||||||
youtubeId: "",
|
audioChannels: 0,
|
||||||
title: "",
|
audioApproxFrames: 0,
|
||||||
description: "",
|
audioFrames: 0,
|
||||||
author: "",
|
audioSampleRate: 0,
|
||||||
audioChannels: 0,
|
audioYoutubeItag: 0,
|
||||||
audioApproxFrames: 0,
|
audioMimeType: "",
|
||||||
audioFrames: 0,
|
videoYoutubeItag: 0,
|
||||||
audioSampleRate: 0,
|
videoMimeType: "",
|
||||||
audioYoutubeItag: 0,
|
};
|
||||||
audioMimeType: "",
|
|
||||||
videoDuration: undefined,
|
|
||||||
videoYoutubeItag: 0,
|
|
||||||
videoMimeType: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MediaSet = {
|
export const MediaSet = {
|
||||||
encode(
|
encode(
|
||||||
|
@ -146,15 +138,6 @@ export const MediaSet = {
|
||||||
if (message.youtubeId !== "") {
|
if (message.youtubeId !== "") {
|
||||||
writer.uint32(18).string(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) {
|
if (message.audioChannels !== 0) {
|
||||||
writer.uint32(24).int32(message.audioChannels);
|
writer.uint32(24).int32(message.audioChannels);
|
||||||
}
|
}
|
||||||
|
@ -188,7 +171,7 @@ export const MediaSet = {
|
||||||
decode(input: _m0.Reader | Uint8Array, length?: number): MediaSet {
|
decode(input: _m0.Reader | Uint8Array, length?: number): MediaSet {
|
||||||
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
|
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
|
||||||
let end = length === undefined ? reader.len : reader.pos + length;
|
let end = length === undefined ? reader.len : reader.pos + length;
|
||||||
const message = createBaseMediaSet();
|
const message = { ...baseMediaSet } as MediaSet;
|
||||||
while (reader.pos < end) {
|
while (reader.pos < end) {
|
||||||
const tag = reader.uint32();
|
const tag = reader.uint32();
|
||||||
switch (tag >>> 3) {
|
switch (tag >>> 3) {
|
||||||
|
@ -198,15 +181,6 @@ export const MediaSet = {
|
||||||
case 2:
|
case 2:
|
||||||
message.youtubeId = reader.string();
|
message.youtubeId = reader.string();
|
||||||
break;
|
break;
|
||||||
case 12:
|
|
||||||
message.title = reader.string();
|
|
||||||
break;
|
|
||||||
case 13:
|
|
||||||
message.description = reader.string();
|
|
||||||
break;
|
|
||||||
case 14:
|
|
||||||
message.author = reader.string();
|
|
||||||
break;
|
|
||||||
case 3:
|
case 3:
|
||||||
message.audioChannels = reader.int32();
|
message.audioChannels = reader.int32();
|
||||||
break;
|
break;
|
||||||
|
@ -243,58 +217,67 @@ export const MediaSet = {
|
||||||
},
|
},
|
||||||
|
|
||||||
fromJSON(object: any): MediaSet {
|
fromJSON(object: any): MediaSet {
|
||||||
return {
|
const message = { ...baseMediaSet } as MediaSet;
|
||||||
id: isSet(object.id) ? String(object.id) : "",
|
message.id =
|
||||||
youtubeId: isSet(object.youtubeId) ? String(object.youtubeId) : "",
|
object.id !== undefined && object.id !== null ? String(object.id) : "";
|
||||||
title: isSet(object.title) ? String(object.title) : "",
|
message.youtubeId =
|
||||||
description: isSet(object.description) ? String(object.description) : "",
|
object.youtubeId !== undefined && object.youtubeId !== null
|
||||||
author: isSet(object.author) ? String(object.author) : "",
|
? String(object.youtubeId)
|
||||||
audioChannels: isSet(object.audioChannels)
|
: "";
|
||||||
|
message.audioChannels =
|
||||||
|
object.audioChannels !== undefined && object.audioChannels !== null
|
||||||
? Number(object.audioChannels)
|
? Number(object.audioChannels)
|
||||||
: 0,
|
: 0;
|
||||||
audioApproxFrames: isSet(object.audioApproxFrames)
|
message.audioApproxFrames =
|
||||||
|
object.audioApproxFrames !== undefined &&
|
||||||
|
object.audioApproxFrames !== null
|
||||||
? Number(object.audioApproxFrames)
|
? Number(object.audioApproxFrames)
|
||||||
: 0,
|
: 0;
|
||||||
audioFrames: isSet(object.audioFrames) ? Number(object.audioFrames) : 0,
|
message.audioFrames =
|
||||||
audioSampleRate: isSet(object.audioSampleRate)
|
object.audioFrames !== undefined && object.audioFrames !== null
|
||||||
|
? Number(object.audioFrames)
|
||||||
|
: 0;
|
||||||
|
message.audioSampleRate =
|
||||||
|
object.audioSampleRate !== undefined && object.audioSampleRate !== null
|
||||||
? Number(object.audioSampleRate)
|
? Number(object.audioSampleRate)
|
||||||
: 0,
|
: 0;
|
||||||
audioYoutubeItag: isSet(object.audioYoutubeItag)
|
message.audioYoutubeItag =
|
||||||
|
object.audioYoutubeItag !== undefined && object.audioYoutubeItag !== null
|
||||||
? Number(object.audioYoutubeItag)
|
? Number(object.audioYoutubeItag)
|
||||||
: 0,
|
: 0;
|
||||||
audioMimeType: isSet(object.audioMimeType)
|
message.audioMimeType =
|
||||||
|
object.audioMimeType !== undefined && object.audioMimeType !== null
|
||||||
? String(object.audioMimeType)
|
? String(object.audioMimeType)
|
||||||
: "",
|
: "";
|
||||||
videoDuration: isSet(object.videoDuration)
|
message.videoDuration =
|
||||||
|
object.videoDuration !== undefined && object.videoDuration !== null
|
||||||
? Duration.fromJSON(object.videoDuration)
|
? Duration.fromJSON(object.videoDuration)
|
||||||
: undefined,
|
: undefined;
|
||||||
videoYoutubeItag: isSet(object.videoYoutubeItag)
|
message.videoYoutubeItag =
|
||||||
|
object.videoYoutubeItag !== undefined && object.videoYoutubeItag !== null
|
||||||
? Number(object.videoYoutubeItag)
|
? Number(object.videoYoutubeItag)
|
||||||
: 0,
|
: 0;
|
||||||
videoMimeType: isSet(object.videoMimeType)
|
message.videoMimeType =
|
||||||
|
object.videoMimeType !== undefined && object.videoMimeType !== null
|
||||||
? String(object.videoMimeType)
|
? String(object.videoMimeType)
|
||||||
: "",
|
: "";
|
||||||
};
|
return message;
|
||||||
},
|
},
|
||||||
|
|
||||||
toJSON(message: MediaSet): unknown {
|
toJSON(message: MediaSet): unknown {
|
||||||
const obj: any = {};
|
const obj: any = {};
|
||||||
message.id !== undefined && (obj.id = message.id);
|
message.id !== undefined && (obj.id = message.id);
|
||||||
message.youtubeId !== undefined && (obj.youtubeId = message.youtubeId);
|
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 &&
|
message.audioChannels !== undefined &&
|
||||||
(obj.audioChannels = Math.round(message.audioChannels));
|
(obj.audioChannels = message.audioChannels);
|
||||||
message.audioApproxFrames !== undefined &&
|
message.audioApproxFrames !== undefined &&
|
||||||
(obj.audioApproxFrames = Math.round(message.audioApproxFrames));
|
(obj.audioApproxFrames = message.audioApproxFrames);
|
||||||
message.audioFrames !== undefined &&
|
message.audioFrames !== undefined &&
|
||||||
(obj.audioFrames = Math.round(message.audioFrames));
|
(obj.audioFrames = message.audioFrames);
|
||||||
message.audioSampleRate !== undefined &&
|
message.audioSampleRate !== undefined &&
|
||||||
(obj.audioSampleRate = Math.round(message.audioSampleRate));
|
(obj.audioSampleRate = message.audioSampleRate);
|
||||||
message.audioYoutubeItag !== undefined &&
|
message.audioYoutubeItag !== undefined &&
|
||||||
(obj.audioYoutubeItag = Math.round(message.audioYoutubeItag));
|
(obj.audioYoutubeItag = message.audioYoutubeItag);
|
||||||
message.audioMimeType !== undefined &&
|
message.audioMimeType !== undefined &&
|
||||||
(obj.audioMimeType = message.audioMimeType);
|
(obj.audioMimeType = message.audioMimeType);
|
||||||
message.videoDuration !== undefined &&
|
message.videoDuration !== undefined &&
|
||||||
|
@ -302,19 +285,16 @@ export const MediaSet = {
|
||||||
? Duration.toJSON(message.videoDuration)
|
? Duration.toJSON(message.videoDuration)
|
||||||
: undefined);
|
: undefined);
|
||||||
message.videoYoutubeItag !== undefined &&
|
message.videoYoutubeItag !== undefined &&
|
||||||
(obj.videoYoutubeItag = Math.round(message.videoYoutubeItag));
|
(obj.videoYoutubeItag = message.videoYoutubeItag);
|
||||||
message.videoMimeType !== undefined &&
|
message.videoMimeType !== undefined &&
|
||||||
(obj.videoMimeType = message.videoMimeType);
|
(obj.videoMimeType = message.videoMimeType);
|
||||||
return obj;
|
return obj;
|
||||||
},
|
},
|
||||||
|
|
||||||
fromPartial<I extends Exact<DeepPartial<MediaSet>, I>>(object: I): MediaSet {
|
fromPartial<I extends Exact<DeepPartial<MediaSet>, I>>(object: I): MediaSet {
|
||||||
const message = createBaseMediaSet();
|
const message = { ...baseMediaSet } as MediaSet;
|
||||||
message.id = object.id ?? "";
|
message.id = object.id ?? "";
|
||||||
message.youtubeId = object.youtubeId ?? "";
|
message.youtubeId = object.youtubeId ?? "";
|
||||||
message.title = object.title ?? "";
|
|
||||||
message.description = object.description ?? "";
|
|
||||||
message.author = object.author ?? "";
|
|
||||||
message.audioChannels = object.audioChannels ?? 0;
|
message.audioChannels = object.audioChannels ?? 0;
|
||||||
message.audioApproxFrames = object.audioApproxFrames ?? 0;
|
message.audioApproxFrames = object.audioApproxFrames ?? 0;
|
||||||
message.audioFrames = object.audioFrames ?? 0;
|
message.audioFrames = object.audioFrames ?? 0;
|
||||||
|
@ -331,9 +311,7 @@ export const MediaSet = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function createBaseGetRequest(): GetRequest {
|
const baseGetRequest: object = { youtubeId: "" };
|
||||||
return { youtubeId: "" };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GetRequest = {
|
export const GetRequest = {
|
||||||
encode(
|
encode(
|
||||||
|
@ -349,7 +327,7 @@ export const GetRequest = {
|
||||||
decode(input: _m0.Reader | Uint8Array, length?: number): GetRequest {
|
decode(input: _m0.Reader | Uint8Array, length?: number): GetRequest {
|
||||||
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
|
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
|
||||||
let end = length === undefined ? reader.len : reader.pos + length;
|
let end = length === undefined ? reader.len : reader.pos + length;
|
||||||
const message = createBaseGetRequest();
|
const message = { ...baseGetRequest } as GetRequest;
|
||||||
while (reader.pos < end) {
|
while (reader.pos < end) {
|
||||||
const tag = reader.uint32();
|
const tag = reader.uint32();
|
||||||
switch (tag >>> 3) {
|
switch (tag >>> 3) {
|
||||||
|
@ -365,9 +343,12 @@ export const GetRequest = {
|
||||||
},
|
},
|
||||||
|
|
||||||
fromJSON(object: any): GetRequest {
|
fromJSON(object: any): GetRequest {
|
||||||
return {
|
const message = { ...baseGetRequest } as GetRequest;
|
||||||
youtubeId: isSet(object.youtubeId) ? String(object.youtubeId) : "",
|
message.youtubeId =
|
||||||
};
|
object.youtubeId !== undefined && object.youtubeId !== null
|
||||||
|
? String(object.youtubeId)
|
||||||
|
: "";
|
||||||
|
return message;
|
||||||
},
|
},
|
||||||
|
|
||||||
toJSON(message: GetRequest): unknown {
|
toJSON(message: GetRequest): unknown {
|
||||||
|
@ -379,15 +360,13 @@ export const GetRequest = {
|
||||||
fromPartial<I extends Exact<DeepPartial<GetRequest>, I>>(
|
fromPartial<I extends Exact<DeepPartial<GetRequest>, I>>(
|
||||||
object: I
|
object: I
|
||||||
): GetRequest {
|
): GetRequest {
|
||||||
const message = createBaseGetRequest();
|
const message = { ...baseGetRequest } as GetRequest;
|
||||||
message.youtubeId = object.youtubeId ?? "";
|
message.youtubeId = object.youtubeId ?? "";
|
||||||
return message;
|
return message;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function createBaseGetPeaksRequest(): GetPeaksRequest {
|
const baseGetPeaksRequest: object = { id: "", numBins: 0 };
|
||||||
return { id: "", numBins: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GetPeaksRequest = {
|
export const GetPeaksRequest = {
|
||||||
encode(
|
encode(
|
||||||
|
@ -406,7 +385,7 @@ export const GetPeaksRequest = {
|
||||||
decode(input: _m0.Reader | Uint8Array, length?: number): GetPeaksRequest {
|
decode(input: _m0.Reader | Uint8Array, length?: number): GetPeaksRequest {
|
||||||
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
|
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
|
||||||
let end = length === undefined ? reader.len : reader.pos + length;
|
let end = length === undefined ? reader.len : reader.pos + length;
|
||||||
const message = createBaseGetPeaksRequest();
|
const message = { ...baseGetPeaksRequest } as GetPeaksRequest;
|
||||||
while (reader.pos < end) {
|
while (reader.pos < end) {
|
||||||
const tag = reader.uint32();
|
const tag = reader.uint32();
|
||||||
switch (tag >>> 3) {
|
switch (tag >>> 3) {
|
||||||
|
@ -425,33 +404,34 @@ export const GetPeaksRequest = {
|
||||||
},
|
},
|
||||||
|
|
||||||
fromJSON(object: any): GetPeaksRequest {
|
fromJSON(object: any): GetPeaksRequest {
|
||||||
return {
|
const message = { ...baseGetPeaksRequest } as GetPeaksRequest;
|
||||||
id: isSet(object.id) ? String(object.id) : "",
|
message.id =
|
||||||
numBins: isSet(object.numBins) ? Number(object.numBins) : 0,
|
object.id !== undefined && object.id !== null ? String(object.id) : "";
|
||||||
};
|
message.numBins =
|
||||||
|
object.numBins !== undefined && object.numBins !== null
|
||||||
|
? Number(object.numBins)
|
||||||
|
: 0;
|
||||||
|
return message;
|
||||||
},
|
},
|
||||||
|
|
||||||
toJSON(message: GetPeaksRequest): unknown {
|
toJSON(message: GetPeaksRequest): unknown {
|
||||||
const obj: any = {};
|
const obj: any = {};
|
||||||
message.id !== undefined && (obj.id = message.id);
|
message.id !== undefined && (obj.id = message.id);
|
||||||
message.numBins !== undefined &&
|
message.numBins !== undefined && (obj.numBins = message.numBins);
|
||||||
(obj.numBins = Math.round(message.numBins));
|
|
||||||
return obj;
|
return obj;
|
||||||
},
|
},
|
||||||
|
|
||||||
fromPartial<I extends Exact<DeepPartial<GetPeaksRequest>, I>>(
|
fromPartial<I extends Exact<DeepPartial<GetPeaksRequest>, I>>(
|
||||||
object: I
|
object: I
|
||||||
): GetPeaksRequest {
|
): GetPeaksRequest {
|
||||||
const message = createBaseGetPeaksRequest();
|
const message = { ...baseGetPeaksRequest } as GetPeaksRequest;
|
||||||
message.id = object.id ?? "";
|
message.id = object.id ?? "";
|
||||||
message.numBins = object.numBins ?? 0;
|
message.numBins = object.numBins ?? 0;
|
||||||
return message;
|
return message;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function createBaseGetPeaksProgress(): GetPeaksProgress {
|
const baseGetPeaksProgress: object = { peaks: 0, percentComplete: 0, url: "" };
|
||||||
return { peaks: [], percentComplete: 0, url: "", audioFrames: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GetPeaksProgress = {
|
export const GetPeaksProgress = {
|
||||||
encode(
|
encode(
|
||||||
|
@ -469,16 +449,14 @@ export const GetPeaksProgress = {
|
||||||
if (message.url !== "") {
|
if (message.url !== "") {
|
||||||
writer.uint32(26).string(message.url);
|
writer.uint32(26).string(message.url);
|
||||||
}
|
}
|
||||||
if (message.audioFrames !== 0) {
|
|
||||||
writer.uint32(32).int64(message.audioFrames);
|
|
||||||
}
|
|
||||||
return writer;
|
return writer;
|
||||||
},
|
},
|
||||||
|
|
||||||
decode(input: _m0.Reader | Uint8Array, length?: number): GetPeaksProgress {
|
decode(input: _m0.Reader | Uint8Array, length?: number): GetPeaksProgress {
|
||||||
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
|
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
|
||||||
let end = length === undefined ? reader.len : reader.pos + length;
|
let end = length === undefined ? reader.len : reader.pos + length;
|
||||||
const message = createBaseGetPeaksProgress();
|
const message = { ...baseGetPeaksProgress } as GetPeaksProgress;
|
||||||
|
message.peaks = [];
|
||||||
while (reader.pos < end) {
|
while (reader.pos < end) {
|
||||||
const tag = reader.uint32();
|
const tag = reader.uint32();
|
||||||
switch (tag >>> 3) {
|
switch (tag >>> 3) {
|
||||||
|
@ -498,9 +476,6 @@ export const GetPeaksProgress = {
|
||||||
case 3:
|
case 3:
|
||||||
message.url = reader.string();
|
message.url = reader.string();
|
||||||
break;
|
break;
|
||||||
case 4:
|
|
||||||
message.audioFrames = longToNumber(reader.int64() as Long);
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
reader.skipType(tag & 7);
|
reader.skipType(tag & 7);
|
||||||
break;
|
break;
|
||||||
|
@ -510,48 +485,47 @@ export const GetPeaksProgress = {
|
||||||
},
|
},
|
||||||
|
|
||||||
fromJSON(object: any): GetPeaksProgress {
|
fromJSON(object: any): GetPeaksProgress {
|
||||||
return {
|
const message = { ...baseGetPeaksProgress } as GetPeaksProgress;
|
||||||
peaks: Array.isArray(object?.peaks)
|
message.peaks = (object.peaks ?? []).map((e: any) => Number(e));
|
||||||
? object.peaks.map((e: any) => Number(e))
|
message.percentComplete =
|
||||||
: [],
|
object.percentComplete !== undefined && object.percentComplete !== null
|
||||||
percentComplete: isSet(object.percentComplete)
|
|
||||||
? Number(object.percentComplete)
|
? Number(object.percentComplete)
|
||||||
: 0,
|
: 0;
|
||||||
url: isSet(object.url) ? String(object.url) : "",
|
message.url =
|
||||||
audioFrames: isSet(object.audioFrames) ? Number(object.audioFrames) : 0,
|
object.url !== undefined && object.url !== null ? String(object.url) : "";
|
||||||
};
|
return message;
|
||||||
},
|
},
|
||||||
|
|
||||||
toJSON(message: GetPeaksProgress): unknown {
|
toJSON(message: GetPeaksProgress): unknown {
|
||||||
const obj: any = {};
|
const obj: any = {};
|
||||||
if (message.peaks) {
|
if (message.peaks) {
|
||||||
obj.peaks = message.peaks.map((e) => Math.round(e));
|
obj.peaks = message.peaks.map((e) => e);
|
||||||
} else {
|
} else {
|
||||||
obj.peaks = [];
|
obj.peaks = [];
|
||||||
}
|
}
|
||||||
message.percentComplete !== undefined &&
|
message.percentComplete !== undefined &&
|
||||||
(obj.percentComplete = message.percentComplete);
|
(obj.percentComplete = message.percentComplete);
|
||||||
message.url !== undefined && (obj.url = message.url);
|
message.url !== undefined && (obj.url = message.url);
|
||||||
message.audioFrames !== undefined &&
|
|
||||||
(obj.audioFrames = Math.round(message.audioFrames));
|
|
||||||
return obj;
|
return obj;
|
||||||
},
|
},
|
||||||
|
|
||||||
fromPartial<I extends Exact<DeepPartial<GetPeaksProgress>, I>>(
|
fromPartial<I extends Exact<DeepPartial<GetPeaksProgress>, I>>(
|
||||||
object: I
|
object: I
|
||||||
): GetPeaksProgress {
|
): GetPeaksProgress {
|
||||||
const message = createBaseGetPeaksProgress();
|
const message = { ...baseGetPeaksProgress } as GetPeaksProgress;
|
||||||
message.peaks = object.peaks?.map((e) => e) || [];
|
message.peaks = object.peaks?.map((e) => e) || [];
|
||||||
message.percentComplete = object.percentComplete ?? 0;
|
message.percentComplete = object.percentComplete ?? 0;
|
||||||
message.url = object.url ?? "";
|
message.url = object.url ?? "";
|
||||||
message.audioFrames = object.audioFrames ?? 0;
|
|
||||||
return message;
|
return message;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function createBaseGetPeaksForSegmentRequest(): GetPeaksForSegmentRequest {
|
const baseGetPeaksForSegmentRequest: object = {
|
||||||
return { id: "", numBins: 0, startFrame: 0, endFrame: 0 };
|
id: "",
|
||||||
}
|
numBins: 0,
|
||||||
|
startFrame: 0,
|
||||||
|
endFrame: 0,
|
||||||
|
};
|
||||||
|
|
||||||
export const GetPeaksForSegmentRequest = {
|
export const GetPeaksForSegmentRequest = {
|
||||||
encode(
|
encode(
|
||||||
|
@ -579,7 +553,9 @@ export const GetPeaksForSegmentRequest = {
|
||||||
): GetPeaksForSegmentRequest {
|
): GetPeaksForSegmentRequest {
|
||||||
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
|
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
|
||||||
let end = length === undefined ? reader.len : reader.pos + length;
|
let end = length === undefined ? reader.len : reader.pos + length;
|
||||||
const message = createBaseGetPeaksForSegmentRequest();
|
const message = {
|
||||||
|
...baseGetPeaksForSegmentRequest,
|
||||||
|
} as GetPeaksForSegmentRequest;
|
||||||
while (reader.pos < end) {
|
while (reader.pos < end) {
|
||||||
const tag = reader.uint32();
|
const tag = reader.uint32();
|
||||||
switch (tag >>> 3) {
|
switch (tag >>> 3) {
|
||||||
|
@ -604,30 +580,41 @@ export const GetPeaksForSegmentRequest = {
|
||||||
},
|
},
|
||||||
|
|
||||||
fromJSON(object: any): GetPeaksForSegmentRequest {
|
fromJSON(object: any): GetPeaksForSegmentRequest {
|
||||||
return {
|
const message = {
|
||||||
id: isSet(object.id) ? String(object.id) : "",
|
...baseGetPeaksForSegmentRequest,
|
||||||
numBins: isSet(object.numBins) ? Number(object.numBins) : 0,
|
} as GetPeaksForSegmentRequest;
|
||||||
startFrame: isSet(object.startFrame) ? Number(object.startFrame) : 0,
|
message.id =
|
||||||
endFrame: isSet(object.endFrame) ? Number(object.endFrame) : 0,
|
object.id !== undefined && object.id !== null ? String(object.id) : "";
|
||||||
};
|
message.numBins =
|
||||||
|
object.numBins !== undefined && object.numBins !== null
|
||||||
|
? Number(object.numBins)
|
||||||
|
: 0;
|
||||||
|
message.startFrame =
|
||||||
|
object.startFrame !== undefined && object.startFrame !== null
|
||||||
|
? Number(object.startFrame)
|
||||||
|
: 0;
|
||||||
|
message.endFrame =
|
||||||
|
object.endFrame !== undefined && object.endFrame !== null
|
||||||
|
? Number(object.endFrame)
|
||||||
|
: 0;
|
||||||
|
return message;
|
||||||
},
|
},
|
||||||
|
|
||||||
toJSON(message: GetPeaksForSegmentRequest): unknown {
|
toJSON(message: GetPeaksForSegmentRequest): unknown {
|
||||||
const obj: any = {};
|
const obj: any = {};
|
||||||
message.id !== undefined && (obj.id = message.id);
|
message.id !== undefined && (obj.id = message.id);
|
||||||
message.numBins !== undefined &&
|
message.numBins !== undefined && (obj.numBins = message.numBins);
|
||||||
(obj.numBins = Math.round(message.numBins));
|
message.startFrame !== undefined && (obj.startFrame = message.startFrame);
|
||||||
message.startFrame !== undefined &&
|
message.endFrame !== undefined && (obj.endFrame = message.endFrame);
|
||||||
(obj.startFrame = Math.round(message.startFrame));
|
|
||||||
message.endFrame !== undefined &&
|
|
||||||
(obj.endFrame = Math.round(message.endFrame));
|
|
||||||
return obj;
|
return obj;
|
||||||
},
|
},
|
||||||
|
|
||||||
fromPartial<I extends Exact<DeepPartial<GetPeaksForSegmentRequest>, I>>(
|
fromPartial<I extends Exact<DeepPartial<GetPeaksForSegmentRequest>, I>>(
|
||||||
object: I
|
object: I
|
||||||
): GetPeaksForSegmentRequest {
|
): GetPeaksForSegmentRequest {
|
||||||
const message = createBaseGetPeaksForSegmentRequest();
|
const message = {
|
||||||
|
...baseGetPeaksForSegmentRequest,
|
||||||
|
} as GetPeaksForSegmentRequest;
|
||||||
message.id = object.id ?? "";
|
message.id = object.id ?? "";
|
||||||
message.numBins = object.numBins ?? 0;
|
message.numBins = object.numBins ?? 0;
|
||||||
message.startFrame = object.startFrame ?? 0;
|
message.startFrame = object.startFrame ?? 0;
|
||||||
|
@ -636,9 +623,7 @@ export const GetPeaksForSegmentRequest = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function createBaseGetPeaksForSegmentResponse(): GetPeaksForSegmentResponse {
|
const baseGetPeaksForSegmentResponse: object = { peaks: 0 };
|
||||||
return { peaks: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GetPeaksForSegmentResponse = {
|
export const GetPeaksForSegmentResponse = {
|
||||||
encode(
|
encode(
|
||||||
|
@ -659,7 +644,10 @@ export const GetPeaksForSegmentResponse = {
|
||||||
): GetPeaksForSegmentResponse {
|
): GetPeaksForSegmentResponse {
|
||||||
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
|
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
|
||||||
let end = length === undefined ? reader.len : reader.pos + length;
|
let end = length === undefined ? reader.len : reader.pos + length;
|
||||||
const message = createBaseGetPeaksForSegmentResponse();
|
const message = {
|
||||||
|
...baseGetPeaksForSegmentResponse,
|
||||||
|
} as GetPeaksForSegmentResponse;
|
||||||
|
message.peaks = [];
|
||||||
while (reader.pos < end) {
|
while (reader.pos < end) {
|
||||||
const tag = reader.uint32();
|
const tag = reader.uint32();
|
||||||
switch (tag >>> 3) {
|
switch (tag >>> 3) {
|
||||||
|
@ -682,17 +670,17 @@ export const GetPeaksForSegmentResponse = {
|
||||||
},
|
},
|
||||||
|
|
||||||
fromJSON(object: any): GetPeaksForSegmentResponse {
|
fromJSON(object: any): GetPeaksForSegmentResponse {
|
||||||
return {
|
const message = {
|
||||||
peaks: Array.isArray(object?.peaks)
|
...baseGetPeaksForSegmentResponse,
|
||||||
? object.peaks.map((e: any) => Number(e))
|
} as GetPeaksForSegmentResponse;
|
||||||
: [],
|
message.peaks = (object.peaks ?? []).map((e: any) => Number(e));
|
||||||
};
|
return message;
|
||||||
},
|
},
|
||||||
|
|
||||||
toJSON(message: GetPeaksForSegmentResponse): unknown {
|
toJSON(message: GetPeaksForSegmentResponse): unknown {
|
||||||
const obj: any = {};
|
const obj: any = {};
|
||||||
if (message.peaks) {
|
if (message.peaks) {
|
||||||
obj.peaks = message.peaks.map((e) => Math.round(e));
|
obj.peaks = message.peaks.map((e) => e);
|
||||||
} else {
|
} else {
|
||||||
obj.peaks = [];
|
obj.peaks = [];
|
||||||
}
|
}
|
||||||
|
@ -702,15 +690,20 @@ export const GetPeaksForSegmentResponse = {
|
||||||
fromPartial<I extends Exact<DeepPartial<GetPeaksForSegmentResponse>, I>>(
|
fromPartial<I extends Exact<DeepPartial<GetPeaksForSegmentResponse>, I>>(
|
||||||
object: I
|
object: I
|
||||||
): GetPeaksForSegmentResponse {
|
): GetPeaksForSegmentResponse {
|
||||||
const message = createBaseGetPeaksForSegmentResponse();
|
const message = {
|
||||||
|
...baseGetPeaksForSegmentResponse,
|
||||||
|
} as GetPeaksForSegmentResponse;
|
||||||
message.peaks = object.peaks?.map((e) => e) || [];
|
message.peaks = object.peaks?.map((e) => e) || [];
|
||||||
return message;
|
return message;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function createBaseGetAudioSegmentRequest(): GetAudioSegmentRequest {
|
const baseGetAudioSegmentRequest: object = {
|
||||||
return { id: "", startFrame: 0, endFrame: 0, format: 0 };
|
id: "",
|
||||||
}
|
startFrame: 0,
|
||||||
|
endFrame: 0,
|
||||||
|
format: 0,
|
||||||
|
};
|
||||||
|
|
||||||
export const GetAudioSegmentRequest = {
|
export const GetAudioSegmentRequest = {
|
||||||
encode(
|
encode(
|
||||||
|
@ -738,7 +731,7 @@ export const GetAudioSegmentRequest = {
|
||||||
): GetAudioSegmentRequest {
|
): GetAudioSegmentRequest {
|
||||||
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
|
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
|
||||||
let end = length === undefined ? reader.len : reader.pos + length;
|
let end = length === undefined ? reader.len : reader.pos + length;
|
||||||
const message = createBaseGetAudioSegmentRequest();
|
const message = { ...baseGetAudioSegmentRequest } as GetAudioSegmentRequest;
|
||||||
while (reader.pos < end) {
|
while (reader.pos < end) {
|
||||||
const tag = reader.uint32();
|
const tag = reader.uint32();
|
||||||
switch (tag >>> 3) {
|
switch (tag >>> 3) {
|
||||||
|
@ -763,21 +756,29 @@ export const GetAudioSegmentRequest = {
|
||||||
},
|
},
|
||||||
|
|
||||||
fromJSON(object: any): GetAudioSegmentRequest {
|
fromJSON(object: any): GetAudioSegmentRequest {
|
||||||
return {
|
const message = { ...baseGetAudioSegmentRequest } as GetAudioSegmentRequest;
|
||||||
id: isSet(object.id) ? String(object.id) : "",
|
message.id =
|
||||||
startFrame: isSet(object.startFrame) ? Number(object.startFrame) : 0,
|
object.id !== undefined && object.id !== null ? String(object.id) : "";
|
||||||
endFrame: isSet(object.endFrame) ? Number(object.endFrame) : 0,
|
message.startFrame =
|
||||||
format: isSet(object.format) ? audioFormatFromJSON(object.format) : 0,
|
object.startFrame !== undefined && object.startFrame !== null
|
||||||
};
|
? Number(object.startFrame)
|
||||||
|
: 0;
|
||||||
|
message.endFrame =
|
||||||
|
object.endFrame !== undefined && object.endFrame !== null
|
||||||
|
? Number(object.endFrame)
|
||||||
|
: 0;
|
||||||
|
message.format =
|
||||||
|
object.format !== undefined && object.format !== null
|
||||||
|
? audioFormatFromJSON(object.format)
|
||||||
|
: 0;
|
||||||
|
return message;
|
||||||
},
|
},
|
||||||
|
|
||||||
toJSON(message: GetAudioSegmentRequest): unknown {
|
toJSON(message: GetAudioSegmentRequest): unknown {
|
||||||
const obj: any = {};
|
const obj: any = {};
|
||||||
message.id !== undefined && (obj.id = message.id);
|
message.id !== undefined && (obj.id = message.id);
|
||||||
message.startFrame !== undefined &&
|
message.startFrame !== undefined && (obj.startFrame = message.startFrame);
|
||||||
(obj.startFrame = Math.round(message.startFrame));
|
message.endFrame !== undefined && (obj.endFrame = message.endFrame);
|
||||||
message.endFrame !== undefined &&
|
|
||||||
(obj.endFrame = Math.round(message.endFrame));
|
|
||||||
message.format !== undefined &&
|
message.format !== undefined &&
|
||||||
(obj.format = audioFormatToJSON(message.format));
|
(obj.format = audioFormatToJSON(message.format));
|
||||||
return obj;
|
return obj;
|
||||||
|
@ -786,7 +787,7 @@ export const GetAudioSegmentRequest = {
|
||||||
fromPartial<I extends Exact<DeepPartial<GetAudioSegmentRequest>, I>>(
|
fromPartial<I extends Exact<DeepPartial<GetAudioSegmentRequest>, I>>(
|
||||||
object: I
|
object: I
|
||||||
): GetAudioSegmentRequest {
|
): GetAudioSegmentRequest {
|
||||||
const message = createBaseGetAudioSegmentRequest();
|
const message = { ...baseGetAudioSegmentRequest } as GetAudioSegmentRequest;
|
||||||
message.id = object.id ?? "";
|
message.id = object.id ?? "";
|
||||||
message.startFrame = object.startFrame ?? 0;
|
message.startFrame = object.startFrame ?? 0;
|
||||||
message.endFrame = object.endFrame ?? 0;
|
message.endFrame = object.endFrame ?? 0;
|
||||||
|
@ -795,15 +796,23 @@ export const GetAudioSegmentRequest = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function createBaseGetAudioSegmentProgress(): GetAudioSegmentProgress {
|
const baseGetAudioSegmentProgress: object = {
|
||||||
return { percentComplete: 0, audioData: new Uint8Array() };
|
mimeType: "",
|
||||||
}
|
message: "",
|
||||||
|
percentComplete: 0,
|
||||||
|
};
|
||||||
|
|
||||||
export const GetAudioSegmentProgress = {
|
export const GetAudioSegmentProgress = {
|
||||||
encode(
|
encode(
|
||||||
message: GetAudioSegmentProgress,
|
message: GetAudioSegmentProgress,
|
||||||
writer: _m0.Writer = _m0.Writer.create()
|
writer: _m0.Writer = _m0.Writer.create()
|
||||||
): _m0.Writer {
|
): _m0.Writer {
|
||||||
|
if (message.mimeType !== "") {
|
||||||
|
writer.uint32(10).string(message.mimeType);
|
||||||
|
}
|
||||||
|
if (message.message !== "") {
|
||||||
|
writer.uint32(18).string(message.message);
|
||||||
|
}
|
||||||
if (message.percentComplete !== 0) {
|
if (message.percentComplete !== 0) {
|
||||||
writer.uint32(29).float(message.percentComplete);
|
writer.uint32(29).float(message.percentComplete);
|
||||||
}
|
}
|
||||||
|
@ -819,10 +828,19 @@ export const GetAudioSegmentProgress = {
|
||||||
): GetAudioSegmentProgress {
|
): GetAudioSegmentProgress {
|
||||||
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
|
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
|
||||||
let end = length === undefined ? reader.len : reader.pos + length;
|
let end = length === undefined ? reader.len : reader.pos + length;
|
||||||
const message = createBaseGetAudioSegmentProgress();
|
const message = {
|
||||||
|
...baseGetAudioSegmentProgress,
|
||||||
|
} as GetAudioSegmentProgress;
|
||||||
|
message.audioData = new Uint8Array();
|
||||||
while (reader.pos < end) {
|
while (reader.pos < end) {
|
||||||
const tag = reader.uint32();
|
const tag = reader.uint32();
|
||||||
switch (tag >>> 3) {
|
switch (tag >>> 3) {
|
||||||
|
case 1:
|
||||||
|
message.mimeType = reader.string();
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
message.message = reader.string();
|
||||||
|
break;
|
||||||
case 3:
|
case 3:
|
||||||
message.percentComplete = reader.float();
|
message.percentComplete = reader.float();
|
||||||
break;
|
break;
|
||||||
|
@ -838,18 +856,32 @@ export const GetAudioSegmentProgress = {
|
||||||
},
|
},
|
||||||
|
|
||||||
fromJSON(object: any): GetAudioSegmentProgress {
|
fromJSON(object: any): GetAudioSegmentProgress {
|
||||||
return {
|
const message = {
|
||||||
percentComplete: isSet(object.percentComplete)
|
...baseGetAudioSegmentProgress,
|
||||||
|
} as GetAudioSegmentProgress;
|
||||||
|
message.mimeType =
|
||||||
|
object.mimeType !== undefined && object.mimeType !== null
|
||||||
|
? String(object.mimeType)
|
||||||
|
: "";
|
||||||
|
message.message =
|
||||||
|
object.message !== undefined && object.message !== null
|
||||||
|
? String(object.message)
|
||||||
|
: "";
|
||||||
|
message.percentComplete =
|
||||||
|
object.percentComplete !== undefined && object.percentComplete !== null
|
||||||
? Number(object.percentComplete)
|
? Number(object.percentComplete)
|
||||||
: 0,
|
: 0;
|
||||||
audioData: isSet(object.audioData)
|
message.audioData =
|
||||||
|
object.audioData !== undefined && object.audioData !== null
|
||||||
? bytesFromBase64(object.audioData)
|
? bytesFromBase64(object.audioData)
|
||||||
: new Uint8Array(),
|
: new Uint8Array();
|
||||||
};
|
return message;
|
||||||
},
|
},
|
||||||
|
|
||||||
toJSON(message: GetAudioSegmentProgress): unknown {
|
toJSON(message: GetAudioSegmentProgress): unknown {
|
||||||
const obj: any = {};
|
const obj: any = {};
|
||||||
|
message.mimeType !== undefined && (obj.mimeType = message.mimeType);
|
||||||
|
message.message !== undefined && (obj.message = message.message);
|
||||||
message.percentComplete !== undefined &&
|
message.percentComplete !== undefined &&
|
||||||
(obj.percentComplete = message.percentComplete);
|
(obj.percentComplete = message.percentComplete);
|
||||||
message.audioData !== undefined &&
|
message.audioData !== undefined &&
|
||||||
|
@ -862,16 +894,18 @@ export const GetAudioSegmentProgress = {
|
||||||
fromPartial<I extends Exact<DeepPartial<GetAudioSegmentProgress>, I>>(
|
fromPartial<I extends Exact<DeepPartial<GetAudioSegmentProgress>, I>>(
|
||||||
object: I
|
object: I
|
||||||
): GetAudioSegmentProgress {
|
): GetAudioSegmentProgress {
|
||||||
const message = createBaseGetAudioSegmentProgress();
|
const message = {
|
||||||
|
...baseGetAudioSegmentProgress,
|
||||||
|
} as GetAudioSegmentProgress;
|
||||||
|
message.mimeType = object.mimeType ?? "";
|
||||||
|
message.message = object.message ?? "";
|
||||||
message.percentComplete = object.percentComplete ?? 0;
|
message.percentComplete = object.percentComplete ?? 0;
|
||||||
message.audioData = object.audioData ?? new Uint8Array();
|
message.audioData = object.audioData ?? new Uint8Array();
|
||||||
return message;
|
return message;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function createBaseGetVideoRequest(): GetVideoRequest {
|
const baseGetVideoRequest: object = { id: "" };
|
||||||
return { id: "" };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GetVideoRequest = {
|
export const GetVideoRequest = {
|
||||||
encode(
|
encode(
|
||||||
|
@ -887,7 +921,7 @@ export const GetVideoRequest = {
|
||||||
decode(input: _m0.Reader | Uint8Array, length?: number): GetVideoRequest {
|
decode(input: _m0.Reader | Uint8Array, length?: number): GetVideoRequest {
|
||||||
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
|
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
|
||||||
let end = length === undefined ? reader.len : reader.pos + length;
|
let end = length === undefined ? reader.len : reader.pos + length;
|
||||||
const message = createBaseGetVideoRequest();
|
const message = { ...baseGetVideoRequest } as GetVideoRequest;
|
||||||
while (reader.pos < end) {
|
while (reader.pos < end) {
|
||||||
const tag = reader.uint32();
|
const tag = reader.uint32();
|
||||||
switch (tag >>> 3) {
|
switch (tag >>> 3) {
|
||||||
|
@ -903,9 +937,10 @@ export const GetVideoRequest = {
|
||||||
},
|
},
|
||||||
|
|
||||||
fromJSON(object: any): GetVideoRequest {
|
fromJSON(object: any): GetVideoRequest {
|
||||||
return {
|
const message = { ...baseGetVideoRequest } as GetVideoRequest;
|
||||||
id: isSet(object.id) ? String(object.id) : "",
|
message.id =
|
||||||
};
|
object.id !== undefined && object.id !== null ? String(object.id) : "";
|
||||||
|
return message;
|
||||||
},
|
},
|
||||||
|
|
||||||
toJSON(message: GetVideoRequest): unknown {
|
toJSON(message: GetVideoRequest): unknown {
|
||||||
|
@ -917,15 +952,13 @@ export const GetVideoRequest = {
|
||||||
fromPartial<I extends Exact<DeepPartial<GetVideoRequest>, I>>(
|
fromPartial<I extends Exact<DeepPartial<GetVideoRequest>, I>>(
|
||||||
object: I
|
object: I
|
||||||
): GetVideoRequest {
|
): GetVideoRequest {
|
||||||
const message = createBaseGetVideoRequest();
|
const message = { ...baseGetVideoRequest } as GetVideoRequest;
|
||||||
message.id = object.id ?? "";
|
message.id = object.id ?? "";
|
||||||
return message;
|
return message;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function createBaseGetVideoProgress(): GetVideoProgress {
|
const baseGetVideoProgress: object = { percentComplete: 0, url: "" };
|
||||||
return { percentComplete: 0, url: "" };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GetVideoProgress = {
|
export const GetVideoProgress = {
|
||||||
encode(
|
encode(
|
||||||
|
@ -944,7 +977,7 @@ export const GetVideoProgress = {
|
||||||
decode(input: _m0.Reader | Uint8Array, length?: number): GetVideoProgress {
|
decode(input: _m0.Reader | Uint8Array, length?: number): GetVideoProgress {
|
||||||
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
|
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
|
||||||
let end = length === undefined ? reader.len : reader.pos + length;
|
let end = length === undefined ? reader.len : reader.pos + length;
|
||||||
const message = createBaseGetVideoProgress();
|
const message = { ...baseGetVideoProgress } as GetVideoProgress;
|
||||||
while (reader.pos < end) {
|
while (reader.pos < end) {
|
||||||
const tag = reader.uint32();
|
const tag = reader.uint32();
|
||||||
switch (tag >>> 3) {
|
switch (tag >>> 3) {
|
||||||
|
@ -963,12 +996,14 @@ export const GetVideoProgress = {
|
||||||
},
|
},
|
||||||
|
|
||||||
fromJSON(object: any): GetVideoProgress {
|
fromJSON(object: any): GetVideoProgress {
|
||||||
return {
|
const message = { ...baseGetVideoProgress } as GetVideoProgress;
|
||||||
percentComplete: isSet(object.percentComplete)
|
message.percentComplete =
|
||||||
|
object.percentComplete !== undefined && object.percentComplete !== null
|
||||||
? Number(object.percentComplete)
|
? Number(object.percentComplete)
|
||||||
: 0,
|
: 0;
|
||||||
url: isSet(object.url) ? String(object.url) : "",
|
message.url =
|
||||||
};
|
object.url !== undefined && object.url !== null ? String(object.url) : "";
|
||||||
|
return message;
|
||||||
},
|
},
|
||||||
|
|
||||||
toJSON(message: GetVideoProgress): unknown {
|
toJSON(message: GetVideoProgress): unknown {
|
||||||
|
@ -982,16 +1017,14 @@ export const GetVideoProgress = {
|
||||||
fromPartial<I extends Exact<DeepPartial<GetVideoProgress>, I>>(
|
fromPartial<I extends Exact<DeepPartial<GetVideoProgress>, I>>(
|
||||||
object: I
|
object: I
|
||||||
): GetVideoProgress {
|
): GetVideoProgress {
|
||||||
const message = createBaseGetVideoProgress();
|
const message = { ...baseGetVideoProgress } as GetVideoProgress;
|
||||||
message.percentComplete = object.percentComplete ?? 0;
|
message.percentComplete = object.percentComplete ?? 0;
|
||||||
message.url = object.url ?? "";
|
message.url = object.url ?? "";
|
||||||
return message;
|
return message;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function createBaseGetVideoThumbnailRequest(): GetVideoThumbnailRequest {
|
const baseGetVideoThumbnailRequest: object = { id: "" };
|
||||||
return { id: "" };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GetVideoThumbnailRequest = {
|
export const GetVideoThumbnailRequest = {
|
||||||
encode(
|
encode(
|
||||||
|
@ -1010,7 +1043,9 @@ export const GetVideoThumbnailRequest = {
|
||||||
): GetVideoThumbnailRequest {
|
): GetVideoThumbnailRequest {
|
||||||
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
|
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
|
||||||
let end = length === undefined ? reader.len : reader.pos + length;
|
let end = length === undefined ? reader.len : reader.pos + length;
|
||||||
const message = createBaseGetVideoThumbnailRequest();
|
const message = {
|
||||||
|
...baseGetVideoThumbnailRequest,
|
||||||
|
} as GetVideoThumbnailRequest;
|
||||||
while (reader.pos < end) {
|
while (reader.pos < end) {
|
||||||
const tag = reader.uint32();
|
const tag = reader.uint32();
|
||||||
switch (tag >>> 3) {
|
switch (tag >>> 3) {
|
||||||
|
@ -1026,9 +1061,12 @@ export const GetVideoThumbnailRequest = {
|
||||||
},
|
},
|
||||||
|
|
||||||
fromJSON(object: any): GetVideoThumbnailRequest {
|
fromJSON(object: any): GetVideoThumbnailRequest {
|
||||||
return {
|
const message = {
|
||||||
id: isSet(object.id) ? String(object.id) : "",
|
...baseGetVideoThumbnailRequest,
|
||||||
};
|
} as GetVideoThumbnailRequest;
|
||||||
|
message.id =
|
||||||
|
object.id !== undefined && object.id !== null ? String(object.id) : "";
|
||||||
|
return message;
|
||||||
},
|
},
|
||||||
|
|
||||||
toJSON(message: GetVideoThumbnailRequest): unknown {
|
toJSON(message: GetVideoThumbnailRequest): unknown {
|
||||||
|
@ -1040,15 +1078,15 @@ export const GetVideoThumbnailRequest = {
|
||||||
fromPartial<I extends Exact<DeepPartial<GetVideoThumbnailRequest>, I>>(
|
fromPartial<I extends Exact<DeepPartial<GetVideoThumbnailRequest>, I>>(
|
||||||
object: I
|
object: I
|
||||||
): GetVideoThumbnailRequest {
|
): GetVideoThumbnailRequest {
|
||||||
const message = createBaseGetVideoThumbnailRequest();
|
const message = {
|
||||||
|
...baseGetVideoThumbnailRequest,
|
||||||
|
} as GetVideoThumbnailRequest;
|
||||||
message.id = object.id ?? "";
|
message.id = object.id ?? "";
|
||||||
return message;
|
return message;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function createBaseGetVideoThumbnailResponse(): GetVideoThumbnailResponse {
|
const baseGetVideoThumbnailResponse: object = { width: 0, height: 0 };
|
||||||
return { image: new Uint8Array(), width: 0, height: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GetVideoThumbnailResponse = {
|
export const GetVideoThumbnailResponse = {
|
||||||
encode(
|
encode(
|
||||||
|
@ -1073,7 +1111,10 @@ export const GetVideoThumbnailResponse = {
|
||||||
): GetVideoThumbnailResponse {
|
): GetVideoThumbnailResponse {
|
||||||
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
|
const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input);
|
||||||
let end = length === undefined ? reader.len : reader.pos + length;
|
let end = length === undefined ? reader.len : reader.pos + length;
|
||||||
const message = createBaseGetVideoThumbnailResponse();
|
const message = {
|
||||||
|
...baseGetVideoThumbnailResponse,
|
||||||
|
} as GetVideoThumbnailResponse;
|
||||||
|
message.image = new Uint8Array();
|
||||||
while (reader.pos < end) {
|
while (reader.pos < end) {
|
||||||
const tag = reader.uint32();
|
const tag = reader.uint32();
|
||||||
switch (tag >>> 3) {
|
switch (tag >>> 3) {
|
||||||
|
@ -1095,13 +1136,22 @@ export const GetVideoThumbnailResponse = {
|
||||||
},
|
},
|
||||||
|
|
||||||
fromJSON(object: any): GetVideoThumbnailResponse {
|
fromJSON(object: any): GetVideoThumbnailResponse {
|
||||||
return {
|
const message = {
|
||||||
image: isSet(object.image)
|
...baseGetVideoThumbnailResponse,
|
||||||
|
} as GetVideoThumbnailResponse;
|
||||||
|
message.image =
|
||||||
|
object.image !== undefined && object.image !== null
|
||||||
? bytesFromBase64(object.image)
|
? bytesFromBase64(object.image)
|
||||||
: new Uint8Array(),
|
: new Uint8Array();
|
||||||
width: isSet(object.width) ? Number(object.width) : 0,
|
message.width =
|
||||||
height: isSet(object.height) ? Number(object.height) : 0,
|
object.width !== undefined && object.width !== null
|
||||||
};
|
? Number(object.width)
|
||||||
|
: 0;
|
||||||
|
message.height =
|
||||||
|
object.height !== undefined && object.height !== null
|
||||||
|
? Number(object.height)
|
||||||
|
: 0;
|
||||||
|
return message;
|
||||||
},
|
},
|
||||||
|
|
||||||
toJSON(message: GetVideoThumbnailResponse): unknown {
|
toJSON(message: GetVideoThumbnailResponse): unknown {
|
||||||
|
@ -1110,15 +1160,17 @@ export const GetVideoThumbnailResponse = {
|
||||||
(obj.image = base64FromBytes(
|
(obj.image = base64FromBytes(
|
||||||
message.image !== undefined ? message.image : new Uint8Array()
|
message.image !== undefined ? message.image : new Uint8Array()
|
||||||
));
|
));
|
||||||
message.width !== undefined && (obj.width = Math.round(message.width));
|
message.width !== undefined && (obj.width = message.width);
|
||||||
message.height !== undefined && (obj.height = Math.round(message.height));
|
message.height !== undefined && (obj.height = message.height);
|
||||||
return obj;
|
return obj;
|
||||||
},
|
},
|
||||||
|
|
||||||
fromPartial<I extends Exact<DeepPartial<GetVideoThumbnailResponse>, I>>(
|
fromPartial<I extends Exact<DeepPartial<GetVideoThumbnailResponse>, I>>(
|
||||||
object: I
|
object: I
|
||||||
): GetVideoThumbnailResponse {
|
): GetVideoThumbnailResponse {
|
||||||
const message = createBaseGetVideoThumbnailResponse();
|
const message = {
|
||||||
|
...baseGetVideoThumbnailResponse,
|
||||||
|
} as GetVideoThumbnailResponse;
|
||||||
message.image = object.image ?? new Uint8Array();
|
message.image = object.image ?? new Uint8Array();
|
||||||
message.width = object.width ?? 0;
|
message.width = object.width ?? 0;
|
||||||
message.height = object.height ?? 0;
|
message.height = object.height ?? 0;
|
||||||
|
@ -1560,7 +1612,3 @@ if (_m0.util.Long !== Long) {
|
||||||
_m0.util.Long = Long as any;
|
_m0.util.Long = Long as any;
|
||||||
_m0.configure();
|
_m0.configure();
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSet(value: any): boolean {
|
|
||||||
return value !== null && value !== undefined;
|
|
||||||
}
|
|
||||||
|
|
|
@ -13,13 +13,7 @@
|
||||||
|
|
||||||
var jspb = require('google-protobuf');
|
var jspb = require('google-protobuf');
|
||||||
var goog = jspb;
|
var goog = jspb;
|
||||||
var global = (function() {
|
var global = Function('return this')();
|
||||||
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');
|
var google_protobuf_duration_pb = require('google-protobuf/google/protobuf/duration_pb.js');
|
||||||
goog.object.extend(proto, google_protobuf_duration_pb);
|
goog.object.extend(proto, google_protobuf_duration_pb);
|
||||||
|
@ -322,9 +316,6 @@ proto.media_set.MediaSet.toObject = function(includeInstance, msg) {
|
||||||
var f, obj = {
|
var f, obj = {
|
||||||
id: jspb.Message.getFieldWithDefault(msg, 1, ""),
|
id: jspb.Message.getFieldWithDefault(msg, 1, ""),
|
||||||
youtubeId: jspb.Message.getFieldWithDefault(msg, 2, ""),
|
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),
|
audioChannels: jspb.Message.getFieldWithDefault(msg, 3, 0),
|
||||||
audioApproxFrames: jspb.Message.getFieldWithDefault(msg, 4, 0),
|
audioApproxFrames: jspb.Message.getFieldWithDefault(msg, 4, 0),
|
||||||
audioFrames: jspb.Message.getFieldWithDefault(msg, 5, 0),
|
audioFrames: jspb.Message.getFieldWithDefault(msg, 5, 0),
|
||||||
|
@ -378,18 +369,6 @@ proto.media_set.MediaSet.deserializeBinaryFromReader = function(msg, reader) {
|
||||||
var value = /** @type {string} */ (reader.readString());
|
var value = /** @type {string} */ (reader.readString());
|
||||||
msg.setYoutubeId(value);
|
msg.setYoutubeId(value);
|
||||||
break;
|
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:
|
case 3:
|
||||||
var value = /** @type {number} */ (reader.readInt32());
|
var value = /** @type {number} */ (reader.readInt32());
|
||||||
msg.setAudioChannels(value);
|
msg.setAudioChannels(value);
|
||||||
|
@ -470,27 +449,6 @@ proto.media_set.MediaSet.serializeBinaryToWriter = function(message, writer) {
|
||||||
f
|
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();
|
f = message.getAudioChannels();
|
||||||
if (f !== 0) {
|
if (f !== 0) {
|
||||||
writer.writeInt32(
|
writer.writeInt32(
|
||||||
|
@ -594,60 +552,6 @@ proto.media_set.MediaSet.prototype.setYoutubeId = function(value) {
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* optional string title = 12;
|
|
||||||
* @return {string}
|
|
||||||
*/
|
|
||||||
proto.media_set.MediaSet.prototype.getTitle = function() {
|
|
||||||
return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 12, ""));
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} value
|
|
||||||
* @return {!proto.media_set.MediaSet} returns this
|
|
||||||
*/
|
|
||||||
proto.media_set.MediaSet.prototype.setTitle = function(value) {
|
|
||||||
return jspb.Message.setProto3StringField(this, 12, value);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* optional string description = 13;
|
|
||||||
* @return {string}
|
|
||||||
*/
|
|
||||||
proto.media_set.MediaSet.prototype.getDescription = function() {
|
|
||||||
return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 13, ""));
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} value
|
|
||||||
* @return {!proto.media_set.MediaSet} returns this
|
|
||||||
*/
|
|
||||||
proto.media_set.MediaSet.prototype.setDescription = function(value) {
|
|
||||||
return jspb.Message.setProto3StringField(this, 13, value);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* optional string author = 14;
|
|
||||||
* @return {string}
|
|
||||||
*/
|
|
||||||
proto.media_set.MediaSet.prototype.getAuthor = function() {
|
|
||||||
return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 14, ""));
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} value
|
|
||||||
* @return {!proto.media_set.MediaSet} returns this
|
|
||||||
*/
|
|
||||||
proto.media_set.MediaSet.prototype.setAuthor = function(value) {
|
|
||||||
return jspb.Message.setProto3StringField(this, 14, value);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* optional int32 audio_channels = 3;
|
* optional int32 audio_channels = 3;
|
||||||
* @return {number}
|
* @return {number}
|
||||||
|
@ -1160,8 +1064,7 @@ proto.media_set.GetPeaksProgress.toObject = function(includeInstance, msg) {
|
||||||
var f, obj = {
|
var f, obj = {
|
||||||
peaksList: (f = jspb.Message.getRepeatedField(msg, 1)) == null ? undefined : f,
|
peaksList: (f = jspb.Message.getRepeatedField(msg, 1)) == null ? undefined : f,
|
||||||
percentComplete: jspb.Message.getFloatingPointFieldWithDefault(msg, 2, 0.0),
|
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) {
|
if (includeInstance) {
|
||||||
|
@ -1212,10 +1115,6 @@ proto.media_set.GetPeaksProgress.deserializeBinaryFromReader = function(msg, rea
|
||||||
var value = /** @type {string} */ (reader.readString());
|
var value = /** @type {string} */ (reader.readString());
|
||||||
msg.setUrl(value);
|
msg.setUrl(value);
|
||||||
break;
|
break;
|
||||||
case 4:
|
|
||||||
var value = /** @type {number} */ (reader.readInt64());
|
|
||||||
msg.setAudioFrames(value);
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
reader.skipField();
|
reader.skipField();
|
||||||
break;
|
break;
|
||||||
|
@ -1266,13 +1165,6 @@ proto.media_set.GetPeaksProgress.serializeBinaryToWriter = function(message, wri
|
||||||
f
|
f
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
f = message.getAudioFrames();
|
|
||||||
if (f !== 0) {
|
|
||||||
writer.writeInt64(
|
|
||||||
4,
|
|
||||||
f
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
@ -1349,24 +1241,6 @@ proto.media_set.GetPeaksProgress.prototype.setUrl = function(value) {
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* optional int64 audio_frames = 4;
|
|
||||||
* @return {number}
|
|
||||||
*/
|
|
||||||
proto.media_set.GetPeaksProgress.prototype.getAudioFrames = function() {
|
|
||||||
return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 4, 0));
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {number} value
|
|
||||||
* @return {!proto.media_set.GetPeaksProgress} returns this
|
|
||||||
*/
|
|
||||||
proto.media_set.GetPeaksProgress.prototype.setAudioFrames = function(value) {
|
|
||||||
return jspb.Message.setProto3IntField(this, 4, value);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -1997,6 +1871,8 @@ proto.media_set.GetAudioSegmentProgress.prototype.toObject = function(opt_includ
|
||||||
*/
|
*/
|
||||||
proto.media_set.GetAudioSegmentProgress.toObject = function(includeInstance, msg) {
|
proto.media_set.GetAudioSegmentProgress.toObject = function(includeInstance, msg) {
|
||||||
var f, obj = {
|
var f, obj = {
|
||||||
|
mimeType: jspb.Message.getFieldWithDefault(msg, 1, ""),
|
||||||
|
message: jspb.Message.getFieldWithDefault(msg, 2, ""),
|
||||||
percentComplete: jspb.Message.getFloatingPointFieldWithDefault(msg, 3, 0.0),
|
percentComplete: jspb.Message.getFloatingPointFieldWithDefault(msg, 3, 0.0),
|
||||||
audioData: msg.getAudioData_asB64()
|
audioData: msg.getAudioData_asB64()
|
||||||
};
|
};
|
||||||
|
@ -2035,6 +1911,14 @@ proto.media_set.GetAudioSegmentProgress.deserializeBinaryFromReader = function(m
|
||||||
}
|
}
|
||||||
var field = reader.getFieldNumber();
|
var field = reader.getFieldNumber();
|
||||||
switch (field) {
|
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:
|
case 3:
|
||||||
var value = /** @type {number} */ (reader.readFloat());
|
var value = /** @type {number} */ (reader.readFloat());
|
||||||
msg.setPercentComplete(value);
|
msg.setPercentComplete(value);
|
||||||
|
@ -2072,6 +1956,20 @@ proto.media_set.GetAudioSegmentProgress.prototype.serializeBinary = function() {
|
||||||
*/
|
*/
|
||||||
proto.media_set.GetAudioSegmentProgress.serializeBinaryToWriter = function(message, writer) {
|
proto.media_set.GetAudioSegmentProgress.serializeBinaryToWriter = function(message, writer) {
|
||||||
var f = undefined;
|
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();
|
f = message.getPercentComplete();
|
||||||
if (f !== 0.0) {
|
if (f !== 0.0) {
|
||||||
writer.writeFloat(
|
writer.writeFloat(
|
||||||
|
@ -2089,6 +1987,42 @@ proto.media_set.GetAudioSegmentProgress.serializeBinaryToWriter = function(messa
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* optional string mime_type = 1;
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
proto.media_set.GetAudioSegmentProgress.prototype.getMimeType = function() {
|
||||||
|
return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 1, ""));
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} value
|
||||||
|
* @return {!proto.media_set.GetAudioSegmentProgress} returns this
|
||||||
|
*/
|
||||||
|
proto.media_set.GetAudioSegmentProgress.prototype.setMimeType = function(value) {
|
||||||
|
return jspb.Message.setProto3StringField(this, 1, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* optional string message = 2;
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
proto.media_set.GetAudioSegmentProgress.prototype.getMessage = function() {
|
||||||
|
return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 2, ""));
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} value
|
||||||
|
* @return {!proto.media_set.GetAudioSegmentProgress} returns this
|
||||||
|
*/
|
||||||
|
proto.media_set.GetAudioSegmentProgress.prototype.setMessage = function(value) {
|
||||||
|
return jspb.Message.setProto3StringField(this, 2, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* optional float percent_complete = 3;
|
* optional float percent_complete = 3;
|
||||||
* @return {number}
|
* @return {number}
|
||||||
|
|
|
@ -1,15 +0,0 @@
|
||||||
import constrainNumeric from './constrainNumeric';
|
|
||||||
|
|
||||||
describe('constrainNumeric', () => {
|
|
||||||
it('constrains the value when it is less than 0', () => {
|
|
||||||
expect(constrainNumeric(-1, 10)).toEqual(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('constrains the value when it is greater than max', () => {
|
|
||||||
expect(constrainNumeric(11, 10)).toEqual(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not constrain an acceptable value', () => {
|
|
||||||
expect(constrainNumeric(3, 10)).toEqual(3);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,11 +0,0 @@
|
||||||
function constrainNumeric(x: number, max: number): number {
|
|
||||||
if (x < 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (x > max) {
|
|
||||||
return max;
|
|
||||||
}
|
|
||||||
return x;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default constrainNumeric;
|
|
|
@ -1,18 +0,0 @@
|
||||||
import frameToWaveformCanvasX from './frameToWaveformCanvasX';
|
|
||||||
|
|
||||||
describe('frameToWaveformCanvasX', () => {
|
|
||||||
it('returns null when the frame is before the viewport', () => {
|
|
||||||
const x = frameToWaveformCanvasX(100, { start: 200, end: 300 }, 2000);
|
|
||||||
expect(x).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when the frame is after the viewport', () => {
|
|
||||||
const x = frameToWaveformCanvasX(400, { start: 200, end: 300 }, 2000);
|
|
||||||
expect(x).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the expected coordinate when the frame is inside the viewport', () => {
|
|
||||||
const x = frameToWaveformCanvasX(251, { start: 200, end: 300 }, 2000);
|
|
||||||
expect(x).toEqual(1020);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,16 +0,0 @@
|
||||||
import { FrameRange } from '../AppState';
|
|
||||||
|
|
||||||
function frameToWaveformCanvasX(
|
|
||||||
frame: number,
|
|
||||||
viewport: FrameRange,
|
|
||||||
canvasWidth: number
|
|
||||||
): number | null {
|
|
||||||
if (frame < viewport.start || frame > viewport.end) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pixelsPerFrame = canvasWidth / (viewport.end - viewport.start);
|
|
||||||
return (frame - viewport.start) * pixelsPerFrame;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default frameToWaveformCanvasX;
|
|
|
@ -1,29 +0,0 @@
|
||||||
import framesToDuration from './framesToDuration';
|
|
||||||
|
|
||||||
describe('framesToDuration', () => {
|
|
||||||
it('returns the expected result for 0 frames at 44100hz', () => {
|
|
||||||
expect(framesToDuration(0, 44100)).toEqual({ seconds: 0, nanos: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the expected result for 44100 frames at 44100hz', () => {
|
|
||||||
expect(framesToDuration(44100, 44100)).toEqual({ seconds: 1, nanos: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the expected result for 88200 frames at 44100hz', () => {
|
|
||||||
expect(framesToDuration(88200, 44100)).toEqual({ seconds: 2, nanos: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the expected result for 88201 frames at 44100hz', () => {
|
|
||||||
expect(framesToDuration(88201, 44100)).toEqual({
|
|
||||||
seconds: 2,
|
|
||||||
nanos: 22675,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the expected result for 110250 frames at 44100hz', () => {
|
|
||||||
expect(framesToDuration(110250, 44100)).toEqual({
|
|
||||||
seconds: 2,
|
|
||||||
nanos: 500_000_000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,11 +0,0 @@
|
||||||
import { Duration } from '../generated/google/protobuf/duration';
|
|
||||||
|
|
||||||
function framesToDuration(frames: number, sampleRate: number): Duration {
|
|
||||||
const secs = Math.floor(frames / sampleRate);
|
|
||||||
const nanos = Math.floor(
|
|
||||||
((frames % sampleRate) / sampleRate) * 1_000_000_000
|
|
||||||
);
|
|
||||||
return { seconds: secs, nanos: nanos };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default framesToDuration;
|
|
|
@ -1,13 +0,0 @@
|
||||||
import millisFromDuration from "./millisFromDuration";
|
|
||||||
import { Duration } from '../generated/google/protobuf/duration';
|
|
||||||
|
|
||||||
describe('millisFromDuration', () => {
|
|
||||||
it('returns 0 if duration is not passed', () => {
|
|
||||||
expect(millisFromDuration()).toEqual(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('correctly returns the ms when the duration has both seconds and nanos', () => {
|
|
||||||
const duration: Duration = { seconds: 34, nanos: 549_875_992 };
|
|
||||||
expect(millisFromDuration(duration)).toEqual(34_549);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,10 +0,0 @@
|
||||||
import { Duration } from '../generated/google/protobuf/duration';
|
|
||||||
|
|
||||||
function millisFromDuration(dur?: Duration): number {
|
|
||||||
if (dur == undefined) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return Math.floor(dur.seconds * 1000.0 + dur.nanos / 1000.0 / 1000.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default millisFromDuration;
|
|
|
@ -1,49 +0,0 @@
|
||||||
import toHHMMSS from './toHHMMSS';
|
|
||||||
import { Duration } from '../generated/google/protobuf/duration';
|
|
||||||
|
|
||||||
describe('toHHMMSS', () => {
|
|
||||||
it('renders correctly for 0ms', () => {
|
|
||||||
const duration: Duration = { seconds: 0, nanos: 0 };
|
|
||||||
expect(toHHMMSS(duration)).toEqual('00:00');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders correctly for 500ms', () => {
|
|
||||||
const duration: Duration = { seconds: 0, nanos: 500_000_000 };
|
|
||||||
expect(toHHMMSS(duration)).toEqual('00:00');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders correctly for 2700ms', () => {
|
|
||||||
const duration: Duration = { seconds: 27, nanos: 0 };
|
|
||||||
expect(toHHMMSS(duration)).toEqual('00:27');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders correctly for 61s', () => {
|
|
||||||
const duration: Duration = { seconds: 61, nanos: 0 };
|
|
||||||
expect(toHHMMSS(duration)).toEqual('01:01');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders correctly for 1200s', () => {
|
|
||||||
const duration: Duration = { seconds: 1200, nanos: 0 };
|
|
||||||
expect(toHHMMSS(duration)).toEqual('20:00');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders correctly for 1201s', () => {
|
|
||||||
const duration: Duration = { seconds: 1201, nanos: 0 };
|
|
||||||
expect(toHHMMSS(duration)).toEqual('20:01');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders correctly for 1h', () => {
|
|
||||||
const duration: Duration = { seconds: 3600, nanos: 0 };
|
|
||||||
expect(toHHMMSS(duration)).toEqual('01:00:00');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders correctly for 1h1m1s', () => {
|
|
||||||
const duration: Duration = { seconds: 3661, nanos: 0 };
|
|
||||||
expect(toHHMMSS(duration)).toEqual('01:01:01');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders correctly for 24h1s', () => {
|
|
||||||
const duration: Duration = { seconds: 86401, nanos: 0 };
|
|
||||||
expect(toHHMMSS(duration)).toEqual('24:00:01');
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,17 +0,0 @@
|
||||||
import { Duration } from '../generated/google/protobuf/duration';
|
|
||||||
import millisFromDuration from './millisFromDuration';
|
|
||||||
|
|
||||||
function toHHMMSS(dur: Duration): string {
|
|
||||||
const millis = millisFromDuration(dur);
|
|
||||||
let secs = Math.floor(millis / 1_000);
|
|
||||||
const hrs = Math.floor(secs / 3600);
|
|
||||||
const mins = Math.floor(secs / 60) % 60;
|
|
||||||
secs = secs % 60;
|
|
||||||
|
|
||||||
return [hrs, mins, secs]
|
|
||||||
.map((v) => (v < 10 ? '0' + v : v))
|
|
||||||
.filter((v, i) => v != '00' || i > 0)
|
|
||||||
.join(':');
|
|
||||||
}
|
|
||||||
|
|
||||||
export default toHHMMSS;
|
|
|
@ -1,273 +0,0 @@
|
||||||
import {
|
|
||||||
zoomViewportIn,
|
|
||||||
zoomViewportOut,
|
|
||||||
canZoomViewportIn,
|
|
||||||
canZoomViewportOut,
|
|
||||||
} from './zoom';
|
|
||||||
|
|
||||||
// zf is the zoom factor.
|
|
||||||
const zf = 2;
|
|
||||||
const emptySelection = { start: 0, end: 0 };
|
|
||||||
|
|
||||||
describe('zoomViewportIn', () => {
|
|
||||||
describe('when viewport start and end is equal', () => {
|
|
||||||
it('returns the same viewport', () => {
|
|
||||||
const newViewport = zoomViewportIn(
|
|
||||||
{ start: 100, end: 100 },
|
|
||||||
500,
|
|
||||||
emptySelection,
|
|
||||||
0,
|
|
||||||
zf
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(newViewport).toEqual({ start: 100, end: 100 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('with nothing selected', () => {
|
|
||||||
it('centres the zoom on the playback position if possible', () => {
|
|
||||||
const newViewport = zoomViewportIn(
|
|
||||||
{ start: 100_000, end: 200_000 },
|
|
||||||
500_000,
|
|
||||||
emptySelection,
|
|
||||||
50_000,
|
|
||||||
zf
|
|
||||||
);
|
|
||||||
expect(newViewport).toEqual({ start: 25_000, end: 75_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('offsets the new viewport if it overlaps the viewport minimum', () => {
|
|
||||||
const newViewport = zoomViewportIn(
|
|
||||||
{ start: 100_000, end: 200_000 },
|
|
||||||
500_000,
|
|
||||||
emptySelection,
|
|
||||||
0,
|
|
||||||
zf
|
|
||||||
);
|
|
||||||
expect(newViewport).toEqual({ start: 0, end: 50_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('offsets the new viewport if it overlaps the viewport maximum', () => {
|
|
||||||
const newViewport = zoomViewportIn(
|
|
||||||
{ start: 100_000, end: 200_000 },
|
|
||||||
500_000,
|
|
||||||
emptySelection,
|
|
||||||
490_000,
|
|
||||||
zf
|
|
||||||
);
|
|
||||||
expect(newViewport).toEqual({ start: 450_000, end: 500_000 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('with an active selection', () => {
|
|
||||||
it('centres the new viewport on the selection if possible', () => {
|
|
||||||
const newViewport = zoomViewportIn(
|
|
||||||
{ start: 100_000, end: 200_000 },
|
|
||||||
500_000,
|
|
||||||
{ start: 120_000, end: 140_000 },
|
|
||||||
0,
|
|
||||||
zf
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(newViewport).toEqual({ start: 105_000, end: 155_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('offsets the new viewport if it overlaps the viewport minimum', () => {
|
|
||||||
const newViewport = zoomViewportIn(
|
|
||||||
{ start: 100_000, end: 200_000 },
|
|
||||||
500_000,
|
|
||||||
{ start: 10_000, end: 20_000 },
|
|
||||||
0,
|
|
||||||
zf
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(newViewport).toEqual({ start: 0, end: 50_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('offsets the new viewport if it overlaps the viewport maximum', () => {
|
|
||||||
const newViewport = zoomViewportIn(
|
|
||||||
{ start: 100_000, end: 200_000 },
|
|
||||||
500_000,
|
|
||||||
{ start: 480_000, end: 490_000 },
|
|
||||||
0,
|
|
||||||
zf
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(newViewport).toEqual({ start: 450_000, end: 500_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when zooming beyond the selection', () => {
|
|
||||||
it('disallows the zoom', () => {
|
|
||||||
const newViewport = zoomViewportIn(
|
|
||||||
{ start: 100_000, end: 200_000 },
|
|
||||||
500_000,
|
|
||||||
{ start: 110_000, end: 190_000 },
|
|
||||||
0,
|
|
||||||
zf
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(newViewport).toEqual({ start: 100_000, end: 200_000 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('zoomViewportOut', () => {
|
|
||||||
describe('when viewport start and end is equal', () => {
|
|
||||||
it('returns the same viewport', () => {
|
|
||||||
const newViewport = zoomViewportOut(
|
|
||||||
{ start: 100, end: 100 },
|
|
||||||
500,
|
|
||||||
emptySelection,
|
|
||||||
0,
|
|
||||||
zf
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(newViewport).toEqual({ start: 100, end: 100 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('with nothing selected', () => {
|
|
||||||
it('centres the zoom on the playback position if possible', () => {
|
|
||||||
const newViewport = zoomViewportOut(
|
|
||||||
{ start: 190_000, end: 210_000 },
|
|
||||||
500_000,
|
|
||||||
emptySelection,
|
|
||||||
170_000,
|
|
||||||
zf
|
|
||||||
);
|
|
||||||
expect(newViewport).toEqual({ start: 150_000, end: 190_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('offsets the new viewport if it overlaps the viewport minimum', () => {
|
|
||||||
const newViewport = zoomViewportOut(
|
|
||||||
{ start: 190_000, end: 210_000 },
|
|
||||||
500_000,
|
|
||||||
emptySelection,
|
|
||||||
10_000,
|
|
||||||
zf
|
|
||||||
);
|
|
||||||
expect(newViewport).toEqual({ start: 0, end: 40_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('offsets the new viewport if it overlaps the viewport maximum', () => {
|
|
||||||
const newViewport = zoomViewportOut(
|
|
||||||
{ start: 190_000, end: 210_000 },
|
|
||||||
500_000,
|
|
||||||
emptySelection,
|
|
||||||
485_000,
|
|
||||||
zf
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(newViewport).toEqual({ start: 460_000, end: 500_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses to zoom out beyond the available limits', () => {
|
|
||||||
const newViewport = zoomViewportOut(
|
|
||||||
{ start: 10_000, end: 490_000 },
|
|
||||||
500_000,
|
|
||||||
emptySelection,
|
|
||||||
200_000,
|
|
||||||
zf
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(newViewport).toEqual({ start: 0, end: 500_000 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('with an active selection', () => {
|
|
||||||
it('centres the new viewport on the selection if possible', () => {
|
|
||||||
const newViewport = zoomViewportOut(
|
|
||||||
{ start: 150_000, end: 170_000 },
|
|
||||||
500_000,
|
|
||||||
{ start: 120_000, end: 140_000 },
|
|
||||||
0,
|
|
||||||
zf
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(newViewport).toEqual({ start: 110_000, end: 150_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('offsets the new viewport if it overlaps the viewport minimum', () => {
|
|
||||||
const newViewport = zoomViewportOut(
|
|
||||||
{ start: 190_000, end: 210_000 },
|
|
||||||
500_000,
|
|
||||||
{ start: 10_000, end: 20_000 },
|
|
||||||
10_000,
|
|
||||||
zf
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(newViewport).toEqual({ start: 0, end: 40_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('offsets the new viewport if it overlaps the viewport minimum', () => {
|
|
||||||
const newViewport = zoomViewportOut(
|
|
||||||
{ start: 190_000, end: 210_000 },
|
|
||||||
500_000,
|
|
||||||
{ start: 495_000, end: 500_000 },
|
|
||||||
0,
|
|
||||||
zf
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(newViewport).toEqual({ start: 460_000, end: 500_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses to zoom out beyond the available limits', () => {
|
|
||||||
const newViewport = zoomViewportOut(
|
|
||||||
{ start: 10_000, end: 490_000 },
|
|
||||||
500_000,
|
|
||||||
{ start: 20_000, end: 480_000 },
|
|
||||||
0,
|
|
||||||
zf
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(newViewport).toEqual({ start: 0, end: 500_000 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('canZoomViewportIn', () => {
|
|
||||||
it('does now allow zooming when the viewport is inactive', () => {
|
|
||||||
const result = canZoomViewportIn(
|
|
||||||
{ start: 0, end: 0 },
|
|
||||||
{ start: 0, end: 0 },
|
|
||||||
zf
|
|
||||||
);
|
|
||||||
expect(result).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not allow zooming past the selection', () => {
|
|
||||||
const result = canZoomViewportIn(
|
|
||||||
{ start: 1000, end: 2000 },
|
|
||||||
{ start: 1100, end: 1900 },
|
|
||||||
zf
|
|
||||||
);
|
|
||||||
expect(result).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows zooming', () => {
|
|
||||||
const result = canZoomViewportIn(
|
|
||||||
{ start: 1000, end: 2000 },
|
|
||||||
{ start: 0, end: 100 },
|
|
||||||
zf
|
|
||||||
);
|
|
||||||
expect(result).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('canZoomViewportOut', () => {
|
|
||||||
it('does now allow zooming when the viewport is inactive', () => {
|
|
||||||
const result = canZoomViewportOut({ start: 0, end: 0 }, 1000);
|
|
||||||
expect(result).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does now allow zooming when already at maximum zoom', () => {
|
|
||||||
const result = canZoomViewportOut({ start: 0, end: 1000 }, 1000);
|
|
||||||
expect(result).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows zooming', () => {
|
|
||||||
const result = canZoomViewportOut({ start: 1000, end: 2000 }, 5000);
|
|
||||||
expect(result).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,104 +0,0 @@
|
||||||
import { Frames } from '../App';
|
|
||||||
|
|
||||||
export function zoomViewportIn(
|
|
||||||
viewport: Frames,
|
|
||||||
numFrames: number,
|
|
||||||
selection: Frames,
|
|
||||||
position: number,
|
|
||||||
factor: number
|
|
||||||
): Frames {
|
|
||||||
if (!canZoomViewportIn(viewport, selection, factor)) {
|
|
||||||
return viewport;
|
|
||||||
}
|
|
||||||
|
|
||||||
return zoom(
|
|
||||||
Math.round((viewport.end - viewport.start) / factor),
|
|
||||||
viewport,
|
|
||||||
numFrames,
|
|
||||||
selection,
|
|
||||||
position
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function zoomViewportOut(
|
|
||||||
viewport: Frames,
|
|
||||||
numFrames: number,
|
|
||||||
selection: Frames,
|
|
||||||
position: number,
|
|
||||||
factor: number
|
|
||||||
): Frames {
|
|
||||||
if (!canZoomViewportOut(viewport, numFrames)) {
|
|
||||||
return viewport;
|
|
||||||
}
|
|
||||||
|
|
||||||
return zoom(
|
|
||||||
Math.round((viewport.end - viewport.start) * factor),
|
|
||||||
viewport,
|
|
||||||
numFrames,
|
|
||||||
selection,
|
|
||||||
position
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function zoom(
|
|
||||||
newWidth: number,
|
|
||||||
viewport: Frames,
|
|
||||||
numFrames: number,
|
|
||||||
selection: Frames,
|
|
||||||
position: number
|
|
||||||
): Frames {
|
|
||||||
let newStart;
|
|
||||||
|
|
||||||
if (selection.start != selection.end) {
|
|
||||||
const selectionWidth = selection.end - selection.start;
|
|
||||||
|
|
||||||
// disallow zooming beyond the selection:
|
|
||||||
if (newWidth < selectionWidth) {
|
|
||||||
return viewport;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectionMidpoint = selection.end - selectionWidth / 2;
|
|
||||||
newStart = selectionMidpoint - Math.round(newWidth / 2);
|
|
||||||
} else {
|
|
||||||
newStart = position - newWidth / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newStart < 0) {
|
|
||||||
newStart = 0;
|
|
||||||
}
|
|
||||||
let newEnd = newStart + newWidth;
|
|
||||||
if (newEnd > numFrames) {
|
|
||||||
newEnd = numFrames;
|
|
||||||
newStart = Math.max(0, newEnd - newWidth);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { start: newStart, end: newEnd };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function canZoomViewportIn(
|
|
||||||
viewport: Frames,
|
|
||||||
selection: Frames,
|
|
||||||
factor: number
|
|
||||||
): boolean {
|
|
||||||
if (viewport.start == viewport.end) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selection.start != selection.end) {
|
|
||||||
const newWidth = Math.round((viewport.end - viewport.start) / factor);
|
|
||||||
return newWidth > selection.end - selection.start;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function canZoomViewportOut(
|
|
||||||
viewport: Frames,
|
|
||||||
numFrames: number
|
|
||||||
): boolean {
|
|
||||||
if (viewport.start == viewport.end) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return viewport.start > 0 || viewport.end < numFrames;
|
|
||||||
}
|
|
|
@ -1,7 +1,3 @@
|
||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
interface Frames {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VideoPosition {
|
||||||
|
currentTime: number;
|
||||||
|
percent: number;
|
||||||
|
}
|
|
@ -1,7 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
content: ['./src/**/*.{js,jsx,ts,tsx}'],
|
|
||||||
theme: {
|
|
||||||
extend: {},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
9975
frontend/yarn.lock
9975
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
|
@ -10,9 +10,6 @@ import "google/protobuf/duration.proto";
|
||||||
message MediaSet {
|
message MediaSet {
|
||||||
string id = 1;
|
string id = 1;
|
||||||
string youtube_id = 2;
|
string youtube_id = 2;
|
||||||
string title = 12;
|
|
||||||
string description = 13;
|
|
||||||
string author = 14;
|
|
||||||
|
|
||||||
int32 audio_channels = 3;
|
int32 audio_channels = 3;
|
||||||
int64 audio_approx_frames = 4;
|
int64 audio_approx_frames = 4;
|
||||||
|
@ -39,7 +36,6 @@ message GetPeaksProgress {
|
||||||
repeated int32 peaks = 1;
|
repeated int32 peaks = 1;
|
||||||
float percent_complete = 2;
|
float percent_complete = 2;
|
||||||
string url = 3;
|
string url = 3;
|
||||||
int64 audio_frames = 4;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
message GetPeaksForSegmentRequest {
|
message GetPeaksForSegmentRequest {
|
||||||
|
|
Loading…
Reference in New Issue