Skip to content

Commit 6619563

Browse files
[Feat] FilterGraphs (#15)
* wip * code style * wip: docs * add to doc * add stream validation * tests
1 parent 1265258 commit 6619563

File tree

4 files changed

+178
-0
lines changed

4 files changed

+178
-0
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ The `Expr` class can help with this. If your expression is short enough this mig
9898
+ );
9999
```
100100

101+
## Docs
102+
Here's some more detailed information on some of this package's features:
103+
[Filter Graphs](docs/FilterGraphs.md)
104+
101105
## Testing
102106
```bash
103107
composer test

docs/FilterGraphs.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Filter Graphs
2+
A filter graph is what's used in the `-filter_complex` option of an FFMpeg command. These take in labelled audio or video streams, run a filter over them, then return newly labelled streams for further processing. As more filters are applied, these strings can become cumbersome to manage.
3+
4+
## Usage
5+
When cast to a `string` either by explicitly _type hinting_ (eg. `(string) $filter_complex`), or by simply **using** it as a string (eg. `$cmd = "...{$filter_complex}";`) the `FilterGraph` class will call the `__toString()` magic method, which in turn calls the `build()` method. This makes debugging and testing easier as the `__toString` method cannot throw useful exceptions. You can **chain** the `build()` method to take advantage of this, but it's not ultimately necessary outside of testing and debugging.
6+
7+
```php
8+
use ProjektGopher\FFMpegTools\Filters\FilterGraph;
9+
use ProjektGopher\FFMpegTools\Filters\Video\VideoFilter;
10+
11+
$filter_complex = (string) FilterGraph::make()->addFilter(
12+
in: '[0:v]',
13+
filters: VideoFilter::drawtext()->text('Hello World'),
14+
out: '[vid_with_text]',
15+
);
16+
17+
$cmd = "ffmpeg -y -i input.mp4 ${filter_complex} out.mp4";
18+
```
19+
20+
### Instantiation
21+
The `FilterGraph` class can be instatiated using the class constructor `(new FilterGraph)` or using the _static_ `make()` method, which simply calls the class constructor. The constructor takes no arguments, and all properties are protected. The choice here is usually just a matter of which option looks better with the way in which your command is formatted.
22+
23+
### Adding Filter(s)
24+
The `addFilter()` method is really the only method you should need to use in this class. It returns `self` to allow chaining **multiple** filters. For the sake of clarity, it's recommended to use **named** arguments when calling this method. It accepts **3** arguments: a `string` representing the **named** input streams `$in` (eg: `[0:v]`, `[named_input]`); A `string`, `array`, or `Filter` object, representing the filter to be applied `$filters`; And a **nullable** `string` representing the **named** output streams `$out` (eg: `[bg_with_text]`).
25+
26+
Named streams should **not** be re-used. A stream should only be exported and imported **once**. This has nothing to do with this package, it's just an FFMpeg thing.
27+
28+
If `$filters` is passed an `array`, the `validateFilters()` method is called recursively until a final list of `strings` or `Filters` has been created. This is generally only done when very simple filters that accept/output single streams are used to avoid intermediate named outputs (eg: `[1:v] scale=-1:1080 [photo];[photo] split [photo_1][photo_2]` becomes `[1:v] scale=-1:1080, split [photo_1][photo_2]`).
29+
30+
This example can now be written as:
31+
```php
32+
$filter_complex = (string) FilterGraph::make()->addFilter(
33+
in: '[1:v]',
34+
filters: [
35+
'scale=-1:1080',
36+
'split',
37+
],
38+
out: '[photo_1][photo_2]',
39+
);
40+
```
41+
42+
For something this simple, the `FilterGraph` class might be a bit overkill; But the more `complex` your graph becomes (pun intended), the more useful this abstraction becomes. It also just helps limit line-length in your IDE, which is always nice.
43+
44+
### Extending
45+
The `FilterGraph` class is `final` and can therefor not be extended.
46+
47+
## Testing
48+
You can call the `->build()` method after the end of your method chain in order to `dd()` for manual inspection, or to `assert` against.

