Compare commits

...

67 Commits

Author SHA1 Message Date
Rob Watson 36bd92608a Update Go to 1.19
continuous-integration/drone/push Build is passing Details
2022-08-05 19:53:54 +02:00
Rob Watson dbcc5a0cf6 Update drone.yml
continuous-integration/drone Build is passing Details
2022-05-18 18:17:31 +02:00
Rob Watson e434ad9a84 Remove fast-forward and rewind buttons 2022-05-18 18:16:51 +02:00
Rob Watson e40e794721 Improve SeekBar interface
continuous-integration/drone/push Build was killed Details
continuous-integration/drone Build was killed Details
2022-02-16 20:43:39 +01:00
Rob Watson 691099da3a Update backend dependencies
continuous-integration/drone/push Build is passing Details
2022-02-10 20:00:53 +01:00
Rob Watson 7eb53417ac Update frontend dependencies 2022-02-10 20:00:49 +01:00
Rob Watson 26b51b8c93 Combine Player and VideoPreview components.
This makes sense semantically and also simplifies the component
structure as it avoids leaking the Video reference outside of the
component (at least for now).
2022-02-10 20:00:45 +01:00
Rob Watson f2d7d1f5bb Bug fix: load waveform peaks after fetching audio from Youtube
continuous-integration/drone/push Build is passing Details
2022-02-07 20:17:23 +01:00
Rob Watson 669afcf6d9 Add test coverage for positionchanged event
continuous-integration/drone/push Build is failing Details
2022-02-07 19:29:17 +01:00
Rob Watson 29129afe90 Add test coverage for waveformselectionchanged event
continuous-integration/drone/push Build is passing Details
2022-02-07 07:55:47 +01:00
Rob Watson a0bb48fb69 Add basic test coverage for viewport handling
continuous-integration/drone/push Build is passing Details
2022-02-05 09:16:31 +01:00
Rob Watson a8ba36a0e1 GetAudio: avoid leaking goroutine on cancellation
continuous-integration/drone/push Build is passing Details
2022-02-05 07:46:52 +01:00
Rob Watson 54e9bc0d2c Pass context from gRPC streams to background tasks 2022-02-05 07:44:44 +01:00
Rob Watson 6dde29cdcf GetPeaksForSegment: add extra invalid arg check 2022-02-04 16:22:06 +01:00
Rob Watson a9ea462b41 Rename CanvasLogicalWidth to CanvasWidth
continuous-integration/drone/push Build is passing Details
2022-02-04 08:37:39 +01:00
Rob Watson 3dcc1edc62 Replace Selection interface with CanvasRange
continuous-integration/drone/push Build is passing Details
2022-02-04 08:35:54 +01:00
Rob Watson b64f0b4daa Start to add test coverage to AppState
continuous-integration/drone/push Build is passing Details
2022-02-04 08:10:33 +01:00
Rob Watson 9f76d2764f Refactor top-level state management => useReducer
continuous-integration/drone/push Build is passing Details
2022-02-03 19:56:05 +01:00
Rob Watson a855d589f3 Refactor HudCanvasState tests 2022-02-03 19:44:28 +01:00
Rob Watson 6ba19b3e01 Bug fix: prevent incorrect selectionChange callbacks
continuous-integration/drone/push Build is passing Details
When re-rendering the HudCanvas component, the selectionChange callback
should not be triggered with the passed-in properties. Doing so leads to
incorrect selection values being bubbled up when the selection is not
enclosed in the viewport.

