about summary refs log tree commit diff stats
diff options
context:
space:
mode:
-rw-r--r--META6.json5
-rw-r--r--README142
-rw-r--r--README.org98
-rw-r--r--algorithms/raku/DFS.raku124
-rw-r--r--lib/Fornax/CLI.rakumod135
-rw-r--r--lib/Fornax/GenerateFrame.rakumod100
-rw-r--r--resources/input/0618
-rw-r--r--t/00-basic.rakutest3
8 files changed, 435 insertions, 190 deletions
diff --git a/META6.json b/META6.json
index 1cf95de..3fc67d1 100644
--- a/META6.json
+++ b/META6.json
@@ -1,14 +1,15 @@
 {
     "name" : "fornax",
     "auth" : "zef:andinus",
-    "version" : "0.1.0",
+    "version" : "0.2.0",
     "description" : "Collection of tools to visualize Path Finding Algorithms",
     "authors" : [ "Andinus <andinus@nand.sh>" ],
     "license" : "ISC",
     "perl" : "6.d",
     "provides" : {
         "Fornax::CLI" : "lib/Fornax/CLI.rakumod",
-        "Fornax::Hex2RGB" : "lib/Fornax/Hex2RGB.rakumod"
+        "Fornax::Hex2RGB" : "lib/Fornax/Hex2RGB.rakumod",
+        "Fornax::GenerateFrame" : "lib/Fornax/GenerateFrame.rakumod"
     },
     "depends" : [
         "Cairo:ver<0.2.7+>"
diff --git a/README b/README
index 76c9420..af6327f 100644
--- a/README
+++ b/README
@@ -11,11 +11,18 @@ Table of Contents
 ─────────────────
 
 1. Demo
-2. Installation
-3. Documentation
-4. Project Structure
-5. Fornax Format
+2. Usage
+3. Installation
+.. 1. Release
+.. 2. From Source
+4. Documentation
+.. 1. Options
+.. 2. Fornax Format
+.. 3. Project Structure
+5. Bugs
 6. News
+.. 1. v0.1.1 - 2021-11-16
+.. 2. v0.1.0 - 2021-11-03
 
 
 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -56,7 +63,19 @@ Writings:
 <https://andinus.unfla.me/resources/projects/fornax/2021-11-16-DFS-60.mp4>
 
 
-2 Installation
+2 Usage
+═══════
+
+  ┌────
+  │ # Solve the maze.
+  │ raku algorithms/raku/DFS.raku resources/input/06 > /tmp/solution.fornax
+  │
+  │ # Visualize the solution.
+  │ raku -Ilib bin/fornax /tmp/solution.fornax
+  └────
+
+
+3 Installation
 ══════════════
 
   `fornax' is written in Raku, it can be installed with `zef'. You can
@@ -66,7 +85,7 @@ Writings:
   • *Note*: `Cairo' module & `ffmpeg' program is required.
 
 
-2.1 Release
+3.1 Release
 ───────────
 
   1. Run `zef install 'fornax:auth<zef:andinus>''
@@ -77,14 +96,14 @@ Writings:
     get them from this repository.
 
 
-2.2 From Source
+3.2 From Source
 ───────────────
 
   You can either download the release archive generated by cgit/GitHub
   or clone the project if you have `git' installed.
 
 
-2.2.1 Without `git'
+3.2.1 Without `git'
 ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
 
   1. Download the release:
@@ -94,7 +113,7 @@ Writings:
   3. Run `zef install .' in source directory.
 
 
-2.2.2 With `git'
+3.2.2 With `git'
 ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
 
   All commits by /Andinus/ will be signed by this [PGP Key].
@@ -113,7 +132,7 @@ Writings:
 <https://andinus.nand.sh/static/D9AE4AEEE1F1B3598E81D9DFB67D55D482A799FD.asc>
 
 
-3 Documentation
+4 Documentation
 ═══════════════
 
   Fornax parses /Fornax format/, generates a `PNG' for each iteration
@@ -122,41 +141,60 @@ Writings:
   • Solved paths are highlighted if the iteration is preceded by `|'.
   • Illegal paths are highlighted if the iteration is preceded by `!'.
 
-  • *Note*: If the number of iterations are greater than an 8 digit
-    number then the slideshow might be incorrect.
-
 
-3.1 Options
+4.1 Options
 ───────────
 
   • `input': This takes solved input file in the /Fornax/ format.
-  • `frame-rate': Frame rate for the video.
-  • `output': Output directory (for solution video/images).
+  • `fps': Frame rate for the video solution.
+  • `skip-video': Skip generating the video solution.
+  • `batch': Number of iterations to process at once.
 
 
-4 Project Structure
-═══════════════════
+4.2 Fornax Format
+─────────────────
 
-  • Algorithms are located in `algorithms/' directory, sub-directory
-    needs to be created for programming languages which will hold the
-    actual source.
+  Fornax format defines 2 formats:
+  • Maze (input)
+  • Solution (output)
 
-  • Sample solutions can be found in `resources/solutions/' directory.
 
-    • *Note*: Some solutions might output illegal moves (like walking
-      over blocked path), this error is only in visualization, the
-      solution is correct.
+4.2.1 Grids
+╌╌╌╌╌╌╌╌╌╌╌
 
-      This has been fixed in commit
-      `8cef86f0eb8b46b0ed2d7c37fa216890300249f6'.
+  A grid is printed for every iteration. Grids are composed of cells.
+
+  ━━━━━━━━━━━━━━━━━━━━━━━━━━
+   Cell              Symbol
+  ──────────────────────────
+   Path              `.'
+   Blocked           `#'
+   Start             `^'
+   Destination       `$'
+  ──────────────────────────
+   Visited           `-'
+   Current Path      `~'
+   Current Position  `@'
+  ━━━━━━━━━━━━━━━━━━━━━━━━━━
 
 
-5 Fornax Format
-═══════════════
+4.2.2 Maze (input)
+╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
+
+  Maze input must be in this format:
+  ┌────
+  │ ...rows
+  └────
+
+  It is upto the program to infer the number of rows & columns from the
+  input file or it ask the user.
 
-  Fornax format is an intermediate output file generated after solving
-  the maze. Algorithms must output the solution in this format.
 
+4.2.3 Solution (output)
+╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
+
+  Fornax solution format is an intermediate output file generated after
+  solving the maze. Algorithms must output the solution in this format:
   ┌────
   │ rows:<number of rows> cols:<number of columns>

@@ -182,26 +220,34 @@ Writings:
   • First iteration is assumed to be the maze.
 
 
-5.1 Grids
-─────────
+4.3 Project Structure
+─────────────────────
 
-  A grid is printed for every iteration. Grids are composed of cells.
+  • Algorithms are located in `algorithms/' directory, sub-directory
+    needs to be created for programming languages which will hold the
+    actual source.
 
-  ━━━━━━━━━━━━━━━━━━━━━━━━━━
-   Cell              Symbol
-  ──────────────────────────
-   Path              `.'
-   Blocked           `#'
-   Start             `^'
-   Destination       `$'
-  ──────────────────────────
-   Visited           `-'
-   Current Path      `~'
-   Current Position  `@'
-  ━━━━━━━━━━━━━━━━━━━━━━━━━━
+  • Sample solutions can be found in `resources/solutions/' directory.
+
+    • *Note*: Some solutions might output illegal moves (like walking
+      over blocked path), this error is only in visualization, the
+      solution is correct.
+
+      This has been fixed in commit
+      `8cef86f0eb8b46b0ed2d7c37fa216890300249f6'.
+
+
+5 Bugs
+══════
+
+  • If the number of iterations are greater than an 8 digit number then
+    the slideshow might be incorrect.
+
+  • `/tmp' is assumed to exist.
 
-  • /Current Position/ is prioritized over /Blocked/ & /Destination/
-    symbol if it makes sense.
+  • Might panic with: `MoarVM oops: MVM_str_hash_entry_size called with
+      a stale hashtable pointer'. This has been fixed:
+      <https://github.com/rakudo/rakudo/pull/4634>.
 
 
 6 News
diff --git a/README.org b/README.org
index 20a8b0b..ec8d3f0 100644
--- a/README.org
+++ b/README.org
@@ -1,7 +1,8 @@
 #+title: Fornax
 #+subtitle: Collection of tools to visualize Path Finding Algorithms
 #+export_file_name: index
-#+options: toc:1
+#+options: toc:2
+#+startup: overview
 #+setupfile: ~/.emacs.d/org-templates/projects.org
 
 | Website         | https://andinus.unfla.me/projects/fornax |
@@ -32,6 +33,16 @@ Fornax v0.1.0:
 - DFS-51-incomplete (upto 4,000 frames; 120 fps):
   https://diode.zone/w/hWWaw8uKHCP5weUP5WWArD
 
+* Usage
+
+#+begin_src sh
+# Solve the maze.
+raku algorithms/raku/DFS.raku resources/input/06 > /tmp/solution.fornax
+
+# Visualize the solution.
+raku -Ilib bin/fornax /tmp/solution.fornax
+#+end_src
+
 * Installation
 
 ~fornax~ is written in Raku, it can be installed with ~zef~. You can also
@@ -83,35 +94,48 @@ later converted to a slideshow with ~ffmpeg~.
 - Solved paths are highlighted if the iteration is preceded by ~|~.
 - Illegal paths are highlighted if the iteration is preceded by ~!~.
 
-- *Note*: If the number of iterations are greater than an 8 digit number
-  then the slideshow might be incorrect.
-
 ** Options
 
 - ~input~: This takes solved input file in the /Fornax/ format.
-- ~frame-rate~: Frame rate for the video.
-- ~output~: Output directory (for solution video/images).
+- ~fps~: Frame rate for the video solution.
+- ~skip-video~: Skip generating the video solution.
+- ~batch~: Number of iterations to process at once.
 
-* Project Structure
+** Fornax Format
 
-- Algorithms are located in ~algorithms/~ directory, sub-directory needs
-  to be created for programming languages which will hold the actual
-  source.
+Fornax format defines 2 formats:
+- Maze (input)
+- Solution (output)
 
-- Sample solutions can be found in ~resources/solutions/~ directory.
+*** Grids
 
-  - *Note*: Some solutions might output illegal moves (like walking over
-    blocked path), this error is only in visualization, the solution is
-    correct.
+A grid is printed for every iteration. Grids are composed of cells.
 
-    This has been fixed in commit
-    ~8cef86f0eb8b46b0ed2d7c37fa216890300249f6~.
+| Cell             | Symbol |
+|------------------+--------|
+| Path             | ~.~      |
+| Blocked          | ~#~      |
+| Start            | ~^~      |
+| Destination      | ~$~      |
+|------------------+--------|
+| Visited          | ~-~      |
+| Current Path     | ~~~      |
+| Current Position | ~@~      |
 
-* Fornax Format
+*** Maze (input)
 
-Fornax format is an intermediate output file generated after solving the
-maze. Algorithms must output the solution in this format.
+Maze input must be in this format:
+#+begin_src
+...rows
+#+end_src
 
+It is upto the program to infer the number of rows & columns from the
+input file or it ask the user.
+
+*** Solution (output)
+
+Fornax solution format is an intermediate output file generated after
+solving the maze. Algorithms must output the solution in this format:
 #+begin_src
 rows:<number of rows> cols:<number of columns>
 
@@ -135,23 +159,31 @@ columns is known, the whole grid should be printed in a single line.
 
 - First iteration is assumed to be the maze.
 
-** Grids
+** Project Structure
 
-A grid is printed for every iteration. Grids are composed of cells.
+- Algorithms are located in ~algorithms/~ directory, sub-directory needs
+  to be created for programming languages which will hold the actual
+  source.
 
-| Cell             | Symbol |
-|------------------+--------|
-| Path             | ~.~      |
-| Blocked          | ~#~      |
-| Start            | ~^~      |
-| Destination      | ~$~      |
-|------------------+--------|
-| Visited          | ~-~      |
-| Current Path     | ~~~      |
-| Current Position | ~@~      |
+- Sample solutions can be found in ~resources/solutions/~ directory.
+
+  - *Note*: Some solutions might output illegal moves (like walking over
+    blocked path), this error is only in visualization, the solution is
+    correct.
+
+    This has been fixed in commit
+    ~8cef86f0eb8b46b0ed2d7c37fa216890300249f6~.
+
+* Bugs
+
+- If the number of iterations are greater than an 8 digit number then
+  the slideshow might be incorrect.
+
+- ~/tmp~ is assumed to exist.
 
-- /Current Position/ is prioritized over /Blocked/ & /Destination/ symbol if
-  it makes sense.
+- Might panic with: ~MoarVM oops: MVM_str_hash_entry_size called with a
+  stale hashtable pointer~. This has been fixed:
+  https://github.com/rakudo/rakudo/pull/4634.
 
 * News
 
diff --git a/algorithms/raku/DFS.raku b/algorithms/raku/DFS.raku
new file mode 100644
index 0000000..0cc7207
--- /dev/null
+++ b/algorithms/raku/DFS.raku
@@ -0,0 +1,124 @@
+subset File of Str where *.IO.f;
+
+# Cells as defined by fornax format.
+constant $PATH = '.';
+constant $BLOK = '#';
+constant $DEST = '$';
+constant $STRT = '^';
+constant $VIS = '-';
+constant $CUR = '@';
+constant $CURPATH = '~';
+
+sub MAIN(File $input) {
+    my @maze = $input.IO.lines.map(*.comb);
+    die "Inconsistent maze" unless [==] @maze.map(*.elems);
+
+    put "rows:{@maze.elems} cols:{@maze[0].elems}";
+    dfs(@maze, 0, 0);
+}
+
+sub dfs(
+    @maze, Int $y, Int $x, @visited?, @cur-path? --> Bool
+) {
+    # If @visited was not passed then mark the given cell as visited
+    # because it's the cell we're starting at.
+    @visited[$y][$x] = True unless @visited;
+    @cur-path[$y][$x] = True unless @cur-path;
+
+    # neighbor block loops over the neighbors of $y, $x.
+    neighbor: for neighbors(@maze, $y, $x).List.pick(*) -> $pos {
+        # Move on to next neighbor if we've already visited this one.
+        next neighbor if @visited[$pos[0]][$pos[1]];
+
+        # Printing Marker cells.
+        given @maze[$pos[0]][$pos[1]] {
+            when $DEST { print "|" }
+            when $BLOK { print "!" }
+        }
+
+        # Print the maze on every iteration.
+        for 0..@maze.end -> $j {
+            for 0..@maze[0].end -> $k {
+                if @maze[$j][$k] eq $STRT | $DEST {
+                    print @maze[$j][$k];
+                } else {
+                    if $j == $pos[0] and $k == $pos[1] {
+                        print "@";
+                    } else {
+                        print(
+                            @cur-path[$j][$k]
+                            ?? "~" !! @visited[$j][$k] ?? "-" !! @maze[$j][$k]
+                        );
+                    }
+                }
+            }
+        }
+        print "\n";
+
+        given @maze[$pos[0]][$pos[1]] {
+            when $DEST { exit; }
+            when $PATH|$STRT {
+                @visited[$pos[0]][$pos[1]] = @cur-path[$pos[0]][$pos[1]] = True;
+                dfs(@maze, $pos[0], $pos[1], @visited, @cur-path);
+                @cur-path[$pos[0]][$pos[1]] = False;
+            }
+        }
+    }
+}
+
+# neighbors returns the neighbors of given index. Neighbors are cached
+# in @neighbors array. This way we don't have to compute them
+# everytime neighbors subroutine is called for the same position. We
+# don't need this caching here since every cell will be visited only
+# once. This subroutine was taken from Octans::Neighbors.
+sub neighbors(
+    @puzzle, Int $y, Int $x --> List
+) is export {
+    # @directions is holding a list of directions we can move in. It's
+    # used later for neighbors subroutine.
+    state List @directions = (
+        # $y, $x
+        ( +1, +0 ), # bottom
+        ( -1, +0 ), # top
+        ( +0, +1 ), # left
+        ( +0, -1 ), # right
+    );
+
+    # @neighbors holds the neighbors of given position.
+    state Array @neighbors;
+
+    if @puzzle[$y][$x] {
+        # Don't re-compute neighbors.
+        unless @neighbors[$y][$x] {
+            # Set it to an empty array because otherwise if it has no
+            # neighbors then it would've be recomputed everytime
+            # neighbors() was called.
+            @neighbors[$y][$x] = [];
+
+            my Int $pos-x;
+            my Int $pos-y;
+
+            # Starting from the intital position of $y, $x we move to
+            # each direction according to the values specified in
+            # @directions array. In this case we're just trying to
+            # move in 4 directions (top, bottom, left & right).
+            direction: for @directions -> $direction {
+                $pos-y = $y + $direction[0];
+                $pos-x = $x + $direction[1];
+
+                # If movement in this direction is out of puzzle grid
+                # boundary then move on to next direction.
+                next direction unless @puzzle[$pos-y][$pos-x];
+
+                # If neighbors exist in this direction then add them
+                # to @neighbors[$y][$x] array.
+                push @neighbors[$y][$x], [$pos-y, $pos-x];
+            }
+        }
+    } else {
+        # If it's out of boundary then return no neighbor.
+        @neighbors[$y][$x] = [];
+    }
+
+    return @neighbors[$y][$x];
+}
diff --git a/lib/Fornax/CLI.rakumod b/lib/Fornax/CLI.rakumod
index 35109b7..98f20a7 100644
--- a/lib/Fornax/CLI.rakumod
+++ b/lib/Fornax/CLI.rakumod
@@ -1,5 +1,4 @@
-use Cairo;
-use Fornax::Hex2RGB;
+use Fornax::GenerateFrame;
 
 subset File of Str where *.IO.f;
 
@@ -15,52 +14,26 @@ proto MAIN(|) is export { unless so @*ARGS { put $*USAGE; exit }; {*} }
 #| Collection of tools to visualize Path Finding Algorithms
 multi sub MAIN(
     File $input, #= fornax format file (solved)
-    IO() :$out = '/tmp', #= output directory (default: /tmp)
-    Int() :$batch = 4, #= batch size (generate frames in parallel)
-    Rat() :$frame-rate = 1, #= frame rate (default: 1)
+
+    Int() :$batch = 4, #= number of iterations to process at once (default: 4)
+    Int() :fps($frame-rate) = 1, #= frame rate for video solution (default: 1)
     Bool :$skip-video, #= skip video solution
-    Bool :$verbose = True, #= verbosity
+    Bool :$debug, #= debug logs
 ) is export {
     my IO() $output = "%s/fornax-%s".sprintf(
-        $out.absolute, ('a'...'z', 'A'...'Z', 0...9).roll(8).join
+        '/tmp', ('a'...'z', 'A'...'Z', 0...9).roll(8).join
     );
     mkdir $output;
     die "Output directory doesn't exist" unless $output.d;
 
-    put "[fornax] Output: '$output'" if $verbose;
+    put "[fornax] Output: '$output'";
 
     my Str @lines = $input.IO.lines;
     my Int() %meta{Str} = Metadata.parse(@lines.first).Hash
-                             or die "Cannot parse metadata";
-
-    # Cells as defined by fornax format.
-    constant $PATH = '.';
-    constant $BLOK = '#';
-    constant $DEST = '$';
-    constant $STRT = '^';
-    constant $VIS = '-';
-    constant $CUR = '@';
-    constant $CURPATH = '~';
+                                  or die "Cannot parse metadata";
 
     constant %CANVAS = :1920width, :1080height;
 
-    # Colors.
-    constant %C = (
-        bg-main => "#ffffff",
-
-        red-subtle-bg => "#f2b0a2",
-        blue-subtle-bg => "#b5d0ff",
-        cyan-subtle-bg => "#c0efff",
-        green-subtle-bg => "#aecf90",
-
-        fg-main => "#000000",
-
-        fg-special-cold => "#093060",
-        fg-special-warm => "#5d3026",
-        fg-special-mild => "#184034",
-        fg-special-calm => "#61284f",
-    ).map: {.key => hex2rgb(.value)};
-
     # Every cell must be square. Get the maximum width, height and use
     # that to decide which is to be used.
     my Int %cell = width => %CANVAS<width> div %meta<cols>,
@@ -79,7 +52,8 @@ multi sub MAIN(
         $side = %cell<height>;
     }
 
-    enum IterStatus <Walking Blocked Completed>;
+    my $render-start = now;
+    my Int $total-frames = @lines.elems - 1;
 
     my Promise @p;
     for @lines.skip.kv -> $idx, $iter is rw {
@@ -88,89 +62,38 @@ multi sub MAIN(
         if @p.elems == $batch {
             await @p;
             @p = [];
+
+            print "\r";
+            print "%s  Remaining: %.2fs  Elapsed: %.2fs %s".sprintf(
+                "[fornax $idx/$total-frames]",
+                ((now - $render-start) / $idx) * ($total-frames - $idx),
+                now - $render-start, "        ",
+            );
         }
 
         push @p, start {
-            my IterStatus $status;
-            given $iter.substr(0, 1) {
-                when '|' { $status = Completed }
-                when '!' { $status = Blocked }
-                default { $status = Walking }
-            };
-
-            # Remove marker.
-            $iter .= substr(1) if $status == Completed|Blocked;
-
-            put "[fornax] $idx $iter $status" if $verbose;
-
-            my @grid = $iter.comb.rotor: %meta<cols>;
-            warn "Invalid grid: $idx $iter $status" unless @grid.elems == %meta<rows>;
-
-            given Cairo::Image.create(
-                Cairo::FORMAT_ARGB32, %CANVAS<width>, %CANVAS<height>
-            ) {
-                given Cairo::Context.new($_) {
-                    # Paint the entire canvas white.
-                    .rgb: |%C<bg-main>;
-                    .rectangle(0, 0, %CANVAS<width>, %CANVAS<height>);
-                    .fill;
-
-                    for ^%meta<rows> -> $r {
-                        for ^%meta<cols> -> $c {
-                            my Int @target = %excess<width> div 2 + $c * $side,
-                                             %excess<height> div 2 + $r * $side,
-                                             $side, $side;
-
-                            .rectangle: |@target;
-
-                            given @grid[$r][$c] -> $cell {
-                                # According to the format, current
-                                # position may be prioritized over
-                                # Destination symbol so we colorize it
-                                # according to $status.
-                                when $cell eq $CUR {
-                                    .rgba: |%C<fg-special-cold>, 0.56;
-                                    .rgba: |%C<fg-special-mild>, 0.72 if $status == Completed;
-                                    .rgba: |%C<fg-special-warm>, 0.72 if $status == Blocked;
-                                }
-                                when $cell eq $CURPATH {
-                                    .rgba: |%C<blue-subtle-bg>, 0.84;
-                                    .rgba: |%C<green-subtle-bg>, 0.96 if $status == Completed;
-                                    .rgba: |%C<red-subtle-bg>, 0.96 if $status == Blocked;
-                                }
-                                when $cell eq $VIS {
-                                    .rgba: |%C<cyan-subtle-bg>, 0.72;
-                                }
-                                when $cell eq $BLOK { .rgba: |%C<fg-main>, 0.56 }
-                                when $cell eq $STRT|$DEST { .rgba: |%C<fg-special-mild>, 0.72 }
-                                default { .rgba: |%C<fg-main>, 0.08 }
-                            }
-                            .fill :preserve;
-
-                            .rgb: |%C<fg-main>;
-                            .stroke;
-                        }
-                    }
-                }
-                .write_png("%s/%08d.png".sprintf: $output, $idx);
-                .finish;
-            }
+            generate-frame(
+                :%CANVAS, :%excess, :$side, :%meta, :$iter, :$idx, :$debug,
+                :out("%s/%08d.png".sprintf: $output, $idx),
+            );
         }
     }
     # Wait for remaining jobs to finish.
     await @p;
 
-    put "[fornax] Generated images." if $verbose;
+    print "\r";
+    put "[fornax] Generated $total-frames frames in %.2fs. %s".sprintf(
+        now - $render-start, " " x 16,
+    );
 
     unless $skip-video {
-        put "[fornax] Creating a slideshow." if $verbose;
+        put "[fornax] Creating a slideshow.";
 
-        my Str $log-level = $verbose ?? "info" !! "error";
+        my Str $log-level = $debug ?? "info" !! "error";
         run «ffmpeg -loglevel "$log-level" -r "$frame-rate" -i "$output/\%08d.png"
-                    -vf 'tpad=stop_mode=clone:stop_duration=4'
-                    -vcodec libx264 -crf 28 -pix_fmt yuv420p "$output/solution.mp4"»;
+                    -vf 'tpad=stop_mode=clone:stop_duration=2'
+                    -vcodec libx264 -crf 24 -pix_fmt yuv420p "$output/solution.mp4"»;
     }
-    put "[fornax] Output: '$output'";
 }
 
 multi sub MAIN(
diff --git a/lib/Fornax/GenerateFrame.rakumod b/lib/Fornax/GenerateFrame.rakumod
new file mode 100644
index 0000000..e19d342
--- /dev/null
+++ b/lib/Fornax/GenerateFrame.rakumod
@@ -0,0 +1,100 @@
+use Cairo;
+use Fornax::Hex2RGB;
+
+# Cells as defined by fornax format.
+constant $PATH = '.';
+constant $BLOK = '#';
+constant $DEST = '$';
+constant $STRT = '^';
+constant $VIS = '-';
+constant $CUR = '@';
+constant $CURPATH = '~';
+
+# Colors.
+constant %C = (
+    bg-main => "#ffffff",
+
+    red-subtle-bg => "#f2b0a2",
+    blue-subtle-bg => "#b5d0ff",
+    cyan-subtle-bg => "#c0efff",
+    green-subtle-bg => "#aecf90",
+
+    fg-main => "#000000",
+
+    fg-special-cold => "#093060",
+    fg-special-warm => "#5d3026",
+    fg-special-mild => "#184034",
+    fg-special-calm => "#61284f",
+).map: {.key => hex2rgb(.value)};
+
+enum IterStatus <Walking Blocked Completed>;
+
+sub generate-frame(
+    :%CANVAS, :$out, :%excess, :$side, :%meta, :$iter is copy
+    , :$idx, :$debug,
+) is export {
+    my IterStatus $status;
+    given $iter.substr(0, 1) {
+        when '|' { $status = Completed }
+        when '!' { $status = Blocked }
+        default { $status = Walking }
+    };
+
+    # Remove marker.
+    $iter .= substr(1) if $status == Completed|Blocked;
+
+    put "\n[fornax] $idx $iter $status" if $debug;
+
+    my @grid = $iter.comb.rotor: %meta<cols>;
+    warn "Invalid grid: $idx $iter $status" unless @grid.elems == %meta<rows>;
+
+    given Cairo::Image.create(
+        Cairo::FORMAT_ARGB32, %CANVAS<width>, %CANVAS<height>
+    ) {
+        given Cairo::Context.new($_) {
+            # Paint the entire canvas white.
+            .rgb: |%C<bg-main>;
+            .rectangle(0, 0, %CANVAS<width>, %CANVAS<height>);
+            .fill;
+
+            # This seems to be slower than creating an intermediate
+            # variable and assigning from that. Difference is not much
+            # so we'll ignore it.
+            for ^%meta<rows> X ^%meta<cols>  -> ($r, $c) {
+                my Int @target = %excess<width> div 2 + $c * $side,
+                                 %excess<height> div 2 + $r * $side,
+                                 $side, $side;
+
+                .rectangle: |@target;
+
+                given @grid[$r][$c] -> $cell {
+                    # According to the format, current position may be
+                    # prioritized over Destination symbol so we
+                    # colorize it according to $status.
+                    when $cell eq $CUR {
+                        .rgba: |%C<fg-special-cold>, 0.56;
+                        .rgba: |%C<fg-special-mild>, 0.72 if $status == Completed;
+                        .rgba: |%C<fg-special-warm>, 0.72 if $status == Blocked;
+                    }
+                    when $cell eq $CURPATH {
+                        .rgba: |%C<blue-subtle-bg>, 0.84;
+                        .rgba: |%C<green-subtle-bg>, 0.96 if $status == Completed;
+                        .rgba: |%C<red-subtle-bg>, 0.96 if $status == Blocked;
+                    }
+                    when $cell eq $VIS {
+                        .rgba: |%C<cyan-subtle-bg>, 0.72;
+                    }
+                    when $cell eq $BLOK { .rgba: |%C<fg-main>, 0.56 }
+                    when $cell eq $STRT|$DEST { .rgba: |%C<fg-special-mild>, 0.72 }
+                    default { .rgba: |%C<fg-main>, 0.08 }
+                }
+                .fill :preserve;
+
+                .rgb: |%C<fg-main>;
+                .stroke;
+            }
+        }
+        .write_png($out);
+        .finish;
+    }
+}
diff --git a/resources/input/06 b/resources/input/06
new file mode 100644
index 0000000..8e58064
--- /dev/null
+++ b/resources/input/06
@@ -0,0 +1,18 @@
+^...........#...................
+........#...#...................
+.......#..########..............
+.......#........................
+.......#........................
+......#.........................
+...#.........#....##############
+...#.........#..................
+.......#.....#.....#............
+...................#............
+........#..........#............
+....#...#...##.....#............
+........#..........#...#########
+...................#............
+...................#......$.....
+................................
+.............#..................
+.............#..................
diff --git a/t/00-basic.rakutest b/t/00-basic.rakutest
index a119847..9731395 100644
--- a/t/00-basic.rakutest
+++ b/t/00-basic.rakutest
@@ -1,6 +1,7 @@
 use Test;
 
-plan 2;
+plan 3;
 
 use-ok 'Fornax::CLI';
 use-ok 'Fornax::Hex2RGB';
+use-ok 'Fornax::GenerateFrame';