Skip to content

Commit

Permalink
[Feat] Timelines and Keyframes (#3)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
ProjektGopher authored Mar 31, 2023
1 parent 733a8ce commit 306208e
Show file tree
Hide file tree
Showing 10 changed files with 229 additions and 5 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ phpunit.xml
phpstan.neon
testbench.yaml
vendor
tests/Snapshots/*.png
tests/Snapshots/Easings/*.png
tests/Snapshots/Timelines/*.mp4
42 changes: 39 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ $x = (new Tween())
```

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


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

## Usage

Simple tween with delay and duration
```php
use ProjektGopher\FFMpegTween\Tween;
use ProjektGopher\FFMpegTween\Timing;
Expand All @@ -53,6 +55,32 @@ $x = (new Tween())
->ease(Ease::OutSine);
```

Animation sequences using keyframes
```php
use ProjektGopher\FFMpegTween\Keyframe;
use ProjektGopher\FFMpegTween\Timeline;
use ProjektGopher\FFMpegTween\Timing;
use ProjektGopher\FFMpegTween\Enums\Ease;

$x = new Timeline()
$x->keyframe((new Keyframe)
->value('-text_w') // outside left of frame
->hold(Timing::seconds(1))
);
$x->keyframe((new Keyframe)
->value('(main_w/2)-(text_w/2)') // center
->ease(Ease::OutElastic)
->duration(Timing::seconds(1))
->hold(Timing::seconds(3))
);
$x->keyframe((new Keyframe)
->value('main_w') // outside right of frame
->ease(Ease::InBack)
->duration(Timing::seconds(1))
);
```
> **Note** `new Timeline()` returns a _fluent_ api, meaning methods can be chained as well.
## Testing

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

To generate a video using a `Timeline` with `Keyframes`, from the project root, run
```bash
./scripts/generateTimeline
```
The 256x256 MP4 will be generated in the `tests/Snapshots/Timelines` directory.
These snapshots will also be ignored by git, but again allow for a visual
inspection to ensure they match the expected output.

> **Note** The `scripts` directory _may_ need to have its permissions changed to allow script execution
```bash
chmod -R 777 ./scripts
Expand Down
2 changes: 1 addition & 1 deletion scripts/generateSnapshots → scripts/generateEasings
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ foreach (AvailableEasings::cases() as $ease) {
$input = "-f lavfi -i \"color=c=black:s=256x256:d=1\"";
$margin = '28';
$filter = "-vf \"geq=if(eq(round((H-2*{$margin})*({$easeMultiplier}))\,H-Y-{$margin})\,128\,0):128:128\"";
$out = "-frames:v 1 -update 1 tests/Snapshots/{$ease->value}.png";
$out = "-frames:v 1 -update 1 tests/Snapshots/Easings/{$ease->value}.png";
$redirect = '2>&1'; // redirect stderr to stdout

$cmd = "ffmpeg -y {$input} {$filter} {$out} {$redirect}";
Expand Down
55 changes: 55 additions & 0 deletions scripts/generateTimeline
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#!/usr/bin/env php
<?php

require_once __DIR__.'/../vendor/autoload.php';

use ProjektGopher\FFMpegTween\Timeline;
use ProjektGopher\FFMpegTween\Keyframe;
use ProjektGopher\FFMpegTween\Enums\Ease;
use ProjektGopher\FFMpegTween\Timing;

echo 'Generating video sample using Timeline...'.PHP_EOL;

$timeline = new Timeline();
$timeline->keyframe((new Keyframe())
->value('-th')
->hold(Timing::seconds(1))
);
$timeline->keyframe((new Keyframe())
->value('(main_h/2)-(th/2)')
->ease(Ease::OutBounce)
->duration(Timing::seconds(2))
->hold(Timing::seconds(1))
);
$timeline->keyframe((new Keyframe())
->value('main_h')
->ease(Ease::InElastic)
->duration(Timing::seconds(2))
);

$input = "-f lavfi -i \"color=c=black:s=256x256:d=1\"";
$filter = "-filter_complex \"[0:v] loop=-1:1 [bg]; [bg] drawtext=text='Timeline':fontcolor=white:x=(main_w/2)-(tw/2):y={$timeline}\"";
$codecs = '-codec:a copy -codec:v libx264 -crf 25 -pix_fmt yuv420p';
$duration = '-t 8'; // in seconds
$out = "tests/Snapshots/Timelines/drawtext_y_enter-OutBounce_exit-InElastic.mp4";
$redirect = '2>&1'; // redirect stderr to stdout

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

// TEMPORARY
dump($timeline);
echo $cmd;
// die();

(array) $output = [];
(int) $code = 0;
exec($cmd, $output, $code);

if ($code !== 0) {
echo PHP_EOL;
echo "Failed to generate snapshot for Timeline class.".PHP_EOL;
echo "Command: {$cmd}".PHP_EOL;
echo "Output: ".PHP_EOL;
echo implode(PHP_EOL, $output).PHP_EOL;
exit(1);
}
44 changes: 44 additions & 0 deletions src/Keyframe.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

namespace ProjektGopher\FFMpegTween;

use ProjektGopher\FFMpegTween\Enums\Ease;

class Keyframe
{
public string $value;

public ?Ease $ease = null;

public ?Timing $duration = null;

public ?Timing $hold = null;

public function value(string $value): self
{
$this->value = $value;

return $this;
}

public function ease(Ease $ease): self
{
$this->ease = $ease;

return $this;
}

public function duration(Timing $duration): self
{
$this->duration = $duration;

return $this;
}

public function hold(Timing $hold): self
{
$this->hold = $hold;

return $this;
}
}
78 changes: 78 additions & 0 deletions src/Timeline.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

namespace ProjektGopher\FFMpegTween;

class Timeline
{
private array $keyframes = [];

private array $tweens = [];

public function keyframe(Keyframe $keyframe): self
{
/**
* The first keyframe should never have an ease, or duration.
*/
if (count($this->keyframes) === 0) {
if ($keyframe->ease !== null) {
throw new \Exception('The first keyframe should never have an ease.');
}
if ($keyframe->duration !== null) {
throw new \Exception('The first keyframe should never have a duration.');
}
}

$this->keyframes[] = $keyframe;

return $this;
}