The state management should probably be improved to avoid this dance
completely, possibly by hoisting all of this state up to the top-level.
2022-01-29 12:32:39 +01:00
Rob Watson bff15098e6 Enable grpc-web CORS origin checking
continuous-integration/drone/push Build is passing Details
2022-01-27 20:40:33 +01:00
Rob Watson cf90100c5f Add CORS headers to HTTP handlers
continuous-integration/drone/push Build is passing Details
2022-01-26 19:27:57 +01:00
Rob Watson 698b97e904 Update backend dependencies
continuous-integration/drone/push Build is passing Details
2022-01-26 07:32:34 +01:00
Rob Watson 404c11909b Bug fix: update waveform after fetching audio from Youtube
continuous-integration/drone/push Build is passing Details
2022-01-25 22:47:26 +01:00
Rob Watson 5a1ebb7c3a Send AudioFrames in gRPC message when available
continuous-integration/drone/push Build is passing Details
2022-01-25 20:06:19 +01:00
Rob Watson 48c84a7efa Bug fix: avoid NaN in helper 2022-01-25 20:06:15 +01:00
Rob Watson 5af8f0c319 HudCanvas: extract HudCanvasState
continuous-integration/drone/push Build is passing Details
2022-01-24 20:33:16 +01:00
Rob Watson 4f443af8fa HudCanvas: draw hover position
continuous-integration/drone/push Build is passing Details
2022-01-18 18:23:00 +01:00
Rob Watson 9ae4335b19 Disable buttons when zooming is not possible 2022-01-18 18:23:00 +01:00
Rob Watson a4e9ebca3b Update duration display on selection change 2022-01-18 18:23:00 +01:00
Rob Watson f386e12f72 Add option to trigger "selection changed" callback in realtime 2022-01-18 18:23:00 +01:00
Rob Watson bb3366ac9a Rename method, fix useEffect dependencies 2022-01-18 18:23:00 +01:00
Rob Watson 41fe0ce2b1 Centre zoom in/out on selection if availble 2022-01-18 18:23:00 +01:00
Rob Watson aa80c9eb7e Bug fix: avoid space bar conflict with player interface 2022-01-18 18:23:00 +01:00
Rob Watson 9d90ed51e6 Bug fix: ensure playback ends at selection end 2022-01-18 18:23:00 +01:00
Rob Watson a33057651d Update frontend with Tailwind.
- Replace inline CSS with Tailwind classes
- Improve page layout and scaling
- Add icons to ControlBar
- Small refactor of play/pause logic
- Add basic (not by any means final) colours
2022-01-18 18:23:00 +01:00
Rob Watson ec3ac8996d Add tailwindcss 2022-01-18 18:23:00 +01:00
Rob Watson d988a99f78 Update react-scripts -> 5.x 2022-01-18 18:23:00 +01:00
Rob Watson 58bbc06e6f Update deployment configuration to enable prefixes
continuous-integration/drone/push Build is passing Details
2022-01-18 17:56:37 +01:00
Rob Watson fbbb2e2fda config: Add prefix support and test coverage
continuous-integration/drone/push Build is passing Details
2022-01-18 08:23:16 +01:00
Rob Watson d136e00c59 Refactor zoom in/out, add test coverage
continuous-integration/drone/push Build is passing Details
2022-01-16 08:58:07 +01:00
Rob Watson aa4d235c0c frontend: reduce redraw rate to 20ms
continuous-integration/drone/push Build is passing Details
2022-01-15 10:14:04 +01:00
Rob Watson d8173cdace Add framesToDuration helper 2022-01-15 10:13:57 +01:00
Rob Watson ed964cb58f Add toHHMMSS helper 2022-01-15 10:13:52 +01:00
Rob Watson f33fa149fc Remove most useCallback usages
It is unclear whether these are actually significantly improving
performance and they add non-trivial complexity to the codebase,
especially when under heavy frontend development. Removing most of them
for now until it can be shown they are actually worthwhile.
2022-01-14 12:24:59 +01:00
Rob Watson 35b62f1e59 Fetch title, description and author from Youtube
continuous-integration/drone/push Build is passing Details
2022-01-13 20:05:09 +01:00
Rob Watson aabd0f3252 HudCanvas: add useCallbacks
continuous-integration/drone/push Build is passing Details
2022-01-13 07:59:48 +01:00
Rob Watson 5e27c3db9a Fix filename typo
continuous-integration/drone/push Build is passing Details
2022-01-10 21:51:17 +01:00
Rob Watson b0ccf17527 poc: legacy HTTP download for audio clips
continuous-integration/drone/push Build is passing Details
2022-01-10 21:35:31 +01:00
Rob Watson af0674eb11 Add POST /api/media_sets/:id/clip.
continuous-integration/drone Build is passing Details
This will allow for an HTTP/1.1 fallback for
MediaSetService.GetAudioSegment, enabling download of audio clips in
browsers that do not support the File System Access API.
2022-01-10 18:52:04 +01:00
Rob Watson c7d5541379 Refactor response handling.
- Add FileSystem implementation to handle HTTP index files but constrain
  directory listings
