mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-09 10:00:27 +00:00
feat: add FFmpeg WASM runtime (#1512)
This commit is contained in:
31 files changed
+2305
-24
No files matched your search
@@ -0,0 +1,128 @@
|
||||
# Extension FFmpeg runtime
|
||||
|
||||
`gopeed.runtime.ffmpeg.merge(options)` returns a `ReadableStream<Uint8Array>`.
|
||||
It merges one video track and one audio track without re-encoding, using
|
||||
[go-ffmpreg](https://codeberg.org/gruf/go-ffmpreg)'s embedded FFmpeg 5.1.10 and
|
||||
wazero. No system FFmpeg installation is required. go-ffmpreg and its embedded
|
||||
GPL-enabled FFmpeg are distributed under GPLv3; see the pinned dependency's
|
||||
LICENSE, README and build scripts for its source and included components.
|
||||
|
||||
## HTTP inputs and one downloadable output
|
||||
|
||||
```js
|
||||
gopeed.events.onResolve(async (ctx) => {
|
||||
// Resolve these URLs and headers using the site's API in your extension.
|
||||
const video = { url: videoURL, headers: { Referer: pageURL } };
|
||||
const audio = { url: audioURL, headers: { Referer: pageURL } };
|
||||
const url = await gopeed.runtime.blob.createObjectURL(
|
||||
() => gopeed.runtime.ffmpeg.merge({ video, audio }),
|
||||
{ contentType: "video/mp4" }
|
||||
);
|
||||
ctx.res = {
|
||||
name: "video",
|
||||
files: [{ name: "video.mp4", req: { url } }]
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
The opener creates a new merge on each open. Output size is unknown and range
|
||||
support must remain disabled. Do not declare the sum of the input sizes as the
|
||||
output size. The downloader reads the merged output as one file; FFmpeg errors
|
||||
propagate through the stream instead of completing a partial download.
|
||||
|
||||
## JS inputs, including SABR
|
||||
|
||||
Stream inputs **must** be created by an `inputs({ signal })` callback. It returns
|
||||
`{ video, audio }`, each an HTTP descriptor or `ReadableStream<Uint8Array>`,
|
||||
including mixed inputs. Do not start producers before this callback. SABR/UMP decoding belongs
|
||||
to the extension: pass the resulting media streams, with initialization data,
|
||||
not the SABR protocol bytes. Keep each selected track's codec/configuration
|
||||
stable for the duration of a merge.
|
||||
|
||||
```js
|
||||
const output = gopeed.runtime.ffmpeg.merge({
|
||||
inputs: async ({ signal }) => {
|
||||
const session = await prepareSession({ signal });
|
||||
const { videoStream, audioStream, abort } = await session.openStreams();
|
||||
if (signal.aborted) { abort(); throw new Error("Cancelled"); }
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
return { video: videoStream, audio: audioStream };
|
||||
},
|
||||
signal: abortController.signal,
|
||||
args: ["-shortest", "-metadata", "title=Example"]
|
||||
});
|
||||
```
|
||||
|
||||
Creating the output does not start requests or acquire input readers until the
|
||||
output is pulled and a global execution slot is acquired. Queued jobs do not
|
||||
open HTTP inputs, invoke input factories, or allocate media ring buffers.
|
||||
The callback runs once per output; its signal aborts on cancellation, engine
|
||||
shutdown, failure, and completion. A callback must cooperate with cancellation;
|
||||
returned streams are cancelled even if the callback finishes after cancellation.
|
||||
A stream input is single-use. To retry a Blob-backed task,
|
||||
its opener must create a new SABR session and fresh input streams. Arbitrary
|
||||
output byte ranges / resumable merged output are not supported.
|
||||
|
||||
`gopeed.runtime.ffmpeg.supportsInputFactory === true` identifies this API.
|
||||
Older builds accept eager streams and must be upgraded before using callbacks.
|
||||
|
||||
## Options
|
||||
|
||||
- `inputs`: callback returning `{ video, audio }`, synchronously or asynchronously.
|
||||
Required for stream inputs. Cannot be combined with top-level `video`/`audio`.
|
||||
- `video`, `audio`: HTTP descriptors only when supplied at the top level; both
|
||||
are required unless `inputs` is supplied. HTTP descriptors have `url` and optional
|
||||
`headers` (standard `HeadersInit`). HTTP requests use Gopeed's configured
|
||||
proxy. Supply required cookies/authorization explicitly; browser or `fetch`
|
||||
cookie jars are not implicitly shared. When `User-Agent` is absent, requests
|
||||
use the downloader HTTP protocol's configured UA, captured when the extension
|
||||
engine is created, matching fetch and XHR. Explicit UA values (including an
|
||||
empty string) take precedence.
|
||||
- `format`: defaults to `"mp4"`. MP4 output always enables fragmentation
|
||||
(`frag_keyframe+empty_moov+default_base_moof`) for non-seekable output. The
|
||||
other accepted muxers are `"mkv"`/`"matroska"`, `"webm"`, and `"mpegts"`.
|
||||
Codec/container incompatibility is an error, not an automatic conversion.
|
||||
Choose the matching filename and Blob content type in the extension.
|
||||
- `args`: optional array of **output options**, not a shell command or a full
|
||||
FFmpeg invocation. Defaults select `0:v:0`, `1:a:0`, and `-c copy`. Additional
|
||||
`-map` options are additive. Allowed options are listed in `outputOptions` in
|
||||
`runtime.go`: metadata, dispositions, bitstream filters, duration/shortest,
|
||||
fragmentation, timestamp/interleaving options and `-strict`. Codec options
|
||||
must remain `copy`. Extra inputs, output paths, reports and arbitrary global
|
||||
options are rejected. Required MP4 fragmentation is enforced by the host.
|
||||
- `signal`: optional `AbortSignal`. Aborting or cancelling the output stops
|
||||
network requests and native execution, cancels input readers, and removes
|
||||
temporary buffers. Await/consume the output inside a live engine or retain
|
||||
it through a Blob URL; unowned work ends with the extension engine.
|
||||
|
||||
The backend defaults to strict input errors (`-xerror`). Native errors include
|
||||
the FFmpeg exit code and a bounded stderr tail. No host directories are exposed
|
||||
to the WASM module; it can access only its two input files and standard output.
|
||||
|
||||
## Input behavior and resource limits
|
||||
|
||||
HTTP inputs start with a 1 MiB Range GET, reusing the bytes for media reads.
|
||||
A validated 206 response with a known total size enables seeking; a 200
|
||||
response is consumed as-is, sequentially. Subsequent ranges verify positions,
|
||||
lengths, total size and available ETag/Last-Modified validators. Without a
|
||||
server validator, same-size remote changes cannot be reliably detected.
|
||||
Content encoding is forced to identity. Authentication/status failures and
|
||||
malformed range responses are errors. There is no full-file seek fallback.
|
||||
|
||||
Sequential JS inputs are drained independently into memory ring buffers that
|
||||
grow on demand up to **32 MiB per input**, independent of total media size.
|
||||
No temporary files are used. This does not enable seeking. Full buffers apply
|
||||
async backpressure until space is available or the operation is cancelled.
|
||||
Extensions must propagate backpressure through their upstream queues and
|
||||
network reads. Independent upstream track requests avoid blocking one track
|
||||
behind another in a shared response. Buffers are released on close.
|
||||
Blob output writes also wait asynchronously for bounded downstream capacity,
|
||||
so a slow consumer does not block the extension event loop.
|
||||
|
||||
At most two WASM merges execute concurrently per process, with a 512 MiB
|
||||
linear-memory limit per instance. Compilation is shared and lazy; the upstream
|
||||
embedded package still decompresses its WASM at process initialization. Native
|
||||
compilation memory, host buffers and JS allocations are additional to the
|
||||
linear-memory limit. Playback support depends on the selected codecs, not just
|
||||
the `.mp4` suffix. Platform execution and performance must be tested on target
|
||||
devices, especially mobile/32-bit devices.
|
||||
@@ -0,0 +1,227 @@
|
||||
// Package ffmpeg runs an embedded FFmpeg with host-controlled media inputs.
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const httpBlockSize = 1024 * 1024
|
||||
|
||||
// Input is a file visible to FFmpeg. Only verified HTTP Range sources seek.
|
||||
type Input interface {
|
||||
io.ReadCloser
|
||||
Seek(int64, int) (int64, error)
|
||||
Size() int64
|
||||
Seekable() bool
|
||||
}
|
||||
|
||||
var ErrSeek = errors.New("ffmpeg: input requires seeking but source is sequential")
|
||||
|
||||
type HTTPSource struct {
|
||||
URL string `json:"url"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
}
|
||||
|
||||
type httpInput struct {
|
||||
ctx context.Context
|
||||
client *http.Client
|
||||
source HTTPSource
|
||||
body io.ReadCloser
|
||||
size, pos, blockStart int64
|
||||
block []byte
|
||||
ranged bool
|
||||
etag, modified string
|
||||
}
|
||||
|
||||
// OpenHTTP combines capability detection with the first media read. A 200
|
||||
// response remains open and is consumed sequentially without another request.
|
||||
func OpenHTTP(ctx context.Context, client *http.Client, source HTTPSource) (Input, error) {
|
||||
u, err := url.Parse(source.URL)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return nil, errors.New("ffmpeg: expected an HTTP(S) input URL")
|
||||
}
|
||||
h := &httpInput{ctx: ctx, client: client, source: source, size: -1}
|
||||
resp, err := h.request(0, httpBlockSize-1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
if err = identityEncoding(resp); err != nil {
|
||||
resp.Body.Close()
|
||||
return nil, err
|
||||
}
|
||||
h.body, h.size = resp.Body, resp.ContentLength
|
||||
return h, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusPartialContent {
|
||||
return nil, fmt.Errorf("ffmpeg: HTTP input returned %d", resp.StatusCode)
|
||||
}
|
||||
start, end, size, err := contentRange(resp.Header.Get("Content-Range"))
|
||||
if err != nil || start != 0 || end >= httpBlockSize {
|
||||
return nil, errors.New("ffmpeg: invalid initial Content-Range")
|
||||
}
|
||||
h.size, h.ranged = size, true
|
||||
h.etag = resp.Header.Get("ETag")
|
||||
if strings.HasPrefix(h.etag, "W/") {
|
||||
h.etag = ""
|
||||
}
|
||||
h.modified = resp.Header.Get("Last-Modified")
|
||||
h.block, err = readBlock(resp, end-start+1)
|
||||
return h, err
|
||||
}
|
||||
|
||||
func (h *httpInput) request(start, end int64) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(h.ctx, http.MethodGet, h.source.URL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range h.source.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
// Offsets must refer to the actual media bytes, not a decompressed response.
|
||||
req.Header.Set("Accept-Encoding", "identity")
|
||||
req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", start, end))
|
||||
if h.etag != "" {
|
||||
req.Header.Set("If-Range", h.etag)
|
||||
} else if h.modified != "" {
|
||||
req.Header.Set("If-Range", h.modified)
|
||||
}
|
||||
return h.client.Do(req)
|
||||
}
|
||||
|
||||
func identityEncoding(resp *http.Response) error {
|
||||
if enc := resp.Header.Get("Content-Encoding"); enc != "" && !strings.EqualFold(enc, "identity") {
|
||||
return errors.New("ffmpeg: encoded HTTP responses cannot represent media byte offsets")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func contentRange(value string) (start, end, size int64, err error) {
|
||||
err = errors.New("invalid Content-Range")
|
||||
if !strings.HasPrefix(value, "bytes ") {
|
||||
return
|
||||
}
|
||||
parts := strings.Split(strings.TrimPrefix(value, "bytes "), "/")
|
||||
if len(parts) != 2 {
|
||||
return
|
||||
}
|
||||
bounds := strings.Split(parts[0], "-")
|
||||
if len(bounds) != 2 {
|
||||
return
|
||||
}
|
||||
var e error
|
||||
if start, e = strconv.ParseInt(bounds[0], 10, 64); e != nil {
|
||||
return
|
||||
}
|
||||
if end, e = strconv.ParseInt(bounds[1], 10, 64); e != nil {
|
||||
return
|
||||
}
|
||||
if size, e = strconv.ParseInt(parts[1], 10, 64); e != nil {
|
||||
return
|
||||
}
|
||||
if start < 0 || end < start || size <= end {
|
||||
return
|
||||
}
|
||||
err = nil
|
||||
return
|
||||
}
|
||||
|
||||
func readBlock(resp *http.Response, length int64) ([]byte, error) {
|
||||
if err := identityEncoding(resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.ContentLength >= 0 && resp.ContentLength != length {
|
||||
return nil, errors.New("ffmpeg: Range response length mismatch")
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, length+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(b)) != length {
|
||||
return nil, errors.New("ffmpeg: truncated or oversized Range response")
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (h *httpInput) Read(p []byte) (int, error) {
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if err := h.ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !h.ranged {
|
||||
return h.body.Read(p)
|
||||
}
|
||||
if h.pos >= h.size {
|
||||
return 0, io.EOF
|
||||
}
|
||||
if h.pos < h.blockStart || h.pos >= h.blockStart+int64(len(h.block)) {
|
||||
end := min(h.pos+httpBlockSize-1, h.size-1)
|
||||
resp, err := h.request(h.pos, end)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusPartialContent {
|
||||
return 0, fmt.Errorf("ffmpeg: Range request returned %d; source changed or stopped supporting Range", resp.StatusCode)
|
||||
}
|
||||
start, actualEnd, size, err := contentRange(resp.Header.Get("Content-Range"))
|
||||
if err != nil || start != h.pos || actualEnd > end || size != h.size {
|
||||
return 0, errors.New("ffmpeg: inconsistent Content-Range")
|
||||
}
|
||||
if h.etag != "" && resp.Header.Get("ETag") != h.etag {
|
||||
return 0, errors.New("ffmpeg: HTTP input ETag changed")
|
||||
}
|
||||
if h.etag == "" && h.modified != "" && resp.Header.Get("Last-Modified") != h.modified {
|
||||
return 0, errors.New("ffmpeg: HTTP input Last-Modified changed")
|
||||
}
|
||||
h.block, err = readBlock(resp, actualEnd-start+1)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
h.blockStart = start
|
||||
}
|
||||
n := copy(p, h.block[h.pos-h.blockStart:])
|
||||
h.pos += int64(n)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (h *httpInput) Seek(offset int64, whence int) (int64, error) {
|
||||
if !h.ranged {
|
||||
return 0, ErrSeek
|
||||
}
|
||||
base := int64(0)
|
||||
switch whence {
|
||||
case io.SeekStart:
|
||||
case io.SeekCurrent:
|
||||
base = h.pos
|
||||
case io.SeekEnd:
|
||||
base = h.size
|
||||
default:
|
||||
return 0, errors.New("invalid seek origin")
|
||||
}
|
||||
next := base + offset
|
||||
if next < 0 || (offset > 0 && next < base) {
|
||||
return 0, errors.New("invalid seek offset")
|
||||
}
|
||||
h.pos = next
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func (h *httpInput) Size() int64 { return h.size }
|
||||
func (h *httpInput) Seekable() bool { return h.ranged }
|
||||
func (h *httpInput) Close() error {
|
||||
if h.body != nil {
|
||||
return h.body.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHTTPRange(t *testing.T) {
|
||||
data := bytes.Repeat([]byte("0123456789"), 300000)
|
||||
var requests atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
count := requests.Add(1)
|
||||
if r.Header.Get("X-Test") != "secret" {
|
||||
t.Error("missing headers")
|
||||
}
|
||||
if r.Header.Get("Accept-Encoding") != "identity" {
|
||||
t.Error("missing identity encoding")
|
||||
}
|
||||
if count > 1 && r.Header.Get("If-Range") != `"v1"` {
|
||||
t.Error("missing If-Range")
|
||||
}
|
||||
w.Header().Set("ETag", `"v1"`)
|
||||
http.ServeContent(w, r, "media", time.Time{}, bytes.NewReader(data))
|
||||
}))
|
||||
defer srv.Close()
|
||||
in, err := OpenHTTP(context.Background(), srv.Client(), HTTPSource{URL: srv.URL, Headers: map[string]string{"X-Test": "secret"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer in.Close()
|
||||
if !in.Seekable() || in.Size() != int64(len(data)) {
|
||||
t.Fatal("range not detected")
|
||||
}
|
||||
for _, off := range []int64{0, int64(len(data) - 16), 12, int64(httpBlockSize + 35)} {
|
||||
if _, err = in.Seek(off, io.SeekStart); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := make([]byte, 16)
|
||||
if _, err = io.ReadFull(in, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(b, data[off:off+16]) {
|
||||
t.Fatalf("wrong bytes at %d", off)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPSequentialAndValidation(t *testing.T) {
|
||||
for _, kind := range []string{"sequential", "bad-range", "changed", "truncated", "unauthorized"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
var requests atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
count := requests.Add(1)
|
||||
switch kind {
|
||||
case "sequential":
|
||||
w.Write([]byte("media"))
|
||||
case "unauthorized":
|
||||
w.WriteHeader(403)
|
||||
case "bad-range":
|
||||
w.Header().Set("Content-Range", "bytes 2-6/10")
|
||||
w.WriteHeader(206)
|
||||
w.Write([]byte("media"))
|
||||
case "truncated":
|
||||
w.Header().Set("Content-Range", "bytes 0-9/10")
|
||||
w.WriteHeader(206)
|
||||
w.Write([]byte("media"))
|
||||
case "changed":
|
||||
if count == 1 {
|
||||
w.Header().Set("Content-Range", "bytes 0-4/10")
|
||||
w.Header().Set("ETag", `"v1"`)
|
||||
w.WriteHeader(206)
|
||||
}
|
||||
w.Write([]byte("media"))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
in, err := OpenHTTP(context.Background(), srv.Client(), HTTPSource{URL: srv.URL})
|
||||
if kind == "sequential" {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer in.Close()
|
||||
if in.Seekable() {
|
||||
t.Fatal("unexpected seeking")
|
||||
}
|
||||
if _, err = in.Seek(0, 0); err != ErrSeek {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := io.ReadAll(in)
|
||||
if err != nil || string(b) != "media" || requests.Load() != 1 {
|
||||
t.Fatalf("%q %v count %d", b, err, requests.Load())
|
||||
}
|
||||
} else if kind == "changed" {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer in.Close()
|
||||
in.Seek(5, 0)
|
||||
if _, err = in.Read(make([]byte, 2)); err == nil {
|
||||
t.Fatal("accepted 200 after range")
|
||||
}
|
||||
} else if err == nil {
|
||||
in.Close()
|
||||
t.Fatal("accepted invalid response")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamRing(t *testing.T) {
|
||||
s := NewStreamInput(context.Background())
|
||||
defer s.Close()
|
||||
// Start near the boundary to exercise wrap-around without a huge fixture.
|
||||
s.buffer = make([]byte, 256*1024)
|
||||
s.read = int64(len(s.buffer)) - 3
|
||||
s.written = s.read
|
||||
if _, err := s.Write([]byte("abcdefgh")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.End(nil)
|
||||
b, err := io.ReadAll(s)
|
||||
if err != nil || string(b) != "abcdefgh" {
|
||||
t.Fatalf("%q %v", b, err)
|
||||
}
|
||||
if err = s.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.buffer != nil {
|
||||
t.Fatal("buffer retained after close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputArguments(t *testing.T) {
|
||||
a, err := Arguments("", []string{"-shortest", "-metadata", "title=test"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(strings.Join(a, " "), "-f mp4 pipe:1") {
|
||||
t.Fatal(a)
|
||||
}
|
||||
for _, args := range [][]string{{"-i", "/etc/passwd"}, {"-report"}, {"-c:v", "libx264"}, {"-metadata"}, {"other.mp4"}} {
|
||||
if _, err := Arguments("mp4", args); err == nil {
|
||||
t.Fatal(fmt.Sprint(args))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamBackpressureAndCancel(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
s := NewStreamInput(ctx)
|
||||
defer s.Close()
|
||||
s.buffer = make([]byte, StreamBufferLimit)
|
||||
s.written = StreamBufferLimit
|
||||
finished := make(chan error, 1)
|
||||
go func() { _, err := s.Write([]byte{42}); finished <- err }()
|
||||
select {
|
||||
case err := <-finished:
|
||||
t.Fatalf("did not apply backpressure: %v", err)
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
}
|
||||
if _, err := s.Read(make([]byte, 1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case err := <-finished:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("writer did not resume")
|
||||
}
|
||||
go func() { _, err := s.Write([]byte{43}); finished <- err }()
|
||||
cancel()
|
||||
select {
|
||||
case err := <-finished:
|
||||
if err == nil {
|
||||
t.Fatal("cancelled write succeeded")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("writer did not cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamMemoryGrowthAndWrap(t *testing.T) {
|
||||
s := NewStreamInput(context.Background())
|
||||
defer s.Close()
|
||||
if len(s.buffer) != 0 {
|
||||
t.Fatal("buffer allocated before first write")
|
||||
}
|
||||
a := bytes.Repeat([]byte{1}, 200*1024)
|
||||
b := bytes.Repeat([]byte{2}, 180*1024)
|
||||
s.Write(a)
|
||||
consumed := make([]byte, 180*1024)
|
||||
if _, err := io.ReadFull(s, consumed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Write(b) // Wrap in the initial 256 KiB allocation.
|
||||
c := bytes.Repeat([]byte{3}, 300*1024)
|
||||
s.Write(c) // Grow while unread data wraps around the old ring.
|
||||
s.End(nil)
|
||||
got, err := io.ReadAll(s)
|
||||
want := append(append(append([]byte{}, a[len(consumed):]...), b...), c...)
|
||||
if err != nil || !bytes.Equal(got, want) {
|
||||
t.Fatalf("growth corrupted data: %v", err)
|
||||
}
|
||||
if len(s.buffer) > StreamBufferLimit {
|
||||
t.Fatal("buffer exceeded limit")
|
||||
}
|
||||
s.Close()
|
||||
if s.buffer != nil {
|
||||
t.Fatal("buffer retained on close")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
ffembed "codeberg.org/gruf/go-ffmpreg/embed"
|
||||
"codeberg.org/gruf/go-ffmpreg/wasm"
|
||||
"github.com/tetratelabs/wazero"
|
||||
expsys "github.com/tetratelabs/wazero/experimental/sys"
|
||||
"github.com/tetratelabs/wazero/experimental/sysfs"
|
||||
"github.com/tetratelabs/wazero/sys"
|
||||
)
|
||||
|
||||
var shared struct {
|
||||
once sync.Once
|
||||
runtime wazero.Runtime
|
||||
module wazero.CompiledModule
|
||||
err error
|
||||
}
|
||||
|
||||
// Limit active instances independently of the number of extension engines.
|
||||
var slots = make(chan struct{}, 2)
|
||||
|
||||
func initialize() {
|
||||
shared.once.Do(func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
shared.err = fmt.Errorf("ffmpeg initialization: %v", r)
|
||||
}
|
||||
}()
|
||||
cfg := wazero.NewRuntimeConfig().WithCloseOnContextDone(true).WithMemoryLimitPages(8192)
|
||||
shared.runtime, shared.err = wasm.NewRuntime(context.Background(), cfg)
|
||||
if shared.err != nil {
|
||||
return
|
||||
}
|
||||
shared.module, shared.err = shared.runtime.CompileModule(context.Background(), ffembed.B())
|
||||
if shared.err != nil {
|
||||
shared.runtime.Close(context.Background())
|
||||
return
|
||||
}
|
||||
ffembed.Free()
|
||||
})
|
||||
}
|
||||
|
||||
// Output arguments deliberately do not accept extra inputs, output paths or
|
||||
// global filesystem/logging options. The host owns all I/O and cancellation.
|
||||
var outputOptions = map[string]int{
|
||||
"-c": 1, "-codec": 1, "-c:v": 1, "-c:a": 1, "-codec:v": 1, "-codec:a": 1,
|
||||
"-map": 1, "-map_metadata": 1, "-map_chapters": 1, "-metadata": 1, "-metadata:s:a:0": 1, "-metadata:s:v:0": 1,
|
||||
"-movflags": 1, "-frag_duration": 1, "-frag_size": 1, "-min_frag_duration": 1,
|
||||
"-bsf:v": 1, "-bsf:a": 1, "-strict": 1, "-shortest": 0, "-t": 1, "-to": 1,
|
||||
"-avoid_negative_ts": 1, "-max_interleave_delta": 1, "-flush_packets": 1,
|
||||
"-disposition:a:0": 1, "-disposition:v:0": 1,
|
||||
}
|
||||
|
||||
func Arguments(format string, extra []string) ([]string, error) {
|
||||
if format == "" {
|
||||
format = "mp4"
|
||||
}
|
||||
if format == "mkv" {
|
||||
format = "matroska"
|
||||
}
|
||||
switch format {
|
||||
case "mp4", "matroska", "webm", "mpegts":
|
||||
default:
|
||||
return nil, fmt.Errorf("ffmpeg: unsupported output format %q", format)
|
||||
}
|
||||
for i := 0; i < len(extra); {
|
||||
count, ok := outputOptions[extra[i]]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("ffmpeg: unsupported output option %q", extra[i])
|
||||
}
|
||||
if i+count >= len(extra) {
|
||||
return nil, fmt.Errorf("ffmpeg: missing value for %s", extra[i])
|
||||
}
|
||||
if strings.HasPrefix(extra[i], "-c") && extra[i+1] != "copy" {
|
||||
return nil, errors.New("ffmpeg: merge only supports stream copy")
|
||||
}
|
||||
i += count + 1
|
||||
}
|
||||
args := []string{"-hide_banner", "-nostdin", "-xerror", "-loglevel", "error", "-protocol_whitelist", "file,pipe", "-i", "/inputs/video", "-protocol_whitelist", "file,pipe", "-i", "/inputs/audio", "-map", "0:v:0", "-map", "1:a:0", "-c", "copy"}
|
||||
fragmentFlags := ""
|
||||
for i := 0; i < len(extra); {
|
||||
count := outputOptions[extra[i]]
|
||||
if format == "mp4" && extra[i] == "-movflags" {
|
||||
if strings.Contains(extra[i+1], "faststart") || strings.Contains(extra[i+1], "global_sidx") {
|
||||
return nil, errors.New("ffmpeg: these movflags require seekable output")
|
||||
}
|
||||
fragmentFlags = extra[i+1]
|
||||
} else {
|
||||
args = append(args, extra[i:i+count+1]...)
|
||||
}
|
||||
i += count + 1
|
||||
}
|
||||
if format == "mp4" {
|
||||
args = append(args, "-movflags", fragmentFlags+"+frag_keyframe+empty_moov+default_base_moof")
|
||||
}
|
||||
args = append(args, "-f", format, "pipe:1")
|
||||
return args, nil
|
||||
}
|
||||
|
||||
// Run shares compilation, but gives every merge its own memory and filesystem.
|
||||
// A clean stdout EOF is only delivered by the caller after this succeeds.
|
||||
func Run(ctx context.Context, video, audio Input, out io.Writer, format string, extra []string) error {
|
||||
_, err := Arguments(format, extra)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
release, err := Acquire(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
return RunAcquired(ctx, video, audio, out, format, extra)
|
||||
}
|
||||
|
||||
// Acquire reserves execution capacity before input factories or HTTP requests start.
|
||||
func Acquire(ctx context.Context) (func(), error) {
|
||||
select {
|
||||
case slots <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
var once sync.Once
|
||||
return func() { once.Do(func() { <-slots }) }, nil
|
||||
}
|
||||
|
||||
// RunAcquired requires a reservation held until this call returns.
|
||||
func RunAcquired(ctx context.Context, video, audio Input, out io.Writer, format string, extra []string) error {
|
||||
args, err := Arguments(format, extra)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ready := make(chan struct{})
|
||||
go func() { initialize(); close(ready) }()
|
||||
select {
|
||||
case <-ready:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
if shared.err != nil {
|
||||
return shared.err
|
||||
}
|
||||
files := &inputFS{files: map[string]Input{"video": video, "audio": audio}}
|
||||
fsconfig := wazero.NewFSConfig().(sysfs.FSConfig).WithSysFSMount(files, "/inputs")
|
||||
logs := &tailLog{}
|
||||
rc, err := wasm.Run(ctx, shared.runtime, shared.module, wasm.Args{
|
||||
Name: "ffmpeg", Args: args, Stdout: out, Stderr: logs,
|
||||
Config: func(c wazero.ModuleConfig) wazero.ModuleConfig { return c.WithFSConfig(fsconfig) },
|
||||
})
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if files.err != nil {
|
||||
return fmt.Errorf("ffmpeg input: %w (%s)", files.err, logs.String())
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("ffmpeg runtime: %w (%s)", err, logs.String())
|
||||
}
|
||||
if rc != 0 {
|
||||
return fmt.Errorf("ffmpeg exited with code %d: %s", rc, logs.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type tailLog struct{ data []byte }
|
||||
|
||||
func (l *tailLog) Write(p []byte) (int, error) {
|
||||
n := len(p)
|
||||
if len(p) > 8192 {
|
||||
p = p[len(p)-8192:]
|
||||
}
|
||||
l.data = append(l.data, p...)
|
||||
if len(l.data) > 8192 {
|
||||
l.data = l.data[len(l.data)-8192:]
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
func (l *tailLog) String() string { return strings.TrimSpace(string(l.data)) }
|
||||
|
||||
type inputFS struct {
|
||||
expsys.UnimplementedFS
|
||||
files map[string]Input
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *inputFS) OpenFile(path string, flag expsys.Oflag, perm fs.FileMode) (expsys.File, expsys.Errno) {
|
||||
if flag&(expsys.O_WRONLY|expsys.O_RDWR|expsys.O_CREAT|expsys.O_TRUNC) != 0 {
|
||||
return nil, expsys.EROFS
|
||||
}
|
||||
path = strings.TrimPrefix(path, "/")
|
||||
if path == "." || path == "" {
|
||||
return &inputFile{owner: f}, 0
|
||||
}
|
||||
in := f.files[path]
|
||||
if in == nil {
|
||||
return nil, expsys.ENOENT
|
||||
}
|
||||
return &inputFile{owner: f, in: in}, 0
|
||||
}
|
||||
func (f *inputFS) Stat(path string) (sys.Stat_t, expsys.Errno) {
|
||||
file, err := f.OpenFile(path, 0, 0)
|
||||
if err != 0 {
|
||||
return sys.Stat_t{}, err
|
||||
}
|
||||
return file.Stat()
|
||||
}
|
||||
|
||||
type inputFile struct {
|
||||
expsys.UnimplementedFile
|
||||
owner *inputFS
|
||||
in Input
|
||||
}
|
||||
|
||||
func (f *inputFile) IsDir() (bool, expsys.Errno) { return f.in == nil, 0 }
|
||||
func (f *inputFile) Stat() (sys.Stat_t, expsys.Errno) {
|
||||
if f.in == nil {
|
||||
return sys.Stat_t{Mode: fs.ModeDir | 0555}, 0
|
||||
}
|
||||
mode := fs.FileMode(0444)
|
||||
if !f.in.Seekable() {
|
||||
mode |= fs.ModeNamedPipe
|
||||
}
|
||||
return sys.Stat_t{Mode: mode, Size: max(0, f.in.Size()), Nlink: 1}, 0
|
||||
}
|
||||
func (f *inputFile) Read(p []byte) (int, expsys.Errno) {
|
||||
if f.in == nil {
|
||||
return 0, expsys.EISDIR
|
||||
}
|
||||
n, err := f.in.Read(p)
|
||||
if err != nil && err != io.EOF {
|
||||
f.owner.err = err
|
||||
return n, expsys.EIO
|
||||
}
|
||||
return n, 0
|
||||
}
|
||||
func (f *inputFile) Seek(off int64, whence int) (int64, expsys.Errno) {
|
||||
if f.in == nil {
|
||||
return 0, expsys.EISDIR
|
||||
}
|
||||
n, err := f.in.Seek(off, whence)
|
||||
// Capability probes may legitimately fail; a failed seek alone is not a
|
||||
// media error. FFmpeg decides whether it can continue sequentially.
|
||||
if err != nil {
|
||||
return 0, expsys.ENOSYS
|
||||
}
|
||||
return n, 0
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"codeberg.org/gruf/go-ffmpreg/wasm"
|
||||
"github.com/tetratelabs/wazero"
|
||||
)
|
||||
|
||||
// Generate tiny deterministic media with the exact embedded build under test;
|
||||
// no system FFmpeg, network media or third-party fixture licensing is needed.
|
||||
func fixture(t *testing.T, audio bool) []byte {
|
||||
t.Helper()
|
||||
initialize()
|
||||
if shared.err != nil {
|
||||
t.Fatal(shared.err)
|
||||
}
|
||||
args := []string{"-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "color=c=black:s=32x32:r=10", "-t", "0.3", "-c:v", "libx264", "-preset", "ultrafast", "-movflags", "frag_keyframe+empty_moov", "-f", "mp4", "pipe:1"}
|
||||
if audio {
|
||||
args = []string{"-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "anullsrc=r=48000:cl=mono", "-t", "0.3", "-c:a", "aac", "-movflags", "frag_keyframe+empty_moov", "-f", "mp4", "pipe:1"}
|
||||
}
|
||||
var out, logs bytes.Buffer
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
rc, err := wasm.Run(ctx, shared.runtime, shared.module, wasm.Args{Name: "ffmpeg", Args: args, Stdout: &out, Stderr: &logs})
|
||||
if err != nil || rc != 0 {
|
||||
t.Fatalf("fixture: %d %v %s", rc, err, logs.String())
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func TestTailMoovNeedsRange(t *testing.T) {
|
||||
initialize()
|
||||
if shared.err != nil {
|
||||
t.Fatal(shared.err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
dir := t.TempDir()
|
||||
var logs bytes.Buffer
|
||||
rc, err := wasm.Run(ctx, shared.runtime, shared.module, wasm.Args{Name: "ffmpeg", Args: []string{
|
||||
"-v", "error", "-f", "lavfi", "-i", "color=c=black:s=32x32:r=10", "-t", "0.3", "-c:v", "libx264", "-preset", "ultrafast", "/out/video.mp4",
|
||||
}, Stderr: &logs, Config: func(c wazero.ModuleConfig) wazero.ModuleConfig {
|
||||
return c.WithFSConfig(wazero.NewFSConfig().WithDirMount(dir, "/out"))
|
||||
}})
|
||||
if rc != 0 || err != nil {
|
||||
t.Fatalf("fixture %d %v %s", rc, err, logs.String())
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "video.mp4"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var video []byte
|
||||
for offset := 0; offset < len(raw); {
|
||||
length := int(binary.BigEndian.Uint32(raw[offset : offset+4]))
|
||||
if string(raw[offset+4:offset+8]) == "moov" {
|
||||
free := make([]byte, 2*httpBlockSize)
|
||||
binary.BigEndian.PutUint32(free, uint32(len(free)))
|
||||
copy(free[4:], "free")
|
||||
video = append(video, free...)
|
||||
}
|
||||
video = append(video, raw[offset:offset+length]...)
|
||||
offset += length
|
||||
}
|
||||
audio := fixture(t, true)
|
||||
for _, ranged := range []bool{true, false} {
|
||||
t.Run(fmtBool(ranged), func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
data := video
|
||||
if r.URL.Path == "/audio" {
|
||||
data = audio
|
||||
}
|
||||
if ranged {
|
||||
http.ServeContent(w, r, "media", time.Time{}, bytes.NewReader(data))
|
||||
} else {
|
||||
w.Write(data)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
v, err := OpenHTTP(ctx, srv.Client(), HTTPSource{URL: srv.URL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer v.Close()
|
||||
a, err := OpenHTTP(ctx, srv.Client(), HTTPSource{URL: srv.URL + "/audio"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer a.Close()
|
||||
err = Run(ctx, v, a, io.Discard, "mp4", nil)
|
||||
if ranged && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ranged && err == nil {
|
||||
t.Fatal("non-seekable tail-moov input unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func fmtBool(b bool) string {
|
||||
if b {
|
||||
return "range"
|
||||
}
|
||||
return "sequential"
|
||||
}
|
||||
|
||||
func TestWebMToMP4(t *testing.T) {
|
||||
initialize()
|
||||
if shared.err != nil {
|
||||
t.Fatal(shared.err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
inputs := make([]*StreamInput, 2)
|
||||
for i, name := range []string{"video.webm", "audio.webm"} {
|
||||
data, err := os.ReadFile(filepath.Join("..", "testdata", "ffmpeg", name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inputs[i] = NewStreamInput(ctx)
|
||||
defer inputs[i].Close()
|
||||
if _, err = inputs[i].Write(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inputs[i].End(nil)
|
||||
}
|
||||
|
||||
var output, probe, logs bytes.Buffer
|
||||
if err := Run(ctx, inputs[0], inputs[1], &output, "mp4", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rc, err := wasm.Run(ctx, shared.runtime, shared.module, wasm.Args{Name: "ffprobe", Args: []string{"-v", "error", "-show_entries", "stream=codec_name", "-of", "json", "pipe:0"}, Stdin: bytes.NewReader(output.Bytes()), Stdout: &probe, Stderr: &logs})
|
||||
if rc != 0 || err != nil {
|
||||
t.Fatalf("probe %d %v %s", rc, err, logs.String())
|
||||
}
|
||||
if !bytes.Contains(probe.Bytes(), []byte(`"vp9"`)) || !bytes.Contains(probe.Bytes(), []byte(`"opus"`)) {
|
||||
t.Fatalf("codecs changed: %s", probe.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWASMMerge(t *testing.T) {
|
||||
video, audio := fixture(t, false), fixture(t, true)
|
||||
for _, ranged := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "sequential", true: "range"}[ranged], func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
data := video
|
||||
if r.URL.Path == "/audio" {
|
||||
data = audio
|
||||
}
|
||||
if ranged {
|
||||
http.ServeContent(w, r, "media", time.Time{}, bytes.NewReader(data))
|
||||
} else {
|
||||
w.Write(data)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
v, err := OpenHTTP(ctx, srv.Client(), HTTPSource{URL: srv.URL + "/video"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer v.Close()
|
||||
a, err := OpenHTTP(ctx, srv.Client(), HTTPSource{URL: srv.URL + "/audio"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer a.Close()
|
||||
var output bytes.Buffer
|
||||
if err = Run(ctx, v, a, &output, "", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(output.Bytes(), []byte("moof")) {
|
||||
t.Fatal("output is not fragmented MP4")
|
||||
}
|
||||
// Probe the actual result, rather than just checking its filename/header.
|
||||
var probe, logs bytes.Buffer
|
||||
rc, err := wasm.Run(ctx, shared.runtime, shared.module, wasm.Args{Name: "ffprobe", Args: []string{"-v", "error", "-show_streams", "-of", "json", "pipe:0"}, Stdin: bytes.NewReader(output.Bytes()), Stdout: &probe, Stderr: &logs})
|
||||
if rc != 0 || err != nil {
|
||||
t.Fatalf("probe: %d %v %s", rc, err, logs.String())
|
||||
}
|
||||
var result struct {
|
||||
Streams []struct {
|
||||
CodecType string `json:"codec_type"`
|
||||
CodecName string `json:"codec_name"`
|
||||
}
|
||||
}
|
||||
if err = json.Unmarshal(probe.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Streams) != 2 || result.Streams[0].CodecName != "h264" || result.Streams[1].CodecName != "aac" {
|
||||
t.Fatalf("unexpected streams %s", probe.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
// A malformed input must produce an error, not successful empty output.
|
||||
s := NewStreamInput(context.Background())
|
||||
defer s.Close()
|
||||
s.Write([]byte("invalid"))
|
||||
s.End(nil)
|
||||
if err := Run(context.Background(), s, s, io.Discard, "", nil); err == nil {
|
||||
t.Fatal("accepted invalid media")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// A bounded memory ring lets both JS inputs drain independently, including when
|
||||
// they share a SABR producer. It does not make a sequential input seekable.
|
||||
// A full ring waits for consumption or cancellation. Producers must propagate
|
||||
// backpressure and allow each track to make progress independently.
|
||||
const StreamBufferLimit = 32 * 1024 * 1024
|
||||
|
||||
type StreamInput struct {
|
||||
ctx context.Context
|
||||
mu sync.Mutex
|
||||
buffer []byte
|
||||
read, written int64
|
||||
ended, closed bool
|
||||
err error
|
||||
wake chan struct{}
|
||||
space chan struct{}
|
||||
}
|
||||
|
||||
func NewStreamInput(ctx context.Context) *StreamInput {
|
||||
return &StreamInput{ctx: ctx, wake: make(chan struct{}, 1), space: make(chan struct{}, 1)}
|
||||
}
|
||||
|
||||
func (s *StreamInput) Write(p []byte) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(p) > StreamBufferLimit {
|
||||
return 0, errors.New("ffmpeg: input chunk exceeds buffer capacity")
|
||||
}
|
||||
for int64(len(p)) > StreamBufferLimit-(s.written-s.read) && !s.closed && !s.ended {
|
||||
s.mu.Unlock()
|
||||
var err error
|
||||
select {
|
||||
case <-s.space:
|
||||
case <-s.ctx.Done():
|
||||
err = s.ctx.Err()
|
||||
}
|
||||
s.mu.Lock()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if s.closed || s.ended {
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
if err := s.ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
// Grow only as needed. Rebase unread bytes when the ring grows.
|
||||
need := int(s.written-s.read) + len(p)
|
||||
if need > len(s.buffer) {
|
||||
capacity := min(StreamBufferLimit, max(256*1024, max(need, len(s.buffer)*2)))
|
||||
buffer := make([]byte, capacity)
|
||||
unread := int(s.written - s.read)
|
||||
if unread > 0 {
|
||||
off := int(s.read % int64(len(s.buffer)))
|
||||
first := min(unread, len(s.buffer)-off)
|
||||
copy(buffer, s.buffer[off:off+first])
|
||||
copy(buffer[first:], s.buffer[:unread-first])
|
||||
}
|
||||
s.buffer = buffer
|
||||
s.read, s.written = 0, int64(unread)
|
||||
}
|
||||
off := int(s.written % int64(len(s.buffer)))
|
||||
first := min(len(p), len(s.buffer)-off)
|
||||
copy(s.buffer[off:], p[:first])
|
||||
copy(s.buffer, p[first:])
|
||||
s.written += int64(len(p))
|
||||
select {
|
||||
case s.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (s *StreamInput) End(err error) {
|
||||
s.mu.Lock()
|
||||
s.ended = true
|
||||
if s.err == nil {
|
||||
s.err = err
|
||||
}
|
||||
s.mu.Unlock()
|
||||
select {
|
||||
case s.space <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case s.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (s *StreamInput) Read(p []byte) (int, error) {
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
for {
|
||||
if err := s.ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
if s.err != nil {
|
||||
err := s.err
|
||||
s.mu.Unlock()
|
||||
return 0, err
|
||||
}
|
||||
if s.read < s.written {
|
||||
off := s.read % int64(len(s.buffer))
|
||||
n := min(int64(len(p)), s.written-s.read, int64(len(s.buffer))-off)
|
||||
count := copy(p, s.buffer[off:off+n])
|
||||
s.read += int64(count)
|
||||
s.mu.Unlock()
|
||||
select {
|
||||
case s.space <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
ended := s.ended
|
||||
s.mu.Unlock()
|
||||
if ended {
|
||||
return 0, io.EOF
|
||||
}
|
||||
select {
|
||||
case <-s.wake:
|
||||
case <-s.ctx.Done():
|
||||
return 0, s.ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *StreamInput) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.closed = true
|
||||
select {
|
||||
case s.space <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case s.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
s.buffer = nil
|
||||
return nil
|
||||
}
|
||||
func (*StreamInput) Seek(int64, int) (int64, error) { return 0, ErrSeek }
|
||||
func (*StreamInput) Size() int64 { return -1 }
|
||||
func (*StreamInput) Seekable() bool { return false }
|
||||
Reference in new issue
Block a user