Skip to content

Commit 306208e

Browse files
[Feat] Timelines and Keyframes (#3)
* new Timeline class * new Keyframe class * generateTimeline script * generateSnapshots > generateEasings * move generated easing snapshots * make directory for generated timeline snapshots * adds dummy test for Timeline class * updates readme
1 parent 733a8ce commit 306208e

File tree

10 files changed

+229
-5
lines changed

10 files changed

+229
-5
lines changed

.gitignore

+2-1
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ phpunit.xml
77
phpstan.neon
88
testbench.yaml
99
vendor
10-
tests/Snapshots/*.png
10+
tests/Snapshots/Easings/*.png
11+
tests/Snapshots/Timelines/*.mp4

README.md

+39-3
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ $x = (new Tween())
2323
```
2424

2525
The API is modelled after [The GreenSock Animation Platform (GSAP)](https://greensock.com/get-started/#whatIsGSAP)
26-
and all the math for the easings is ported from [Easings.net](https://easings.net.)
26+
and all the math for the easings is ported from [Easings.net](https://easings.net).
27+
The stringification of these math strings is ported from [This Gitlab repo](https://gitlab.com/dak425/easing/-/blob/master/ffmpeg/ffmpeg.go)
2728

2829

2930
## Installation
@@ -40,6 +41,7 @@ For now this package can only be used within a Laravel app, but there are plans
4041

4142
## Usage
4243

44+
Simple tween with delay and duration
4345
```php
4446
use ProjektGopher\FFMpegTween\Tween;
4547
use ProjektGopher\FFMpegTween\Timing;
@@ -53,6 +55,32 @@ $x = (new Tween())
5355
->ease(Ease::OutSine);
5456
```
5557

58+
Animation sequences using keyframes
59+
```php
60+
use ProjektGopher\FFMpegTween\Keyframe;
61+
use ProjektGopher\FFMpegTween\Timeline;
62+
use ProjektGopher\FFMpegTween\Timing;
63+
use ProjektGopher\FFMpegTween\Enums\Ease;
64+
65+
$x = new Timeline()
66+
$x->keyframe((new Keyframe)
67+
->value('-text_w') // outside left of frame
68+
->hold(Timing::seconds(1))
69+
);
70+
$x->keyframe((new Keyframe)
71+
->value('(main_w/2)-(text_w/2)') // center
72+
->ease(Ease::OutElastic)
73+
->duration(Timing::seconds(1))
74+
->hold(Timing::seconds(3))
75+
);
76+
$x->keyframe((new Keyframe)
77+
->value('main_w') // outside right of frame
78+
->ease(Ease::InBack)
79+
->duration(Timing::seconds(1))
80+
);
81+
```
82+
> **Note** `new Timeline()` returns a _fluent_ api, meaning methods can be chained as well.
83+
5684
## Testing
5785

5886
```bash
@@ -62,12 +90,20 @@ composer test
6290
### Visual Snapshot Testing
6391
To generate plots of all `Ease` methods, from the project root, run
6492
```bash
65-
./scripts/generateSnapshots
93+
./scripts/generateEasings
6694
```
67-
The 256x256 PNGs will be generated in the `tests/Snapshots` directory.
95+
The 256x256 PNGs will be generated in the `tests/Snapshots/Easings` directory.
6896
These snapshots will be ignored by git, but allow visual inspection of the plots to
6997
compare against known good sources, like [Easings.net](https://easings.net).
7098

99+
To generate a video using a `Timeline` with `Keyframes`, from the project root, run
100+
```bash
101+
./scripts/generateTimeline
102+
```
103+
The 256x256 MP4 will be generated in the `tests/Snapshots/Timelines` directory.
104+
These snapshots will also be ignored by git, but again allow for a visual
105+
inspection to ensure they match the expected output.
106+
71107
> **Note** The `scripts` directory _may_ need to have its permissions changed to allow script execution
72108
```bash
73109
chmod -R 777 ./scripts

scripts/generateSnapshots scripts/generateEasings

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ foreach (AvailableEasings::cases() as $ease) {
1717
$input = "-f lavfi -i \"color=c=black:s=256x256:d=1\"";
1818
$margin = '28';
1919
$filter = "-vf \"geq=if(eq(round((H-2*{$margin})*({$easeMultiplier}))\,H-Y-{$margin})\,128\,0):128:128\"";
20-
$out = "-frames:v 1 -update 1 tests/Snapshots/{$ease->value}.png";
20+
$out = "-frames:v 1 -update 1 tests/Snapshots/Easings/{$ease->value}.png";
2121
$redirect = '2>&1'; // redirect stderr to stdout
2222

2323
$cmd = "ffmpeg -y {$input} {$filter} {$out} {$redirect}";

scripts/generateTimeline

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
#!/usr/bin/env php
2+
<?php
3+
4+
require_once __DIR__.'/../vendor/autoload.php';
5+
6+
use ProjektGopher\FFMpegTween\Timeline;
7+
use ProjektGopher\FFMpegTween\Keyframe;
8+
use ProjektGopher\FFMpegTween\Enums\Ease;
9+
use ProjektGopher\FFMpegTween\Timing;
10+
11+
echo 'Generating video sample using Timeline...'.PHP_EOL;
12+
13+
$timeline = new Timeline();
14+
$timeline->keyframe((new Keyframe())
15+
->value('-th')
16+
->hold(Timing::seconds(1))
17+
);
18+
$timeline->keyframe((new Keyframe())
19+
->value('(main_h/2)-(th/2)')
20+
->ease(Ease::OutBounce)
21+
->duration(Timing::seconds(2))
22+
->hold(Timing::seconds(1))
23+
);
24+
$timeline->keyframe((new Keyframe())
25+
->value('main_h')
26+
->ease(Ease::InElastic)
27+
->duration(Timing::seconds(2))
28+
);
29+
30+
$input = "-f lavfi -i \"color=c=black:s=256x256:d=1\"";
31+
$filter = "-filter_complex \"[0:v] loop=-1:1 [bg]; [bg] drawtext=text='Timeline':fontcolor=white:x=(main_w/2)-(tw/2):y={$timeline}\"";
32+
$codecs = '-codec:a copy -codec:v libx264 -crf 25 -pix_fmt yuv420p';
33+
$duration = '-t 8'; // in seconds
34+
$out = "tests/Snapshots/Timelines/drawtext_y_enter-OutBounce_exit-InElastic.mp4";
35+
$redirect = '2>&1'; // redirect stderr to stdout
36+
37+
$cmd = "ffmpeg -y {$input} {$filter} {$codecs} {$duration} {$out} {$redirect}";
38+
39+
// TEMPORARY
40+
dump($timeline);
41+
echo $cmd;
42+
// die();
43+
44+
(array) $output = [];
45+
(int) $code = 0;
46+
exec($cmd, $output, $code);
47+
48+
if ($code !== 0) {
49+
echo PHP_EOL;
50+
echo "Failed to generate snapshot for Timeline class.".PHP_EOL;
51+
echo "Command: {$cmd}".PHP_EOL;
52+
echo "Output: ".PHP_EOL;
53+
echo implode(PHP_EOL, $output).PHP_EOL;
54+
exit(1);
55+
}

src/Keyframe.php

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
namespace ProjektGopher\FFMpegTween;
4+
5+
use ProjektGopher\FFMpegTween\Enums\Ease;
6+
7+
class Keyframe
8+
{
9+
public string $value;
10+
11+
public ?Ease $ease = null;
12+
13+
public ?Timing $duration = null;
14+
15+
public ?Timing $hold = null;
16+
17+
public function value(string $value): self
18+
{
19+
$this->value = $value;
20+
21+
return $this;
22+
}
23+
24+
public function ease(Ease $ease): self
25+
{
26+
$this->ease = $ease;
27+
28+
return $this;
29+
}
30+
31+
public function duration(Timing $duration): self
32+
{
33+
$this->duration = $duration;
34+
35+
return $this;
36+
}
37+
38+
public function hold(Timing $hold): self
39+
{
40+
$this->hold = $hold;
41+
42+
return $this;
43+
}
44+
}

src/Timeline.php

+78
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
namespace ProjektGopher\FFMpegTween;
4+
5+
class Timeline
6+
{
7+
private array $keyframes = [];
8+
9+
private array $tweens = [];
10+
11+
public function keyframe(Keyframe $keyframe): self
12+
{
13+
/**
14+
* The first keyframe should never have an ease, or duration.
15+
*/
16+
if (count($this->keyframes) === 0) {
17+
if ($keyframe->ease !== null) {
18+
throw new \Exception('The first keyframe should never have an ease.');
19+
}
20+
if ($keyframe->duration !== null) {
21+
throw new \Exception('The first keyframe should never have a duration.');
22+
}
23+
}
24+
25+
$this->keyframes[] = $keyframe;
26+
27+
return $this;
28+
}
29+
30+
public function buildTweens(): void
31+
{
32+
(int) $current_time = 0;
33+
34+
foreach ($this->keyframes as $index => $keyframe) {
35+
if (! $keyframe instanceof Keyframe) {
36+
throw new \Exception('Keyframe is not of type Keyframe.');
37+
}
38+
39+
// Skip the first keyframe, as the values will be baked into the next tween.
40+
if ($index !== 0) {
41+
$this->tweens[] = (new Tween())
42+
->from($this->getKeyframeByIndex($index - 1)->value)
43+
->to($keyframe->value)
44+
->delay(Timing::seconds($current_time))
45+
->duration($keyframe->duration)
46+
->ease($keyframe->ease);
47+
}
48+
49+
$current_time += $keyframe->hold?->seconds;
50+
$current_time += $keyframe->duration?->seconds;
51+
}
52+
}
53+
54+
public function getKeyframeByIndex(int $index): Keyframe
55+
{
56+
return $this->keyframes[$index];
57+
}
58+
59+
public function __toString(): string
60+
{
61+
if (count($this->tweens) === 0) {
62+
$this->buildTweens();
63+
}
64+
65+
// clone the array so we don't modify the original
66+
$tweens = $this->tweens;
67+
68+
// Initialize the timeline with the first tween.
69+
$timeline = array_shift($tweens);
70+
while ($tween = array_shift($tweens)) {
71+
// If the current time is greater than this tween's delay,
72+
// use the tween. Otherwise, use the previous timeline.
73+
$timeline = "if(gt(t\,{$tween->getDelay()})\,{$tween}\,{$timeline})";
74+
}
75+
76+
return $timeline;
77+
}
78+
}

src/Tween.php

+5
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ public function delay(Timing $delay): self
4444
return $this;
4545
}
4646

47+
public function getDelay(): string
48+
{
49+
return $this->delay;
50+
}
51+
4752
public function ease(AvailableEasings $ease): self
4853
{
4954
$easeString = Ease::{$ease->value}("(t-{$this->delay})/{$this->duration}");
File renamed without changes.

tests/Snapshots/Timelines/.gitkeep

Whitespace-only changes.

tests/src/TimelineTest.php

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<?php
2+
3+
test('happy path', function () {
4+
expect(true)->toBeTrue();
5+
});

0 commit comments

Comments
 (0)