public function buildTweens(): void
{
(int) $current_time = 0;

foreach ($this->keyframes as $index => $keyframe) {
if (! $keyframe instanceof Keyframe) {
throw new \Exception('Keyframe is not of type Keyframe.');
}

// Skip the first keyframe, as the values will be baked into the next tween.
if ($index !== 0) {
$this->tweens[] = (new Tween())
->from($this->getKeyframeByIndex($index - 1)->value)
->to($keyframe->value)
->delay(Timing::seconds($current_time))
->duration($keyframe->duration)
->ease($keyframe->ease);
}

$current_time += $keyframe->hold?->seconds;
$current_time += $keyframe->duration?->seconds;
}
}

public function getKeyframeByIndex(int $index): Keyframe
{
return $this->keyframes[$index];
}

public function __toString(): string
{
if (count($this->tweens) === 0) {
$this->buildTweens();
}

// clone the array so we don't modify the original
$tweens = $this->tweens;

// Initialize the timeline with the first tween.
$timeline = array_shift($tweens);
while ($tween = array_shift($tweens)) {
// If the current time is greater than this tween's delay,
// use the tween. Otherwise, use the previous timeline.
$timeline = "if(gt(t\,{$tween->getDelay()})\,{$tween}\,{$timeline})";
}

return $timeline;
}
}
5 changes: 5 additions & 0 deletions src/Tween.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ public function delay(Timing $delay): self
return $this;
}

public function getDelay(): string
{
return $this->delay;
}

public function ease(AvailableEasings $ease): self
{
$easeString = Ease::{$ease->value}("(t-{$this->delay})/{$this->duration}");
Expand Down
File renamed without changes.
Empty file.
5 changes: 5 additions & 0 deletions tests/src/TimelineTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php

test('happy path', function () {
expect(true)->toBeTrue();
});

0 comments on commit 306208e

Please sign in to comment.