feat: add FFmpeg WASM runtime (#1512)

This commit is contained in:
Levi authored and GitHub committed 2026-09-16 00:38:22 +08:00
1 parent 46c34f2e92
commit c667f097dc
31 files changed
+2305 -24

No files matched your search

+3 -1
View File
@@ -3,6 +3,7 @@ module github.com/GopeedLab/gopeed
go 1.25.4
require (
codeberg.org/gruf/go-ffmpreg v0.6.23-0.20260827095416-df1836cb5be0
github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5
github.com/anacrolix/torrent v1.60.1-0.20251217073903-486bcbe758e0
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
@@ -21,6 +22,7 @@ require (
github.com/pkg/errors v0.9.1
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529
github.com/rs/zerolog v1.31.0
github.com/tetratelabs/wazero v1.12.0
github.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad
go.etcd.io/bbolt v1.4.3
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93
@@ -156,7 +158,7 @@ require (
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.50.0
golang.org/x/sys v0.41.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.34.0
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.42.0 // indirect
+6
View File
@@ -14,6 +14,8 @@ cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2k
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
codeberg.org/gruf/go-ffmpreg v0.6.23-0.20260827095416-df1836cb5be0 h1:AqNFl60o4xhTTOxmzAvSf7pazXHKBGuV5ATw7sETIRA=
codeberg.org/gruf/go-ffmpreg v0.6.23-0.20260827095416-df1836cb5be0/go.mod h1:XM6eW8CSgAuZRKRynm5nDiSNedm0rav30IychqygOHE=
crawshaw.io/iox v0.0.0-20181124134642-c51c3df30797/go.mod h1:sXBiorCo8c46JlQV3oXPKINnZ8mcqnye1EkVkqsectk=
crawshaw.io/sqlite v0.3.2/go.mod h1:igAO5JulrQ1DbdZdtVq48mnZUBAPOeFzer7VhDWNtW4=
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
@@ -552,6 +554,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA=
github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A=
github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
@@ -732,6 +736,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
+3
View File
@@ -1,5 +1,8 @@
package http
// DefaultUserAgent is shared by HTTP downloads and extension HTTP requests.
const DefaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36"
type config struct {
UserAgent string `json:"userAgent"`
Connections int `json:"connections"`
+1 -1
View File
@@ -72,7 +72,7 @@ func (fm *FetcherManager) AutoRename() bool {
func (fm *FetcherManager) DefaultConfig() any {
return &config{
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36",
UserAgent: DefaultUserAgent,
Connections: 16,
}
}
-3
View File
@@ -94,9 +94,6 @@ type Resource struct {
}
func (r *Resource) Validate() error {
if r.Name == "" {
return fmt.Errorf("invalid resource name")
}
if len(r.Files) == 0 {
return fmt.Errorf("invalid resource files")
}
+15 -4
View File
@@ -9,6 +9,7 @@ import (
"github.com/GopeedLab/gopeed/pkg/base"
gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error"
fetchapi "github.com/GopeedLab/gopeed/pkg/download/engine/inject/fetch"
ffmpegapi "github.com/GopeedLab/gopeed/pkg/download/engine/inject/ffmpeg"
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/file"
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/stream"
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/vm"
@@ -159,8 +160,10 @@ func (e *Engine) addCleanup(cleanup func()) {
}
type Config struct {
ProxyConfig *base.DownloaderProxyConfig
StreamConfig *stream.Config
// HTTPUserAgent is a snapshot of the downloader HTTP default. Nil leaves standalone engines unchanged.
HTTPUserAgent *string
ProxyConfig *base.DownloaderProxyConfig
StreamConfig *stream.Config
}
func NewEngine(cfg *Config) *Engine {
@@ -203,9 +206,17 @@ func NewEngine(cfg *Config) *Engine {
if err := stream.Enable(runtime, loop, cfg.StreamConfig); err != nil {
return
}
if err := ffmpegapi.Enable(runtime, loop, &ffmpegapi.Config{
ProxyHandler: cfg.ProxyConfig.ToHandler(),
DefaultUserAgent: cfg.HTTPUserAgent,
RegisterCleanup: engine.addCleanup,
}); err != nil {
return
}
if err := fetchapi.Enable(runtime, loop, &fetchapi.Config{
ProxyHandler: cfg.ProxyConfig.ToHandler(),
RegisterCleanup: engine.addCleanup,
ProxyHandler: cfg.ProxyConfig.ToHandler(),
DefaultUserAgent: cfg.HTTPUserAgent,
RegisterCleanup: engine.addCleanup,
}); err != nil {
return
}
+128
View File
@@ -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.
+227
View File
@@ -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
}
+222
View File
@@ -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")
}
}
+254
View File
@@ -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
}
+215
View File
@@ -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")
}
}
+163
View File
@@ -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 }
@@ -0,0 +1,321 @@
package engine
import (
"bytes"
"context"
"fmt"
media "github.com/GopeedLab/gopeed/pkg/download/engine/ffmpeg"
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/stream"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync/atomic"
"testing"
"time"
)
func TestFFmpegJSStreams(t *testing.T) {
video, err := os.ReadFile("testdata/ffmpeg/video.mp4")
if err != nil {
t.Fatal(err)
}
audio, err := os.ReadFile("testdata/ffmpeg/audio.mp4")
if err != nil {
t.Fatal(err)
}
for _, mode := range []string{"streams", "http", "mixed"} {
t.Run(mode, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b := video
if r.URL.Path == "/audio" {
b = audio
}
http.ServeContent(w, r, "media", time.Time{}, bytes.NewReader(b))
}))
defer srv.Close()
e := NewEngine(nil)
defer e.Close()
// Input data is deliberately interleaved through one JS producer.
// Native push/read operations must never block the event loop.
e.Runtime.Set("videoBytes", e.Runtime.NewArrayBuffer(video))
e.Runtime.Set("audioBytes", e.Runtime.NewArrayBuffer(audio))
e.Runtime.Set("sourceURL", srv.URL)
e.Runtime.Set("inputMode", mode)
result, err := e.RunString(`(async()=>{
let vc, ac, offset=0;
const v=new Uint8Array(videoBytes), a=new Uint8Array(audioBytes);
const video=new ReadableStream({start(c){vc=c;}});
const audio=new ReadableStream({start(c){ac=c;}});
const timer=setInterval(()=>{
if(offset<v.length)vc.enqueue(v.slice(offset,offset+97));
if(offset<a.length)ac.enqueue(a.slice(offset,offset+97));
offset+=97;
if(offset>=Math.max(v.length,a.length)){clearInterval(timer);vc.close();ac.close();}
},1);
const stream=__gopeed_ffmpeg.merge({
inputs: () => ({ video:inputMode==='http'?{url:sourceURL+'/video'}:video,
audio:inputMode==='streams'?audio:{url:sourceURL+'/audio'} }),
args:['-metadata','title=gopeed-test']
});
const bytes=await new Response(stream).arrayBuffer();
return new Uint8Array(bytes);
})()`)
if err != nil {
t.Fatal(err)
}
out, ok := result.([]byte)
if !ok {
t.Fatalf("output %T", result)
}
if !bytes.Contains(out, []byte("moof")) || !bytes.Contains(out, []byte("gopeed-test")) {
t.Fatalf("invalid output (%d bytes)", len(out))
}
})
}
}
func TestFFmpegJSErrorAndCancel(t *testing.T) {
for _, scenario := range []string{"input-error", "abort", "output-cancel", "invalid-args"} {
t.Run(scenario, func(t *testing.T) {
e := NewEngine(nil)
defer e.Close()
e.Runtime.Set("scenario", scenario)
result, err := e.RunString(`(async()=>{
let cancelled=0;
const source=()=>new ReadableStream({
pull(c){if(scenario==='input-error')c.error(new Error('upstream failed'));else return new Promise(()=>{});},
cancel(){cancelled++;}
});
const controller=new AbortController();
const output=__gopeed_ffmpeg.merge({inputs:()=>({video:source(),audio:source()}),signal:controller.signal,
args:scenario==='invalid-args'?['-i','/outside']:[]});
const reader=output.getReader();
const pending=reader.read().then(()=>'',e=>e.message);
if(scenario==='abort')setTimeout(()=>controller.abort(),20);
if(scenario==='output-cancel')setTimeout(()=>reader.cancel(),20);
const message=await pending;
await new Promise(r=>setTimeout(r,10));
return {message,cancelled};
})()`)
if err != nil {
t.Fatal(err)
}
m := result.(map[string]any)
if scenario != "output-cancel" && m["message"] == "" {
t.Fatalf("missing error: %v", m)
}
if scenario == "abort" || scenario == "output-cancel" {
if fmt.Sprint(m["cancelled"]) != "2" {
t.Fatalf("inputs not cancelled: %v", m)
}
}
})
}
}
func TestFFmpegMalformedMediaFails(t *testing.T) {
e := NewEngine(nil)
defer e.Close()
_, err := e.RunString(`(async()=>{
const source=()=>new ReadableStream({start(c){c.enqueue(new Uint8Array([1,2,3]));c.close();}});
return await new Response(__gopeed_ffmpeg.merge({inputs:()=>({video:source(),audio:source()})})).arrayBuffer();
})()`)
if err == nil || !strings.Contains(err.Error(), "ffmpeg") {
t.Fatalf("expected FFmpeg error: %v", err)
}
}
func TestFFmpegBlobOpener(t *testing.T) {
var open stream.ObjectURLOpener
e := NewEngine(&Config{StreamConfig: &stream.Config{CreateObjectURL: func(opts *stream.ObjectURLOptions, opener stream.ObjectURLOpener) (string, error) {
if opts.Range || opts.Size != 0 {
t.Fatal("merged output must not advertise range or a guessed size")
}
open = opener
return "blob:ffmpeg-test", nil
}}})
defer e.Close()
video, _ := os.ReadFile("testdata/ffmpeg/video.mp4")
audio, _ := os.ReadFile("testdata/ffmpeg/audio.mp4")
e.Runtime.Set("videoBytes", e.Runtime.NewArrayBuffer(video))
e.Runtime.Set("audioBytes", e.Runtime.NewArrayBuffer(audio))
_, err := e.RunString(`__gopeed_blob_create_object_url(()=>{
const source=b=>new ReadableStream({start(c){c.enqueue(new Uint8Array(b));c.close();}});
return __gopeed_ffmpeg.merge({inputs:()=>({video:source(videoBytes),audio:source(audioBytes)})});
},{contentType:'video/mp4'})`)
if err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
r, err := open(ctx, stream.ObjectURLOpenRequest{End: -1})
if err != nil {
cancel()
t.Fatal(err)
}
b, err := io.ReadAll(r)
r.Close()
cancel()
if err != nil || !bytes.Contains(b, []byte("moof")) {
t.Fatalf("Blob read: %v (%d bytes)", err, len(b))
}
}
}
func TestFFmpegEngineCloseCancelsRequest(t *testing.T) {
started, closed := make(chan struct{}), make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close(started)
<-r.Context().Done()
close(closed)
}))
defer srv.Close()
e := NewEngine(nil)
defer e.Close()
_, err := e.RunString(fmt.Sprintf(`
globalThis.cancelCount=0;
const audio=new ReadableStream({cancel(){cancelCount++;}});
__gopeed_ffmpeg.merge({inputs:()=>({video:{url:%q},audio})}).getReader().read().catch(()=>{});
"started";`, srv.URL))
if err != nil {
t.Fatal(err)
}
select {
case <-started:
case <-time.After(5 * time.Second):
t.Fatal("HTTP did not start")
}
e.Close()
select {
case <-closed:
case <-time.After(5 * time.Second):
t.Fatal("HTTP leaked after engine shutdown")
}
if e.Runtime.Get("cancelCount").ToInteger() != 1 {
t.Fatal("JS input not cancelled on shutdown")
}
}
func TestFFmpegInputFactoriesWaitForCapacity(t *testing.T) {
for _, mode := range []string{"factory", "http", "cancel"} {
t.Run(mode, func(t *testing.T) {
release1, _ := media.Acquire(context.Background())
defer release1()
release2, _ := media.Acquire(context.Background())
defer release2()
var requests atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests.Add(1); http.Error(w, "test", 500) }))
defer srv.Close()
e := NewEngine(nil)
defer e.Close()
e.Runtime.Set("mode", mode)
e.Runtime.Set("sourceURL", srv.URL)
result, err := e.RunString(`(async()=>{
globalThis.factoryCalls=0;
globalThis.abortQueue=new AbortController();
const options=mode==='http'?{video:{url:sourceURL},audio:{url:sourceURL}}:
{inputs:()=>{factoryCalls++;throw new Error('factory failed');}};
options.signal=abortQueue.signal;
globalThis.work=__gopeed_ffmpeg.merge(options).getReader().read().then(()=>'',e=>e.message);
await new Promise(r=>setTimeout(r,50));
if(mode==='cancel')abortQueue.abort();
return factoryCalls;
})()`)
if err != nil {
t.Fatal(err)
}
if fmt.Sprint(result) != "0" || requests.Load() != 0 {
t.Fatalf("queued inputs started: %v, requests=%d", result, requests.Load())
}
if mode == "cancel" {
if _, err = e.RunString(`work`); err != nil {
t.Fatal(err)
}
}
release1()
if _, err = e.RunString(`work`); err != nil {
t.Fatal(err)
}
calls, err := e.RunString(`factoryCalls`)
if err != nil {
t.Fatal(err)
}
if mode == "factory" && fmt.Sprint(calls) != "1" {
t.Fatal(calls)
}
if mode == "cancel" && fmt.Sprint(calls) != "0" {
t.Fatal("cancelled factory ran")
}
if mode == "http" && requests.Load() != 1 {
t.Fatal("HTTP did not start after admission")
}
// Failure/cancellation must return the acquired slot even with the other held.
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
release, err := media.Acquire(ctx)
if err != nil {
t.Fatalf("slot leaked: %v", err)
}
release()
})
}
}
func TestFFmpegRequiresFactoryForStreams(t *testing.T) {
e := NewEngine(nil)
defer e.Close()
result, err := e.RunString(`(()=>{
const source=()=>new ReadableStream({});
let rejected=0;
for(const opts of [
{video:source(),audio:source()},
{video:{url:'https://example.test'},audio:source()},
{inputs:()=>({}),video:{url:'https://example.test'}},
{inputs:{video:source(),audio:source()}}
]) {try{__gopeed_ffmpeg.merge(opts);}catch(e){if(e instanceof TypeError)rejected++;}}
return rejected;
})()`)
if err != nil || fmt.Sprint(result) != "4" {
t.Fatalf("validation: %v %v", result, err)
}
}
func TestFFmpegCancelPendingFactoryCleansLateInputs(t *testing.T) {
e := NewEngine(nil)
defer e.Close()
result, err := e.RunString(`(async()=>{
let ready,finish,cancelled=0,signal;
const entered=new Promise(r=>ready=r);
const abort=new AbortController();
const output=__gopeed_ffmpeg.merge({signal:abort.signal,inputs:async opts=>{
signal=opts.signal;ready();await new Promise(r=>finish=r);
const source=()=>new ReadableStream({cancel(){cancelled++;}});
return {video:source(),audio:source()};
}});
const work=output.getReader().read().catch(e=>e.name);
await entered;abort.abort();await work;finish();
await new Promise(r=>setTimeout(r,30));
return {cancelled,aborted:signal.aborted};
})()`)
if err != nil {
t.Fatal(err)
}
state := result.(map[string]any)
if fmt.Sprint(state["cancelled"]) != "2" || state["aborted"] != true {
t.Fatal(state)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
first, err := media.Acquire(ctx)
if err != nil {
t.Fatal(err)
}
defer first()
second, err := media.Acquire(ctx)
if err != nil {
t.Fatal("factory cancellation leaked capacity")
}
second()
}
+6 -2
View File
@@ -29,8 +29,9 @@ const FingerprintMagicKey = "__gopeed_fetch_fingerprint"
var script string
type Config struct {
ProxyHandler func(*http.Request) (*url.URL, error)
RegisterCleanup func(func())
DefaultUserAgent *string
ProxyHandler func(*http.Request) (*url.URL, error)
RegisterCleanup func(func())
}
func Enable(runtime *goja.Runtime, loop *eventloop.EventLoop, cfg *Config) error {
@@ -51,6 +52,9 @@ func Enable(runtime *goja.Runtime, loop *eventloop.EventLoop, cfg *Config) error
if err != nil {
panic(runtime.NewGoError(err))
}
if cfg.DefaultUserAgent != nil && !hasHeader(request.Headers, "User-Agent") {
request.Headers = append(request.Headers, [2]string{"User-Agent", *cfg.DefaultUserAgent})
}
fingerprint := util.SafeGet[string](runtime, FingerprintMagicKey)
promise, resolve, reject := runtime.NewPromise()
go func() {
+139
View File
@@ -0,0 +1,139 @@
(function () {
const create = __gopeed_ffmpeg_create;
const read = __gopeed_ffmpeg_read;
const acquire = __gopeed_ffmpeg_acquire;
const start = __gopeed_ffmpeg_start;
const push = __gopeed_ffmpeg_push;
const end = __gopeed_ffmpeg_end;
const cancel = __gopeed_ffmpeg_cancel;
const active = new Set();
globalThis.__gopeed_ffmpeg_close_all = () => {
for (const stop of Array.from(active)) stop(new Error("extension engine closed"));
};
function input(value, name, allowStream) {
if (value && typeof value.getReader === "function") {
if (!allowStream) throw new TypeError("stream inputs must be created by the inputs callback");
if (value.locked) throw new TypeError(name + " stream is locked");
return { stream: value, request: null };
}
if (!value || typeof value.url !== "string" || !/^https?:\/\//i.test(value.url)) {
throw new TypeError(name + " must be an HTTP request or ReadableStream");
}
const headers = {};
new Headers(value.headers || {}).forEach((v, k) => { headers[k] = v; });
return { request: { url: value.url, headers } };
}
function merge(options) {
if (!options || typeof options !== "object") throw new TypeError("merge options are required");
const factory = options.inputs;
if (factory !== undefined && typeof factory !== "function") throw new TypeError("inputs must be a callback");
if (factory && (options.video !== undefined || options.audio !== undefined)) throw new TypeError("inputs cannot be combined with video/audio");
let video = factory ? null : input(options.video, "video", false);
let audio = factory ? null : input(options.audio, "audio", false);
const args = options.args === undefined ? [] : options.args;
if (!Array.isArray(args) || args.some(v => typeof v !== "string")) throw new TypeError("args must be a string array");
const format = options.format === undefined ? "mp4" : options.format;
if (typeof format !== "string") throw new TypeError("format must be a string");
const signal = options.signal;
let id, controller, stopped = false;
const readers = [];
const lifetime = new AbortController();
let ownedInputs;
function discardInputs(inputs, reason) {
for (const source of new Set([inputs && inputs.video, inputs && inputs.audio])) {
if (source && typeof source.cancel === "function" && !source.locked) Promise.resolve(source.cancel(reason)).catch(() => {});
}
}
function stop(reason) {
if (stopped) return;
stopped = true;
active.delete(shutdown);
lifetime.abort();
discardInputs(ownedInputs, reason);
if (id) cancel(id);
if (signal) signal.removeEventListener("abort", abort);
for (const reader of readers) {
Promise.resolve(reader.cancel(reason)).catch(() => {}).then(() => {
try { reader.releaseLock(); } catch (_) {}
});
}
}
function shutdown(reason) {
controller.error(reason);
stop(reason);
}
function abort() {
const error = new Error("FFmpeg operation aborted");
error.name = "AbortError";
stop(error);
controller.error(error);
}
async function pump(reader, index) {
try {
while (!stopped) {
const result = await reader.read();
if (stopped) return;
if (result.done) { end(id, index, ""); return; }
if (!(result.value instanceof Uint8Array)) throw new TypeError("media streams must contain Uint8Array chunks");
// Keep individual crossings bounded, even if upstream emits a large chunk.
for (let offset = 0; offset < result.value.byteLength && !stopped; offset += 256 * 1024) {
if (!await push(id, index, result.value.subarray(offset, offset + 256 * 1024))) return;
}
}
} catch (error) {
if (!stopped) {
end(id, index, String(error && error.message || error));
controller.error(error);
stop(error);
}
}
}
return new ReadableStream({
start(c) {
controller = c;
active.add(shutdown);
if (signal) {
if (signal.aborted) abort();
else signal.addEventListener("abort", abort);
}
},
async pull(c) {
if (stopped) return;
try {
if (!id) {
id = create({ format, args });
await acquire(id);
if (stopped) return;
if (factory) {
const inputs = await factory({ signal: lifetime.signal });
if (stopped) { discardInputs(inputs, new Error("FFmpeg operation cancelled")); return; }
ownedInputs = inputs;
video = input(inputs && inputs.video, "video", true);
audio = input(inputs && inputs.audio, "audio", true);
}
if (video.stream && video.stream === audio.stream) throw new TypeError("video and audio must be distinct streams");
for (const source of [video, audio]) {
if (source.stream) readers.push(source.stream.getReader());
}
start(id, { video: video.request, audio: audio.request, format, args });
let readerIndex = 0;
for (const [index, source] of [video, audio].entries()) {
if (source.stream) void pump(readers[readerIndex++], index);
}
}
const chunk = await read(id);
if (stopped) return;
if (chunk === null) { stop(); c.close(); }
else c.enqueue(new Uint8Array(chunk));
} catch (error) {
if (!stopped) { c.error(error); stop(error); }
}
},
cancel(reason) { stop(reason); }
});
}
globalThis.__gopeed_ffmpeg = Object.freeze({ merge, supportsInputFactory: true });
})();
+324
View File
@@ -0,0 +1,324 @@
package ffmpeg
import (
"context"
_ "embed"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"sync/atomic"
"github.com/GopeedLab/gopeed/internal/httpclient"
media "github.com/GopeedLab/gopeed/pkg/download/engine/ffmpeg"
"github.com/dop251/goja"
"github.com/dop251/goja_nodejs/eventloop"
)
//go:embed ffmpeg.js
var script string
type Config struct {
DefaultUserAgent *string
ProxyHandler func(*http.Request) (*url.URL, error)
RegisterCleanup func(func())
}
type options struct {
Video *media.HTTPSource `json:"video"`
Audio *media.HTTPSource `json:"audio"`
Format string `json:"format"`
Args []string `json:"args"`
}
type job struct {
ctx context.Context
cancel context.CancelFunc
output *io.PipeReader
streams [2]*media.StreamInput
mu sync.Mutex
ready chan struct{}
start chan options
started bool
}
func (j *job) stop() {
j.cancel()
j.output.CloseWithError(context.Canceled)
j.mu.Lock()
defer j.mu.Unlock()
for _, s := range j.streams {
if s != nil {
s.Close()
}
}
}
func Enable(vm *goja.Runtime, loop *eventloop.EventLoop, cfg *Config) error {
if cfg == nil {
cfg = &Config{}
}
client, err := httpclient.NewClient(httpclient.Options{Transport: httpclient.TransportOptions{Proxy: cfg.ProxyHandler}})
if err != nil {
return err
}
var mu sync.Mutex
jobs := map[string]*job{}
var next atomic.Uint64
get := func(id string) *job { mu.Lock(); defer mu.Unlock(); return jobs[id] }
remove := func(id string) {
mu.Lock()
j := jobs[id]
delete(jobs, id)
mu.Unlock()
if j != nil {
j.stop()
}
}
if cfg.RegisterCleanup != nil {
cfg.RegisterCleanup(func() {
done := make(chan struct{})
if loop.RunOnLoop(func(vm *goja.Runtime) {
defer close(done)
if closeAll, ok := goja.AssertFunction(vm.Get("__gopeed_ffmpeg_close_all")); ok {
_, _ = closeAll(goja.Undefined())
}
}) {
<-done
}
mu.Lock()
old := jobs
jobs = map[string]*job{}
mu.Unlock()
for _, j := range old {
j.stop()
}
client.CloseIdleConnections()
})
}
if err := vm.Set("__gopeed_ffmpeg_create", func(call goja.FunctionCall) goja.Value {
var opts options
if err := vm.ExportTo(call.Argument(0), &opts); err != nil {
panic(vm.NewGoError(err))
}
if _, err := media.Arguments(opts.Format, opts.Args); err != nil {
panic(vm.NewGoError(err))
}
ctx, cancel := context.WithCancel(context.Background())
r, w := io.Pipe()
j := &job{ctx: ctx, cancel: cancel, output: r, ready: make(chan struct{}), start: make(chan options, 1)}
id := fmt.Sprintf("ffmpeg-%d", next.Add(1))
mu.Lock()
jobs[id] = j
mu.Unlock()
go func() {
var inputs [2]media.Input
var runErr error
defer func() {
if v := recover(); v != nil {
runErr = fmt.Errorf("ffmpeg panic: %v", v)
}
cancel()
for _, in := range inputs {
if in != nil {
in.Close()
}
}
w.CloseWithError(runErr)
}()
release, err := media.Acquire(ctx)
if err != nil {
runErr = err
return
}
defer release()
close(j.ready)
var opts options
select {
case opts = <-j.start:
case <-ctx.Done():
runErr = ctx.Err()
return
}
for i, source := range []*media.HTTPSource{opts.Video, opts.Audio} {
if source == nil {
inputs[i] = j.streams[i]
} else {
inputs[i], runErr = media.OpenHTTP(ctx, client, *source)
if runErr != nil {
return
}
}
}
runErr = media.RunAcquired(ctx, inputs[0], inputs[1], w, opts.Format, opts.Args)
}()
return vm.ToValue(id)
}); err != nil {
return err
}
if err := vm.Set("__gopeed_ffmpeg_acquire", func(id string) goja.Value {
promise, resolve, reject := vm.NewPromise()
j := get(id)
go func() {
var err error
if j == nil {
err = io.ErrClosedPipe
} else {
select {
case <-j.ready:
case <-j.ctx.Done():
err = j.ctx.Err()
}
if j.ctx.Err() != nil {
err = j.ctx.Err()
}
}
loop.RunOnLoop(func(vm *goja.Runtime) {
if err != nil {
reject(vm.NewGoError(err))
} else {
resolve(true)
}
})
}()
return vm.ToValue(promise)
}); err != nil {
return err
}
if err := vm.Set("__gopeed_ffmpeg_start", func(call goja.FunctionCall) goja.Value {
id := call.Argument(0).String()
j := get(id)
if j == nil {
panic(vm.NewGoError(io.ErrClosedPipe))
}
var opts options
if err := vm.ExportTo(call.Argument(1), &opts); err != nil {
panic(vm.NewGoError(err))
}
if _, err := media.Arguments(opts.Format, opts.Args); err != nil {
panic(vm.NewGoError(err))
}
for _, source := range []*media.HTTPSource{opts.Video, opts.Audio} {
if source == nil || cfg.DefaultUserAgent == nil {
continue
}
specified := false
for key := range source.Headers {
if strings.EqualFold(key, "User-Agent") {
specified = true
break
}
}
if !specified {
if source.Headers == nil {
source.Headers = make(map[string]string)
}
source.Headers["User-Agent"] = *cfg.DefaultUserAgent
}
}
j.mu.Lock()
defer j.mu.Unlock()
if j.started || j.ctx.Err() != nil {
panic(vm.NewGoError(io.ErrClosedPipe))
}
j.started = true
for i, source := range []*media.HTTPSource{opts.Video, opts.Audio} {
if source == nil {
j.streams[i] = media.NewStreamInput(j.ctx)
}
}
j.start <- opts
return goja.Undefined()
}); err != nil {
return err
}
if err := vm.Set("__gopeed_ffmpeg_read", func(id string) goja.Value {
promise, resolve, reject := vm.NewPromise()
go func() {
var readErr error
buf := make([]byte, 64*1024)
n := 0
j := get(id)
if j == nil {
readErr = io.ErrClosedPipe
} else {
n, readErr = j.output.Read(buf)
}
if !loop.RunOnLoop(func(vm *goja.Runtime) {
if readErr != nil {
remove(id)
if readErr == io.EOF {
resolve(goja.Null())
} else {
reject(vm.NewGoError(readErr))
}
return
}
resolve(vm.ToValue(vm.NewArrayBuffer(buf[:n])))
}) {
remove(id)
}
}()
return vm.ToValue(promise)
}); err != nil {
return err
}
if err := vm.Set("__gopeed_ffmpeg_push", func(call goja.FunctionCall) goja.Value {
id := call.Argument(0).String()
index := int(call.Argument(1).ToInteger())
var chunk []byte
switch b := call.Argument(2).Export().(type) {
case goja.ArrayBuffer:
chunk = append([]byte(nil), b.Bytes()...)
case []byte:
chunk = append([]byte(nil), b...)
default:
panic(vm.NewTypeError("ffmpeg input requires bytes"))
}
promise, resolve, reject := vm.NewPromise()
go func() {
var err error
j := get(id)
if j == nil || index < 0 || index > 1 || j.streams[index] == nil {
err = io.ErrClosedPipe
} else {
_, err = j.streams[index].Write(chunk)
}
if err != nil && j != nil && index >= 0 && index < 2 && j.streams[index] != nil {
j.streams[index].End(err)
}
loop.RunOnLoop(func(vm *goja.Runtime) {
if j != nil && j.ctx.Err() != nil {
resolve(false)
return
}
if err != nil {
reject(vm.NewGoError(err))
} else {
resolve(true)
}
})
}()
return vm.ToValue(promise)
}); err != nil {
return err
}
if err := vm.Set("__gopeed_ffmpeg_end", func(id string, index int, message string) {
if j := get(id); j != nil && index >= 0 && index < 2 && j.streams[index] != nil {
var err error
if message != "" {
err = errors.New(message)
}
j.streams[index].End(err)
}
}); err != nil {
return err
}
if err := vm.Set("__gopeed_ffmpeg_cancel", remove); err != nil {
return err
}
_, err = vm.RunString(script)
return err
}
+9 -4
View File
@@ -86,18 +86,23 @@ func Enable(runtime *goja.Runtime, loop *eventloop.EventLoop, cfg *Config) error
}
if err := runtime.Set("__gopeed_blob_pipe_chunk", func(call goja.FunctionCall) goja.Value {
id := call.Argument(0).String()
ok := blobPipes.Push(id, exportBytes(call.Argument(1)))
return runtime.ToValue(ok)
chunk := append([]byte(nil), exportBytes(call.Argument(1))...)
promise, resolve, _ := runtime.NewPromise()
go func() {
ok := blobPipes.Push(id, chunk)
loop.RunOnLoop(func(vm *goja.Runtime) { resolve(ok) })
}()
return runtime.ToValue(promise)
}); err != nil {
return err
}
if err := runtime.Set("__gopeed_blob_pipe_close", func(id string) {
blobPipes.Close(id, nil)
go blobPipes.Close(id, nil)
}); err != nil {
return err
}
if err := runtime.Set("__gopeed_blob_pipe_error", func(id string, message string) {
blobPipes.Close(id, errors.New(message))
go blobPipes.Close(id, errors.New(message))
}); err != nil {
return err
}
@@ -269,3 +269,57 @@ func newStreamTestEngine(t *testing.T, created chan<- createdObjectURL) (*engine
})
return e, e.Close
}
// A stalled disk/network consumer must pause the producer without blocking
// the JS event loop, so cancellation and other tracks can still run.
func TestBlobBackpressureLeavesEventLoopResponsive(t *testing.T) {
created := make(chan createdObjectURL, 1)
e, cleanup := newStreamTestEngine(t, created)
defer cleanup()
_, err := e.RunString(`
globalThis.produced = 0;
globalThis.sourceCancelled = false;
__gopeed_blob_create_object_url(() => new ReadableStream({
pull(c) { produced++; c.enqueue(new Uint8Array(64*1024)); },
cancel() { sourceCancelled = true; }
}));
`)
if err != nil {
t.Fatal(err)
}
object := <-created
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
reader, err := object.open(ctx, stream.ObjectURLOpenRequest{End: -1})
if err != nil {
t.Fatal(err)
}
defer reader.Close()
time.Sleep(100 * time.Millisecond)
value, err := e.RunString("produced")
if err != nil {
t.Fatal(err)
}
before := value.(int64)
if before > 10 {
t.Fatalf("unbounded production: %d", before)
}
time.Sleep(50 * time.Millisecond)
after, err := e.RunString("produced")
if err != nil || after != value {
t.Fatalf("producer did not pause: %v -> %v (%v)", value, after, err)
}
if ctx.Err() != nil {
t.Fatal("event loop only resumed after cancellation")
}
if _, err = reader.Read(make([]byte, 64*1024)); err != nil {
t.Fatal(err)
}
time.Sleep(30 * time.Millisecond)
after, err = e.RunString("produced")
if err != nil || after.(int64) <= before {
t.Fatalf("producer did not resume: %v (%v)", after, err)
}
reader.Close()
waitForJSValue(t, e, "String(sourceCancelled)", "true")
}
+12 -5
View File
@@ -105,6 +105,10 @@
return Promise.resolve({ done: true, value: undefined });
}
this._maybePull();
// pull() may synchronously error before a waiter has been registered.
if (this._errored) {
return Promise.reject(this._errored);
}
if (this._queue.length > 0) {
return Promise.resolve({ done: false, value: this._queue.shift() });
}
@@ -760,11 +764,14 @@
return;
}
const chunk = value instanceof Uint8Array ? value : new Uint8Array(value);
const buffer = chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength);
if (!globalThis.__gopeed_blob_pipe_chunk(pipeId, buffer)) {
state.cancelRequested = true;
state.cancelReason = "blob pipe closed";
return;
for (let offset = 0; offset < chunk.byteLength; offset += 256 * 1024) {
const part = chunk.subarray(offset, offset + 256 * 1024);
const buffer = part.buffer.slice(part.byteOffset, part.byteOffset + part.byteLength);
if (!await globalThis.__gopeed_blob_pipe_chunk(pipeId, buffer)) {
state.cancelRequested = true;
state.cancelReason = "blob pipe closed";
return;
}
}
await yieldBlobPipeTask();
}
+18
View File
@@ -0,0 +1,18 @@
# Synthetic media fixtures
These files contain 0.3 seconds of a generated 32x32 black image (H.264) and
generated mono silence (AAC). They contain no third-party media. Generated
with the pinned go-ffmpreg FFmpeg binary using the equivalent commands:
```
ffmpeg -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 video.mp4
ffmpeg -f lavfi -i anullsrc=r=48000:cl=mono -t 0.3 -c:a aac -movflags frag_keyframe+empty_moov -f mp4 audio.mp4
```
The backend tests also generate and probe media using the embedded FFmpeg and
FFprobe at test time, without relying on a system installation.
`video.webm` and `audio.webm` use the same black/silent sources, encoded once
with native FFmpeg 7.1 using `-c:v libvpx-vp9` and `-c:a libopus`. The committed
fixtures let the tests verify WASM stream-copy remuxing without exercising its
VP9 encoder or requiring native FFmpeg on developer/CI machines.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+10 -2
View File
@@ -3,9 +3,11 @@ package download
import (
"context"
"io"
"strings"
"sync"
internalblob "github.com/GopeedLab/gopeed/internal/blob"
httpProtocol "github.com/GopeedLab/gopeed/internal/protocol/http"
"github.com/GopeedLab/gopeed/pkg/download/engine"
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/stream"
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
@@ -119,9 +121,15 @@ func (d *Downloader) newExtensionEngine() (*engine.Engine, *engineSession) {
return d.blob.Revoke(url)
},
}
httpConfig := struct {
UserAgent string `json:"userAgent"`
}{UserAgent: httpProtocol.DefaultUserAgent}
d.getProtocolConfig("http", &httpConfig)
userAgent := strings.TrimSpace(httpConfig.UserAgent)
e := engine.NewEngine(&engine.Config{
ProxyConfig: d.cfg.Proxy,
StreamConfig: engineCfg,
HTTPUserAgent: &userAgent,
ProxyConfig: d.cfg.Proxy,
StreamConfig: engineCfg,
})
session.SetEngine(e)
return e, session
@@ -30,6 +30,9 @@ func injectGopeed(vm *goja.Runtime, gopeed *Instance) error {
}
runtimeObject := vm.NewObject()
if gopeed.Runtime != nil {
if err := runtimeObject.Set("ffmpeg", vm.Get("__gopeed_ffmpeg")); err != nil {
return err
}
if err := runtimeObject.Set("blob", newJSBlobRuntime(vm)); err != nil {
return err
}
+4
View File
@@ -1484,6 +1484,7 @@ func TestDownloader_ExtensionRuntimeWebViewInjected(t *testing.T) {
value, err := runtime.Eval(`(async () => ({
hasRuntime: !!gopeed.runtime,
hasBlob: !!(gopeed.runtime && gopeed.runtime.blob),
hasFFmpegMerge: typeof gopeed.runtime.ffmpeg.merge,
hasCreateObjectURL: typeof gopeed.runtime.blob.createObjectURL,
hasRevokeObjectURL: typeof gopeed.runtime.blob.revokeObjectURL,
hasWebView: !!(gopeed.runtime && gopeed.runtime.webview),
@@ -1502,6 +1503,9 @@ func TestDownloader_ExtensionRuntimeWebViewInjected(t *testing.T) {
if result["hasRuntime"] != true {
t.Fatalf("expected runtime injection, got %#v", result)
}
if result["hasFFmpegMerge"] != "function" {
t.Fatalf("expected ffmpeg injection, got %#v", result)
}
if result["hasBlob"] != true {
t.Fatalf("expected blob injection, got %#v", result)
}
@@ -0,0 +1,37 @@
package download
import (
"github.com/GopeedLab/gopeed/pkg/base"
"os"
"path/filepath"
"testing"
)
func TestExtensionUnnamedResource(t *testing.T) {
d, cleanup, err := newTestExtensionEngineDownloader()
if err != nil {
t.Fatal(err)
}
defer cleanup()
dir := t.TempDir()
manifest := `{"name":"unnamed","title":"Unnamed test","author":"test","version":"1.0.0","scripts":[{"event":"onResolve","match":{"urls":["https://example.com/unnamed"]},"entry":"index.js"}]}`
script := `gopeed.events.onResolve(async(ctx)=>{ctx.res={range:false,files:[{name:'video.mp4',req:{url:await gopeed.runtime.blob.createObjectURL(()=>new ReadableStream({start(c){c.enqueue(new Uint8Array([1,2,3]));c.close();}}))}}]};});`
for name, data := range map[string]string{"manifest.json": manifest, "index.js": script} {
if err = os.WriteFile(filepath.Join(dir, name), []byte(data), 0600); err != nil {
t.Fatal(err)
}
}
if _, err = d.InstallExtensionByFolder(dir, true); err != nil {
t.Fatal(err)
}
res, err := d.Resolve(&base.Request{URL: "https://example.com/unnamed"}, nil)
if err != nil {
t.Fatal(err)
}
if res.Res.Name != "" || len(res.Res.Files) != 1 || res.Res.Files[0].Name != "video.mp4" {
t.Fatalf("unexpected resource: %+v", res.Res)
}
if !d.blob.IsURL(res.Res.Files[0].Req.URL) {
t.Fatal("extension result was discarded")
}
}
+108
View File
@@ -0,0 +1,108 @@
package download
import (
"bytes"
"fmt"
httpProtocol "github.com/GopeedLab/gopeed/internal/protocol/http"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
)
func TestExtensionHTTPUserAgent(t *testing.T) {
video, err := os.ReadFile("engine/testdata/ffmpeg/video.mp4")
if err != nil {
t.Fatal(err)
}
audio, err := os.ReadFile("engine/testdata/ffmpeg/audio.mp4")
if err != nil {
t.Fatal(err)
}
for _, configured := range []string{" Configured-UA/1.0 ", "", "<default>"} {
for _, header := range []string{"default", "custom", "empty"} {
t.Run(fmt.Sprintf("configured=%q/header=%s", configured, header), func(t *testing.T) {
expected := strings.TrimSpace(configured)
if configured == "<default>" {
expected = httpProtocol.DefaultUserAgent
}
headers := `{}`
if header == "custom" {
expected = "Explicit-UA/2.0"
headers = `{"uSeR-aGeNt":"Explicit-UA/2.0"}`
}
if header == "empty" {
expected = ""
headers = `{"user-agent":""}`
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.UserAgent() != expected {
t.Errorf("%s UA=%q, want %q", r.URL.Path, r.UserAgent(), expected)
}
if strings.HasPrefix(r.URL.Path, "/redirect/") {
http.Redirect(w, r, strings.TrimPrefix(r.URL.Path, "/redirect"), http.StatusFound)
return
}
data := video
if strings.HasSuffix(r.URL.Path, "audio") {
data = audio
}
http.ServeContent(w, r, "media", time.Time{}, bytes.NewReader(data))
}))
defer server.Close()
d, cleanup, err := newTestExtensionEngineDownloader()
if err != nil {
t.Fatal(err)
}
defer cleanup()
cfg, err := d.GetConfig()
if err != nil {
t.Fatal(err)
}
cfg.ProtocolConfig["http"] = map[string]any{"userAgent": configured}
if configured == "<default>" {
cfg.ProtocolConfig["http"] = map[string]any{"connections": 4}
}
if err = d.PutConfig(cfg); err != nil {
t.Fatal(err)
}
runtime, err := newTestExtensionEngine(t, d)
if err != nil {
t.Fatal(err)
}
defer runtime.Close()
// The existing engine keeps its UA snapshot when the downloader config changes.
cfg, err = d.GetConfig()
if err != nil {
t.Fatal(err)
}
cfg.ProtocolConfig["http"] = map[string]any{"userAgent": "Next-engine-UA"}
if err = d.PutConfig(cfg); err != nil {
t.Fatal(err)
}
result, err := runtime.Eval(fmt.Sprintf(`(async()=>{
const headers=%s, url=%q;
await (await fetch(url+'/redirect/fetch',{headers})).arrayBuffer();
await new Promise((resolve,reject)=>{
const xhr=new XMLHttpRequest();xhr.open('GET',url+'/redirect/xhr');
for(const key of Object.keys(headers))xhr.setRequestHeader(key,headers[key]);
xhr.onload=resolve;xhr.onerror=()=>reject(new Error('XHR failed'));xhr.send();
});
// Profile defaults must not replace the configured or explicitly empty UA.
__gopeed_setFingerprint('chrome');
await (await fetch(url+'/fingerprint',{headers})).arrayBuffer();
const output=gopeed.runtime.ffmpeg.merge({video:{url:url+'/redirect/video',headers},audio:{url:url+'/redirect/audio',headers}});
return (await new Response(output).arrayBuffer()).byteLength > 0;
})()`, headers, server.URL))
if err != nil {
t.Fatal(err)
}
if result != true {
t.Fatalf("merge result=%v", result)
}
})
}
}
}
@@ -299,8 +299,9 @@ TaskAssetType _assetType(String name, {required bool isFolder, required api_task
}
String _fileExtension(String name) {
final pathWithoutQuery = name.split(RegExp(r'[?#]')).first;
final extension = path.extension(pathWithoutQuery).replaceFirst('.', '').toLowerCase();
final uri = Uri.tryParse(name);
final filePath = uri != null && uri.hasScheme && uri.hasAuthority ? uri.path : name;
final extension = path.extension(filePath).replaceFirst('.', '').toLowerCase();
return extension.length <= 10 ? extension : '';
}
@@ -0,0 +1,20 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/core/icons/gopeed_icons.dart';
import 'package:gopeed/features/tasks/domain/task_record.dart';
void main() {
test('file names preserve hash and question mark characters for type detection', () {
expect(
taskFileTypeIcon('Slow English Podcast #2 _ Eating, Talking, and Learning Natural English.mp4'),
GopeedIcons.fileVideo,
);
expect(taskFileTypeIcon('What is this? #2.MP4'), GopeedIcons.fileVideo);
expect(taskFileTypeIcon('Episode #2.mp3'), GopeedIcons.fileAudio);
});
test('URL fallback ignores query and fragment when detecting file type', () {
expect(taskFileTypeIcon('https://example.com/video.mp4?token=abc#part'), GopeedIcons.fileVideo);
expect(taskFileTypeIcon('https://example.com/download?name=video.mp4'), GopeedIcons.file);
expect(taskFileTypeIcon('Playlist #2.mp4', isFolder: true), GopeedIcons.folder);
});
}