src/Filters/FilterGraph.php

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
namespace ProjektGopher\FFMpegTools\Filters;
4+
5+
final class FilterGraph
6+
{
7+
protected array $filters = [];
8+
9+
public static function make(): static
10+
{
11+
return new static();
12+
}
13+
14+
public function addFilter(
15+
string $in,
16+
string|array|Filter $filters,
17+
string|null $out = null,
18+
): self {
19+
$this->filters[] = [
20+
'in' => $this->validateStreams($in),
21+
'filters' => $this->validateFilters($filters),
22+
'out' => $out ? $this->validateStreams($out) : null,
23+
];
24+
25+
return $this;
26+
}
27+
28+
public function validateStreams(string $streams): string
29+
{
30+
if (! preg_match('/^(\[[a-zA-Z0-9-_:]+\])+$/', $streams)) {
31+
throw new \Exception("Could not parse stream {$streams}");
32+
}
33+
34+
return $streams;
35+
}
36+
37+
public function validateFilters(string|array|Filter $filters): array
38+
{
39+
if ($filters instanceof Filter) {
40+
return [(string) $filters];
41+
}
42+
43+
if (is_string($filters)) {
44+
return [$filters];
45+
}
46+
47+
$thing = [];
48+
foreach ($filters as $filter) {
49+
$thing[] = $this->validateFilters($filter)[0];
50+
}
51+
52+
return $thing;
53+
}
54+
55+
public function buildFilterString(array $filter): string
56+
{
57+
$filterString = "{$filter['in']} ";
58+
$filterString .= implode(', ', $filter['filters']);
59+
if ($filter['out']) {
60+
$filterString .= " {$filter['out']}";
61+
}
62+
63+
return $filterString;
64+
}
65+
66+
public function build(): string
67+
{
68+
$filters = [];
69+
foreach ($this->filters as $filter) {
70+
$filters[] = $this->buildFilterString($filter);
71+
}
72+
$filters = implode(';', $filters);
73+
74+
return "-filter_complex \"{$filters}\"";
75+
}
76+
77+
public function __toString(): string
78+
{
79+
return $this->build();
80+
}
81+
}

tests/src/Filters/FilterGraphTest.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
use ProjektGopher\FFMpegTools\Filters\FilterGraph;
4+
use ProjektGopher\FFMpegTools\Filters\Video\VideoFilter;
5+
6+
it('expects a stream to be wrapped in brackets', function () {
7+
expect(fn () => FilterGraph::make()->validateStreams('foo'))
8+
->toThrow(\Exception::class);
9+
});
10+
11+
it('accepts multiple streams', function () {
12+
expect(FilterGraph::make()->validateStreams('[foo][1:v]'))->toEqual('[foo][1:v]');
13+
});
14+
15+
it('builds a filter graph', function () {
16+
expect((string) FilterGraph::make()
17+
->addFilter(
18+
in: '[0:v]',
19+
filters: VideoFilter::drawtext()->text('Hello World'),
20+
out: '[vid_with_text]',
21+
)
22+
)->toEqual("-filter_complex \"[0:v] drawtext=text='Hello World' [vid_with_text]\"");
23+
});
24+
25+
it('can skip the named output stream', function () {
26+
expect((string) FilterGraph::make()
27+
->addFilter(
28+
in: '[0:v]',
29+
filters: VideoFilter::drawtext()->text('Hello World'),
30+
)
31+
)->toEqual("-filter_complex \"[0:v] drawtext=text='Hello World'\"");
32+
});
33+
34+
it('builds a filter graph with multiple string filters', function () {
35+
expect((string) FilterGraph::make()
36+
->addFilter(
37+
in: '[1:v]',
38+
filters: [
39+
'scale=-1:1080',
40+
'split',
41+
],
42+
out: '[photo_1][photo_2]',
43+
)
44+
)->toEqual('-filter_complex "[1:v] scale=-1:1080, split [photo_1][photo_2]"');
45+
});

0 commit comments

Comments
 (0)