- Refactor server implementation into a dedicated handler struct
- Add test coverage
2022-01-10 18:45:10 +01:00
Rob Watson 8a26b75127 Revert "Disable directory listings in http.FileServer."
continuous-integration/drone/push Build is passing Details
The middleware approach breaks automatic handling of index files, which
in turn breaks assets serving. A custom file system will be required
instead.

This reverts commit 2377477188.
2022-01-10 08:39:03 +01:00
Rob Watson 2377477188 Disable directory listings in http.FileServer.
continuous-integration/drone/push Build is passing Details
Closes #6
2022-01-08 12:16:30 +01:00
Rob Watson 8e9a4cf8c3 Fix linter error
continuous-integration/drone/push Build is passing Details
2022-01-07 19:54:56 +01:00
Rob Watson 04601bab2e frontend: Add Zoom buttons
continuous-integration/drone/push Build is failing Details
2022-01-07 19:51:53 +01:00
Rob Watson 06fce9af95 Refactor MediaSetService.GetPeaksForSegment.
continuous-integration/drone/push Build is failing Details
- Fix bug where the function would return empty high bins when
  framesPerBin was a low value (< ~10)
- Improve readability
- Add error test cases
2022-01-07 13:34:18 +01:00
Rob Watson 5a4ee4e34f Add FFmpeg WorkerPool
continuous-integration/drone/push Build is passing Details
2022-01-05 19:49:47 +01:00
Rob Watson 33ee9645e7 Update backend dependencies
continuous-integration/drone/push Build is passing Details
2022-01-04 06:57:28 +01:00
Rob Watson 6cb462f769 Add test coverage for getVideoFromYoutube flow
continuous-integration/drone/push Build is passing Details
2022-01-04 06:51:25 +01:00
Rob Watson 932648a44b Add test coverage for getVideoFromFileStore flow
continuous-integration/drone/push Build is passing Details
2022-01-03 21:01:17 +01:00
Rob Watson 12e6e73976 Remove extraenous line
continuous-integration/drone/push Build is passing Details
2022-01-03 18:54:03 +01:00
Rob Watson 66c65694ae Add test coverage for getAudioFromYoutube flow
continuous-integration/drone/push Build is passing Details
2022-01-03 18:44:19 +01:00
Rob Watson 176a1cd8c1 Revert "FileStore.PutObject: Accept io.ReadCloser"
This turned out to actually make testing more difficult, as the
FileStore objects are generally mocked themselves and moving the Close()
call inside them introduced IO problems in the test suite.

This reverts commit a063f85eca.
2022-01-03 13:32:39 +01:00
Rob Watson a063f85eca FileStore.PutObject: Accept io.ReadCloser
continuous-integration/drone/push Build is passing Details
Accepting a ReadCloser in place of a Reader allows the FileSystem
implementation to handle closing the reader, which in turn simplifies
downstream code.
2022-01-03 09:57:49 +01:00
Michael Evans 959f5f0a2d Update README to include testing information
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2022-01-01 17:30:48 -06:00
Michael Evans 335efb23e1 Remove redundant function declaration from App.tsx
continuous-integration/drone/push Build is passing Details
2022-01-01 17:26:10 -06:00
Michael Evans 22dd92f339 Extract millisFromDuration helper and add tests
continuous-integration/drone/push Build is passing Details
2022-01-01 17:23:58 -06:00
76 changed files with 9329 additions and 8711 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

180
backend/server/handler.go Normal file
View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
css

View File

@ -0,0 +1 @@
foo

View File

@ -0,0 +1 @@
index

View File

@ -0,0 +1 @@
bar

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,28 +1,33 @@
import {
MediaSet,
GrpcWebImpl,
MediaSetServiceClientImpl,
GetVideoProgress,
GetPeaksProgress,
} from './generated/media_set';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useEffect, useCallback, useReducer } from 'react';
import { State, stateReducer, zoomFactor, PlayState } from './AppState';
import { AudioFormat } from './generated/media_set';
import { VideoPreview } from './VideoPreview';
import { Overview, CanvasLogicalWidth } from './Overview';
import { Waveform } from './Waveform';
import { WaveformCanvas } from './WaveformCanvas';
import { HudCanvas } from './HudCanvas';
import { Player } from './Player';
import {
CanvasWidth,
CanvasHeight,
EmptySelectionAction,
} from './HudCanvasState';
import { ControlBar } from './ControlBar';
import { SeekBar } from './SeekBar';
import './App.css';
import { Duration } from './generated/google/protobuf/duration';
import { firstValueFrom, from, Observable } from 'rxjs';
import { first, map } from 'rxjs/operators';
import { first, map, bufferCount } from 'rxjs/operators';
import { canZoomViewportIn, canZoomViewportOut } from './helpers/zoom';
import toHHMMSS from './helpers/toHHMMSS';
import framesToDuration from './helpers/framesToDuration';
import frameToWaveformCanvasX from './helpers/frameToWaveformCanvasX';
import { ClockIcon, ExternalLinkIcon } from '@heroicons/react/solid';
// ported from backend, where should they live?
const thumbnailWidth = 177;
const thumbnailHeight = 100;
const initialViewportCanvasPixels = 100;
const thumbnailWidth = 177; // height 100
const apiURL = process.env.REACT_APP_API_URL || 'http://localhost:8888';
@ -37,22 +42,34 @@ export interface VideoPosition {
percent: number;
}
const video = document.createElement('video');
const audio = document.createElement('audio');
const initialState: State = {
selection: { start: 0, end: 0 },
viewport: { start: 0, end: 0 },
overviewPeaks: from([]),
waveformPeaks: from([]),
selectionCanvas: { x1: 0, x2: 0 },
viewportCanvas: { x1: 0, x2: 0 },
position: { currentTime: 0, frame: 0, percent: 0 },
audioSrc: '',
videoSrc: '',
currentTime: 0,
playState: PlayState.Paused,
};
function App(): JSX.Element {
const [mediaSet, setMediaSet] = useState<MediaSet | null>(null);
const [viewport, setViewport] = useState<Frames>({ start: 0, end: 0 });
const [selection, setSelection] = useState<Frames>({ start: 0, end: 0 });
const [overviewPeaks, setOverviewPeaks] = useState<Observable<number[]>>(
from([])
);
const [state, dispatch] = useReducer(stateReducer, { ...initialState });
// position stores the current playback position. positionRef makes it
// available inside a setInterval callback.
const [position, setPosition] = useState({ currentTime: 0, percent: 0 });
const positionRef = useRef(position);
positionRef.current = position;
const {
mediaSet,
waveformPeaks,
overviewPeaks,
selection,
selectionCanvas,
viewport,
viewportCanvas,
position,
playState,
} = state;
// effects
@ -70,110 +87,78 @@ function App(): JSX.Element {
const mediaSet = await service.Get({ youtubeId: videoID });
console.log('got media set:', mediaSet);
setMediaSet(mediaSet);
dispatch({ type: 'mediasetloaded', mediaSet: mediaSet });
// fetch audio asynchronously
console.log('fetching audio...');
const audioProgressStream = service.GetPeaks({
id: mediaSet.id,
numBins: CanvasWidth,
});
const peaks = audioProgressStream.pipe(map((progress) => progress.peaks));
dispatch({ type: 'overviewpeaksloaded', peaks: peaks });
const audioPipe = audioProgressStream.pipe(
first((progress: GetPeaksProgress) => progress.url != '')
);
const fetchAudioTask = firstValueFrom(audioPipe);
// fetch video asynchronously
console.log('fetching video...');
const videoProgressStream = service.GetVideo({ id: mediaSet.id });
const videoPipe = videoProgressStream.pipe(
first((progress: GetVideoProgress) => progress.url != '')
);
const fetchVideoTask = firstValueFrom(videoPipe);
// wait for both audio, then video.
const audioProgress = await fetchAudioTask;
dispatch({
type: 'audiosourceloaded',
src: audioProgress.url,
numFrames: audioProgress.audioFrames,
});
const videoProgress = await fetchVideoTask;
dispatch({ type: 'videosourceloaded', src: videoProgress.url });
})();
}, []);
const updatePlayerPositionIntevalMillis = 30;
// setup player on first page load only:
// load waveform peaks on MediaSet change
useEffect(() => {
if (mediaSet == null) {
return;
}
(async function () {
const { mediaSet, viewport } = state;
const intervalID = setInterval(() => {
const currTime = audio.currentTime;
if (currTime == positionRef.current.currentTime) {
if (mediaSet == null) {
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();
if (viewport.start >= viewport.end) {
return;
}
// update the current position
setPosition({ currentTime: audio.currentTime, percent: percent });
}, updatePlayerPositionIntevalMillis);
const service = new MediaSetServiceClientImpl(newRPC());
const segment = await service.GetPeaksForSegment({
id: mediaSet.id,
numBins: CanvasWidth,
startFrame: viewport.start,
endFrame: viewport.end,
});
return () => clearInterval(intervalID);
}, [mediaSet, selection]);
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.
// selection is a dependency of the handleKeyPress handler, and must be
// included here.
useEffect(() => {
document.addEventListener('keypress', handleKeyPress);
return () => document.removeEventListener('keypress', handleKeyPress);
}, [selection]);
// load audio when MediaSet is loaded:
useEffect(() => {
(async function () {
if (mediaSet == null) {
return;
}
console.log('fetching audio...');
const service = new MediaSetServiceClientImpl(newRPC());
const audioProgressStream = service.GetPeaks({
id: mediaSet.id,
numBins: CanvasLogicalWidth,
});
const peaks = audioProgressStream.pipe(map((progress) => progress.peaks));
setOverviewPeaks(peaks);
const pipe = audioProgressStream.pipe(
first((progress: GetPeaksProgress) => progress.url != '')
);
const progressWithURL = await firstValueFrom(pipe);
audio.src = progressWithURL.url;
audio.muted = false;
audio.volume = 1;
console.log('set audio src', progressWithURL.url);
})();
}, [mediaSet]);
// load video when MediaSet is loaded:
useEffect(() => {
(async function () {
if (mediaSet == null) {
return;
}
console.log('fetching video...');
const service = new MediaSetServiceClientImpl(newRPC());
const videoProgressStream = service.GetVideo({ id: mediaSet.id });
const pipe = videoProgressStream.pipe(
first((progress: GetVideoProgress) => progress.url != '')
);
const progressWithURL = await firstValueFrom(pipe);
video.src = progressWithURL.url;
console.log('set video src', progressWithURL.url);
})();
}, [mediaSet]);
// set viewport when MediaSet is loaded:
useEffect(() => {
if (mediaSet == null) {
return;
}
const numFrames = Math.min(
Math.round(mediaSet.audioFrames / CanvasLogicalWidth) *
initialViewportCanvasPixels,
mediaSet.audioFrames
);
setViewport({ start: 0, end: numFrames });
}, [mediaSet]);
});
useEffect(() => {
console.debug('viewport updated', viewport);
@ -181,81 +166,64 @@ function App(): JSX.Element {
// handlers
const handleKeyPress = useCallback(
(evt: KeyboardEvent) => {
if (evt.code != 'Space') {
return;
}
const togglePlay = () => (playState == PlayState.Paused ? play() : pause());
const play = () => dispatch({ type: 'play' });
const pause = () => dispatch({ type: 'pause' });
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);
const handleKeyPress = (evt: KeyboardEvent) => {
if (evt.code != 'Space') {
return;
}
}, [audio, video, selection]);
togglePlay();
};
const handleClip = useCallback(() => {
const handleClip = () => {
if (!window.showSaveFilePicker) {
downloadClipHTTP();
return;
}
downloadClipFileSystemAccessAPI();
};
const downloadClipHTTP = () => {
(async function () {
console.debug('clip', selection);
if (mediaSet == null) {
return;
}
// TODO: support File System Access API fallback
console.debug('clip http', selection);
const form = document.createElement('form');
form.method = 'POST';
form.action = `${apiURL}/api/media_sets/${mediaSet.id}/clip`;
const startFrameInput = document.createElement('input');
startFrameInput.type = 'hidden';
startFrameInput.name = 'start_frame';
startFrameInput.value = String(selection.start);
form.appendChild(startFrameInput);
const endFrameInput = document.createElement('input');
endFrameInput.type = 'hidden';
endFrameInput.name = 'end_frame';
endFrameInput.value = String(selection.end);
form.appendChild(endFrameInput);
const formatInput = document.createElement('input');
formatInput.type = 'hidden';
formatInput.name = 'format';
formatInput.value = 'mp3';
form.appendChild(formatInput);
document.body.appendChild(form);
form.submit();
})();
};
const downloadClipFileSystemAccessAPI = () => {
(async function () {
if (mediaSet == null) {
return;
}
console.debug('clip grpc', selection);
const h = await window.showSaveFilePicker({ suggestedName: 'clip.mp3' });
const fileStream = await h.createWritable();
@ -274,49 +242,34 @@ function App(): JSX.Element {
await fileStream.close();
console.debug('closed stream');
})();
};
const durationString = useCallback((): string => {
if (!mediaSet || !mediaSet.videoDuration) {
return '';
}
const { selection } = state;
const totalDur = toHHMMSS(mediaSet.videoDuration);
if (selection.start == selection.end) {
return totalDur;
}
const clipDur = toHHMMSS(
framesToDuration(
selection.end - selection.start,
mediaSet.audioSampleRate
)
);
return `Selected ${clipDur} of ${totalDur}`;
}, [mediaSet, selection]);
const setPositionFromFrame = useCallback(
(frame: number) => {
if (mediaSet == null) {
return;
}
const ratio = frame / mediaSet.audioFrames;
const currentTime =
(mediaSet.audioFrames / mediaSet.audioSampleRate) * ratio;
audio.currentTime = currentTime;
video.currentTime = currentTime;
},
[mediaSet, audio, video]
);
// helpers
const currentTimeToFrame = useCallback(
(currentTime: number): number => {
if (mediaSet == null) {
return 0;
}
const dur = mediaSet.audioFrames / mediaSet.audioSampleRate;
const ratio = currentTime / dur;
return Math.round(mediaSet.audioFrames * ratio);
},
[mediaSet]
);
// render component
const containerStyles = {
border: '1px solid black',
width: '90%',
margin: '1em auto',
minHeight: '500px',
height: '700px',
display: 'flex',
flexDirection: 'column',
} as React.CSSProperties;
const offsetPixels = Math.floor(thumbnailWidth / 2);
const marginClass = 'mx-[88px]'; // offsetPixels
if (mediaSet == null) {
// TODO: improve
@ -325,65 +278,131 @@ function App(): JSX.Element {
return (
<>
<div className="App">
<div style={containerStyles}>
<ControlBar
onPlay={handlePlay}
onPause={handlePause}
onClip={handleClip}
/>
<div className="App bg-gray-800 h-screen flex flex-col">
<header className="bg-green-900 h-16 grow-0 flex items-center mb-12 px-[88px]">
<h1 className="text-3xl font-bold">Clipper</h1>
</header>
<div className="flex flex-col grow bg-gray-800 w-full h-full mx-auto">
<div className={`flex flex-col grow ${marginClass}`}>
<div className="flex grow-0 h-8 pt-4 pb-2 items-center space-x-2 text-white">
<span className="text-gray-300">{mediaSet.author}</span>
<span>/</span>
<span>{mediaSet.title}</span>
<a
href={`https://www.youtube.com/watch?v=${mediaSet.youtubeId}`}
target="_blank"
rel="noreferrer"
title="Open in YouTube"
>
<ExternalLinkIcon className="h-6 w-6 text-gray-500 hover:text-gray-200" />
</a>
<span className="flex grow justify-end text-gray-500">
<ClockIcon className="h-5 w-5 mr-1 mt-0.5" />
{durationString()}
</span>
</div>
<ControlBar
playState={playState}
zoomInEnabled={canZoomViewportIn(viewport, selection, zoomFactor)}
zoomOutEnabled={canZoomViewportOut(
viewport,
mediaSet.audioFrames
)}
onTogglePlay={togglePlay}
onClip={handleClip}
onZoomIn={() => dispatch({ type: 'zoomin' })}
onZoomOut={() => dispatch({ type: 'zoomout' })}
downloadClipEnabled={selection.start != selection.end}
/>
<Overview
peaks={overviewPeaks}
mediaSet={mediaSet}
offsetPixels={offsetPixels}
height={80}
viewport={viewport}
position={position}
onSelectionChange={handleOverviewSelectionChange}
/>
<div className="w-full bg-gray-600 h-6"></div>
<Waveform
mediaSet={mediaSet}
position={position}
viewport={viewport}
offsetPixels={offsetPixels}
onSelectionChange={handleWaveformSelectionChange}
/>
<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={video.currentTime}
position={position.currentTime}
duration={mediaSet.audioFrames / mediaSet.audioSampleRate}
offsetPixels={offsetPixels}
onPositionChanged={(position: number) => {
video.currentTime = position;
audio.currentTime = position;
onPositionChanged={(currentTime: number) => {
dispatch({ type: 'skip', currentTime });
}}
/>
<VideoPreview
<Player
mediaSet={mediaSet}
video={video}
position={position}
duration={millisFromDuration(mediaSet.videoDuration)}
height={thumbnailHeight}
playState={playState}
audioSrc={state.audioSrc}
videoSrc={state.videoSrc}
currentTime={state.currentTime}
onPositionChanged={(currentTime) =>
dispatch({ type: 'positionchanged', currentTime: currentTime })
}
/>
</div>
<ul style={{ listStyleType: 'none' } as React.CSSProperties}>
<li>Frames: {mediaSet.audioFrames}</li>
<li>
Viewport (frames): {viewport.start} to {viewport.end}
</li>
<li>
Selection (frames): {selection.start} to {selection.end}
</li>
<li>
Position (frames):{' '}
{Math.round(mediaSet.audioFrames * (position.percent / 100))}
</li>
<li>Position (seconds): {position.currentTime}</li>
<li></li>
</ul>
</div>
</>
);
@ -391,13 +410,6 @@ function App(): JSX.Element {
export default App;
function millisFromDuration(dur?: Duration): number {
if (dur == undefined) {
return 0;
}
return Math.floor(dur.seconds * 1000.0 + dur.nanos / 1000.0 / 1000.0);
}
export function newRPC(): GrpcWebImpl {
return new GrpcWebImpl(apiURL, {});
}

View File

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

424
frontend/src/AppState.tsx Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

155
frontend/src/Player.tsx Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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