diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md
index 0df8b1dc..7cfaf5e8 100644
--- a/.github/CODE_OF_CONDUCT.md
+++ b/.github/CODE_OF_CONDUCT.md
@@ -34,7 +34,7 @@ This Code of Conduct applies both within project spaces and in public spaces whe
 
 ## Enforcement
 
-Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at mat.iavarone@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
+Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at contact@deepmedia.io. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
 
 Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
 
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 89da4fab..b9763c60 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -1 +1,30 @@
-Contributing guidelines are [hosted here](https://natario1.github.io/Transcoder/extra/contributing).
\ No newline at end of file
+
+Everyone is welcome to contribute with suggestions or pull requests. We are grateful to anyone who will contribute with fixes, features or feature requests. 
+
+### Bug reports
+
+Please make sure to fill the bug report issue template on GitHub, if applicable.
+We highly recommend to try to reproduce the bug in the demo app, as this helps a lot in debugging
+and excludes programming errors from your side.
+
+Make sure to include:
+
+- A clear and concise description of what the bug is
+- Transcoder version, device type, Android API level
+- Exact steps to reproduce the issue
+- Description of the expected behavior
+- The original media file(s) that manifest the problem
+
+Recommended extras:
+
+- LogCat logs (use `Logger.setLogLevel(LEVEL_VERBOSE)` to print all)
+- Link to a GitHub repo where the bug is reproducible
+
+### Pull Requests
+
+Please open an issue first!
+
+Unless your PR is a simple fix (typos, documentation, bugs with obvious solution), opening an issue
+will let us discuss the problem, take design decisions and have a reference to the issue description.
+
+If you can, please write tests. We are planning to work on improving the library test coverage soon.
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
deleted file mode 100644
index 47471b0d..00000000
--- a/.github/FUNDING.yml
+++ /dev/null
@@ -1,12 +0,0 @@
-# These are supported funding model platforms
-
-github: [natario1]
-patreon: # Replace with a single Patreon username
-open_collective: # Replace with a single Open Collective username
-ko_fi: # Replace with a single Ko-fi username
-tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
-community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
-liberapay: # Replace with a single Liberapay username
-issuehunt: # Replace with a single IssueHunt username
-otechie: # Replace with a single Otechie username
-custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 57b927e1..5744e083 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -1,56 +1,64 @@
 # https://help.github.com/en/actions/automating-your-workflow-with-github-actions/workflow-syntax-for-github-actions
-# Renaming ? Change the README badge.
 name: Build
 on:
   push:
     branches:
-      - master
+      - main
   pull_request:
-env:
-  TRAVIS: true
 jobs:
   ANDROID_BASE_CHECKS:
     name: Base Checks
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v1
-      - uses: actions/setup-java@v1
+      - uses: actions/checkout@v4
+      - uses: actions/setup-java@v4
         with:
-          java-version: 1.8
+          java-version: 17
+          distribution: temurin
+          cache: gradle
       - name: Perform base checks
-        run: ./gradlew demo:assembleDebug lib:javadoc
+        run: ./gradlew demo:assembleDebug lib:deployLocal --stacktrace
   ANDROID_EMULATOR_TESTS:
     name: Emulator Tests
-    runs-on: macOS-latest
+    runs-on: ubuntu-latest
     strategy:
       fail-fast: false
       matrix:
-        EMULATOR_API: [22, 25, 28]
-        include:
-          - EMULATOR_API: 28
-            EMULATOR_ARCH: x86_64
-          - EMULATOR_API: 25
-            EMULATOR_ARCH: x86
-          - EMULATOR_API: 22
-            EMULATOR_ARCH: x86
+        EMULATOR_API: [24, 27, 29, 31, 34]
     steps:
-      - uses: actions/checkout@v1
-      - uses: actions/setup-java@v1
+      - uses: actions/checkout@v4
+      - uses: actions/setup-java@v4
         with:
-          java-version: 1.8
+          java-version: 17
+          distribution: temurin
+          cache: gradle
+
+      - name: Enable KVM group perms
+        run: |
+          echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
+          sudo udevadm control --reload-rules
+          sudo udevadm trigger --name-match=kvm
+
       - name: Execute emulator tests
         timeout-minutes: 30
-        uses: reactivecircus/android-emulator-runner@v2.2.0
+        uses: reactivecircus/android-emulator-runner@v2
         with:
           api-level: ${{ matrix.EMULATOR_API }}
-          arch: ${{ matrix.EMULATOR_ARCH }}
-          disable-animations: true
-          profile: Nexus 5X
+          arch: x86_64
+          profile: Nexus 6
           emulator-options: -no-snapshot -no-window -no-boot-anim -camera-back none -camera-front none -gpu swiftshader_indirect
-          emulator-build: 6031357
-          script: ./.github/workflows/emulator_script.sh
+          script: ./.github/workflows/emulator_script.sh logcat_${{ matrix.EMULATOR_API }}.txt
+
+      - name: Upload emulator logs
+        uses: actions/upload-artifact@v4
+        if: always()
+        with:
+          name: emulator_logs_${{ matrix.EMULATOR_API }}
+          path: ./logcat_${{ matrix.EMULATOR_API }}.txt
+
       - name: Upload emulator tests artifact
-        uses: actions/upload-artifact@v1
+        uses: actions/upload-artifact@v4
+        if: always()
         with:
           name: emulator_tests_${{ matrix.EMULATOR_API }}
-          path: ./lib/build/outputs/code_coverage/debugAndroidTest/connected
\ No newline at end of file
+          path: ./lib/build/reports/androidTests/connected/debug/
\ No newline at end of file
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 21307445..812d3844 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -4,17 +4,24 @@ on:
   release:
     types: [published]
 jobs:
-  BINTRAY_UPLOAD:
-    name: Bintray Upload
+  MAVEN_UPLOAD:
+    name: Maven Central Upload
     runs-on: ubuntu-latest
     env:
-      TRAVIS: true
-      BINTRAY_USER: ${{ secrets.BINTRAY_USER }}
-      BINTRAY_KEY: ${{ secrets.BINTRAY_KEY }}
+      SIGNING_KEY: ${{ secrets.SIGNING_KEY }}
+      SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }}
+      SONATYPE_USER: ${{ secrets.SONATYPE_USER }}
+      SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }}
+      GHUB_USER: ${{ secrets.GHUB_USER }}
+      GHUB_PERSONAL_ACCESS_TOKEN: ${{ secrets.GHUB_PERSONAL_ACCESS_TOKEN }}
     steps:
-      - uses: actions/checkout@v1
-      - uses: actions/setup-java@v1
+      - uses: actions/checkout@v4
+      - uses: actions/setup-java@v4
         with:
-          java-version: 1.8
-      - name: Perform bintray upload
-        run: ./gradlew bintrayUpload
+          java-version: 17
+          distribution: temurin
+          cache: gradle
+      - name: Publish to Maven Central
+        run: ./gradlew deployNexus --stacktrace
+      - name: Publish to GitHub Packages
+        run: ./gradlew deployGithub --stacktrace
diff --git a/.github/workflows/emulator_script.sh b/.github/workflows/emulator_script.sh
index 921467b1..58a57fb1 100755
--- a/.github/workflows/emulator_script.sh
+++ b/.github/workflows/emulator_script.sh
@@ -1,8 +1,6 @@
 #!/usr/bin/env bash
-ADB_TAGS="Transcoder:I Engine:I"
-ADB_TAGS="$ADB_TAGS DefaultVideoStrategy:I DefaultAudioStrategy:I"
-ADB_TAGS="$ADB_TAGS VideoDecoderOutput:I VideoFrameDropper:I"
-ADB_TAGS="$ADB_TAGS AudioEngine:I"
 adb logcat -c
-adb logcat $ADB_TAGS *:E -v color &
-./gradlew lib:connectedCheck
\ No newline at end of file
+adb logcat *:V > "$1" &
+LOGCAT_PID=$!
+trap "kill $LOGCAT_PID" EXIT
+./gradlew lib:connectedCheck --stacktrace
\ No newline at end of file
diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml
new file mode 100644
index 00000000..e1914ec5
--- /dev/null
+++ b/.github/workflows/snapshot.yml
@@ -0,0 +1,27 @@
+# https://help.github.com/en/actions/automating-your-workflow-with-github-actions/workflow-syntax-for-github-actions
+# Renaming ? Change the README badge.
+name: Snapshot
+on:
+  push:
+    branches:
+      - main
+jobs:
+  SNAPSHOT:
+    name: Publish Snapshot
+    runs-on: ubuntu-latest
+    env:
+      SIGNING_KEY: ${{ secrets.SIGNING_KEY }}
+      SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }}
+      SONATYPE_USER: ${{ secrets.SONATYPE_USER }}
+      SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }}
+      GHUB_USER: ${{ secrets.GHUB_USER }}
+      GHUB_PERSONAL_ACCESS_TOKEN: ${{ secrets.GHUB_PERSONAL_ACCESS_TOKEN }}
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-java@v4
+        with:
+          java-version: 17
+          distribution: temurin
+          cache: gradle
+      - name: Publish nexus snapshot
+        run: ./gradlew deployNexusSnapshot --stacktrace
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 79859d12..d680f1ee 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,5 @@
 .gradle
 /local.properties
-/.idea
 .DS_Store
 /build
 *.iml
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 00000000..f0c75ba1
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,5 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+androidTestResultsUserPreferences.xml
+deploymentTargetDropDown.xml
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
new file mode 100644
index 00000000..b589d56e
--- /dev/null
+++ b/.idea/compiler.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="CompilerConfiguration">
+    <bytecodeTargetLevel target="17" />
+  </component>
+</project>
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
new file mode 100644
index 00000000..75ba98d7
--- /dev/null
+++ b/.idea/gradle.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="GradleMigrationSettings" migrationVersion="1" />
+  <component name="GradleSettings">
+    <option name="linkedExternalProjectsSettings">
+      <GradleProjectSettings>
+        <option name="externalProjectPath" value="$PROJECT_DIR$" />
+        <option name="gradleJvm" value="jbr-17" />
+        <option name="modules">
+          <set>
+            <option value="$PROJECT_DIR$" />
+            <option value="$PROJECT_DIR$/demo" />
+            <option value="$PROJECT_DIR$/lib" />
+            <option value="$PROJECT_DIR$/lib-legacy" />
+          </set>
+        </option>
+      </GradleProjectSettings>
+    </option>
+  </component>
+</project>
\ No newline at end of file
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
new file mode 100644
index 00000000..6d0ee1c2
--- /dev/null
+++ b/.idea/kotlinc.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="KotlinJpsPluginSettings">
+    <option name="version" value="2.0.0" />
+  </component>
+</project>
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 00000000..2189e580
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,4 @@
+<project version="4">
+  <component name="ExternalStorageConfigurationManager" enabled="true" />
+  <component name="ProjectRootManager" version="2" languageLevel="JDK_17" project-jdk-name="11" project-jdk-type="JavaSDK" />
+</project>
\ No newline at end of file
diff --git a/.idea/runConfigurations/deployLocal.xml b/.idea/runConfigurations/deployLocal.xml
new file mode 100644
index 00000000..ca2317c2
--- /dev/null
+++ b/.idea/runConfigurations/deployLocal.xml
@@ -0,0 +1,24 @@
+<component name="ProjectRunConfigurationManager">
+  <configuration default="false" name="deployLocal" type="GradleRunConfiguration" factoryName="Gradle">
+    <ExternalSystemSettings>
+      <option name="executionName" />
+      <option name="externalProjectPath" value="$PROJECT_DIR$" />
+      <option name="externalSystemIdString" value="GRADLE" />
+      <option name="scriptParameters" value="--stacktrace" />
+      <option name="taskDescriptions">
+        <list />
+      </option>
+      <option name="taskNames">
+        <list>
+          <option value="deployLocal" />
+        </list>
+      </option>
+      <option name="vmOptions" />
+    </ExternalSystemSettings>
+    <ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
+    <ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
+    <DebugAllEnabled>false</DebugAllEnabled>
+    <RunAsTest>false</RunAsTest>
+    <method v="2" />
+  </configuration>
+</component>
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 00000000..35eb1ddf
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="VcsDirectoryMappings">
+    <mapping directory="" vcs="Git" />
+  </component>
+</project>
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
index 8dada3ed..119434ee 100644
--- a/LICENSE
+++ b/LICENSE
@@ -186,7 +186,7 @@
       same "printed page" as the copyright notice for easier
       identification within third-party archives.
 
-   Copyright {yyyy} {name of copyright owner}
+   Copyright 2024 DeepMedia Srl
 
    Licensed under the Apache License, Version 2.0 (the "License");
    you may not use this file except in compliance with the License.
diff --git a/README.md b/README.md
index 378e3755..4b553a52 100644
--- a/README.md
+++ b/README.md
@@ -1,93 +1,60 @@
-[![Build Status](https://github.com/natario1/Transcoder/workflows/Build/badge.svg?event=push)](https://github.com/natario1/Transcoder/actions)
-[![Release](https://img.shields.io/github/release/natario1/Transcoder.svg)](https://github.com/natario1/Transcoder/releases)
-[![Issues](https://img.shields.io/github/issues-raw/natario1/Transcoder.svg)](https://github.com/natario1/Transcoder/issues)
+[![Build Status](https://github.com/deepmedia/Transcoder/actions/workflows/build.yml/badge.svg?event=push)](https://github.com/deepmedia/Transcoder/actions)
+[![Release](https://img.shields.io/github/release/deepmedia/Transcoder.svg)](https://github.com/deepmedia/Transcoder/releases)
+[![Issues](https://img.shields.io/github/issues-raw/deepmedia/Transcoder.svg)](https://github.com/deepmedia/Transcoder/issues)
 
-&#10240;  <!-- Hack to add whitespace -->
-
-<p align="center">
-  <img src="docs/static/banner.png" width="100%">
-</p>
-
-*Looking for a powerful camera library to take videos? Take a look at our [CameraView](https://github.com/natario1/CameraView).*
-
-*Need support, consulting, or have any other business-related question? Feel free to <a href="mailto:mat.iavarone@gmail.com">get in touch</a>.*
-
-*Like the project, make profit from it, or simply want to thank back? Please consider [sponsoring me](https://github.com/sponsors/natario1)!*
+![Project logo](assets/logo-256.png)
 
 # Transcoder
 
-Transcodes and compresses video files into the MP4 format, with audio support, using hardware-accelerated 
-Android codecs available on the device. Works on API 18+.
-
-```groovy
-implementation 'com.otaliastudios:transcoder:0.8.0'
-```
+Transcodes and compresses video files into the MP4 format, with audio support, using hardware-accelerated
+Android codecs available on the device. Works on API 21+.
 
 - Fast transcoding to AAC/AVC
 - Hardware accelerated
-- Multithreaded
 - Convenient, fluent API
-- Concatenate multiple video and audio tracks [[docs]](https://natario1.github.io/Transcoder/docs/concatenation)
-- Clip or trim video segments [[docs]](https://natario1.github.io/Transcoder/docs/clipping)
-- Choose output size, with automatic cropping [[docs]](https://natario1.github.io/Transcoder/docs/track-strategies#video-size)
-- Choose output rotation [[docs]](https://natario1.github.io/Transcoder/docs/advanced-options#video-rotation) 
-- Choose output speed [[docs]](https://natario1.github.io/Transcoder/docs/advanced-options#video-speed)
-- Choose output frame rate [[docs]](https://natario1.github.io/Transcoder/docs/track-strategies#other-options)
-- Choose output audio channels [[docs]](https://natario1.github.io/Transcoder/docs/track-strategies#audio-strategies)
-- Choose output audio sample rate [[docs]](https://natario1.github.io/Transcoder/docs/track-strategies#audio-strategies)
-- Override frames timestamp, e.g. to slow down the middle part of the video [[docs]](https://natario1.github.io/Transcoder/docs/advanced-options#time-interpolation) 
-- Error handling [[docs]](https://natario1.github.io/Transcoder/docs/events)
-- Configurable validators to e.g. avoid transcoding if the source is already compressed enough [[docs]](https://natario1.github.io/Transcoder/docs/validators)
-- Configurable video and audio strategies [[docs]](https://natario1.github.io/Transcoder/docs/track-strategies)
-
-&#10240;  <!-- Hack to add whitespace -->
-
-<p align="center">
-  <img src="docs/static/screenshot-1.png" width="250" hspace="25"><img src="docs/static/screenshot-2.png" width="250" hspace="25">
-</p>
-
-&#10240;  <!-- Hack to add whitespace -->
+- Thumbnails support
+- Concatenate multiple video and audio tracks [[docs]](https://opensource.deepmedia.io/transcoder/concatenation)
+- Clip or trim video segments [[docs]](https://opensource.deepmedia.io/transcoder/clipping)
+- Choose output size, with automatic cropping [[docs]](https://opensource.deepmedia.io/transcoder/track-strategies#video-size)
+- Choose output rotation [[docs]](https://opensource.deepmedia.io/transcoder/advanced-options#video-rotation)
+- Choose output speed [[docs]](https://opensource.deepmedia.io/transcoder/advanced-options#video-speed)
+- Choose output frame rate [[docs]](https://opensource.deepmedia.io/transcoder/track-strategies#other-options)
+- Choose output audio channels [[docs]](https://opensource.deepmedia.io/transcoder/track-strategies#audio-strategies)
+- Choose output audio sample rate [[docs]](https://opensource.deepmedia.io/transcoder/track-strategies#audio-strategies)
+- Override frames timestamp, e.g. to slow down the middle part of the video [[docs]](https://opensource.deepmedia.io/transcoder/advanced-options#time-interpolation)
+- Error handling [[docs]](https://opensource.deepmedia.io/transcoder/events)
+- Configurable validators to e.g. avoid transcoding if the source is already compressed enough [[docs]](https://opensource.deepmedia.io/transcoder/validators)
+- Configurable video and audio strategies [[docs]](https://opensource.deepmedia.io/transcoder/track-strategies)
+
+```kotlin
+// build.gradle.kts
+dependencies {
+    implementation("io.deepmedia.community:transcoder-android:0.11.2")
+}
+```
 
 *This project started as a fork of [ypresto/android-transcoder](https://github.com/ypresto/android-transcoder).
-With respect to the source project, which misses most of the functionality listed above, 
-we have also fixed a huge number of bugs and are much less conservative when choosing options 
+With respect to the source project, which misses most of the functionality listed above,
+we have also fixed a huge number of bugs and are much less conservative when choosing options
 that might not be supported. The source project will always throw - for example, accepting only 16:9,
 AVC Baseline Profile videos - we prefer to try and let the codec fail if it wants to*.
 
-## Support
-
-If you like the project, make profit from it, or simply want to thank back, please consider 
-[sponsoring me](https://github.com/sponsors/natario1) through the GitHub Sponsors program! 
-You can have your company logo here, get private support hours or simply help me push this forward. 
-
-Transcoder is trusted and supported by [ShareChat](https://sharechat.com/), a social media app with
-over 100 million downloads. 
+*Transcoder is trusted and supported by [ShareChat](https://sharechat.com/), a social media app with
+over 100 million downloads.*
 
-<p align="center">
-  <img src="docs/static/sharechat.png" width="100%">
-</p>
+Please check out [the official website](https://opensource.deepmedia.io/transcoder) for setup instructions and documentation.
+You may also check the demo app (under `/demo`) for a complete example.
 
-Feel free to <a href="mailto:mat.iavarone@gmail.com">contact me</a> for support, consulting or any 
-other business-related question.
-
-## Setup
-
-Please read the [official website](https://natario1.github.io/Transcoder) for setup instructions and documentation.
-You might also be interested in our [changelog](https://natario1.github.io/Transcoder/about/changelog). 
-Using Transcoder is extremely simple:
-
-```java
+```kotlin
 Transcoder.into(filePath)
-        .addDataSource(context, uri) // or...
-        .addDataSource(filePath) // or...
-        .addDataSource(fileDescriptor) // or...
-        .addDataSource(dataSource)
-        .setListener(new TranscoderListener() {
-             public void onTranscodeProgress(double progress) {}
-             public void onTranscodeCompleted(int successCode) {}
-             public void onTranscodeCanceled() {}
-             public void onTranscodeFailed(@NonNull Throwable exception) {}
-        }).transcode()
+    .addDataSource(context, uri) // or...
+    .addDataSource(filePath) // or...
+    .addDataSource(fileDescriptor) // or...
+    .addDataSource(dataSource)
+    .setListener(object : TranscoderListener {
+         override fun onTranscodeProgress(progress: Double) = Unit
+         override fun onTranscodeCompleted(successCode: Int) = Unit
+         override fun onTranscodeCanceled() = Unit
+         override fun onTranscodeFailed(exception: Throwable) = Unit
+    }).transcode()
 ```
-
-Take a look at the demo app for a complete example.
\ No newline at end of file
diff --git a/assets/logo-256.png b/assets/logo-256.png
new file mode 100644
index 00000000..932c421a
Binary files /dev/null and b/assets/logo-256.png differ
diff --git a/build.gradle b/build.gradle
deleted file mode 100644
index 031fd919..00000000
--- a/build.gradle
+++ /dev/null
@@ -1,30 +0,0 @@
-buildscript {
-
-    repositories {
-        google()
-        jcenter()
-    }
-
-    dependencies {
-        classpath 'com.android.tools.build:gradle:3.4.2'
-        classpath 'com.github.dcendents:android-maven-gradle-plugin:2.1'
-        classpath 'com.jfrog.bintray.gradle:gradle-bintray-plugin:1.8.4'
-    }
-}
-
-allprojects {
-    repositories {
-        google()
-        jcenter()
-    }
-}
-
-ext {
-    compileSdkVersion = 28
-    minSdkVersion = 18
-    targetSdkVersion = 28
-}
-
-task clean(type: Delete) {
-    delete rootProject.buildDir
-}
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 00000000..5816c9a9
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,6 @@
+plugins {
+    kotlin("android") version "2.0.0" apply false
+    id("com.android.library") version "8.2.2" apply false
+    id("com.android.application") version "8.2.2" apply false
+    id("io.deepmedia.tools.deployer") version "0.14.0" apply false
+}
\ No newline at end of file
diff --git a/demo/build.gradle b/demo/build.gradle
deleted file mode 100644
index b9f4ee55..00000000
--- a/demo/build.gradle
+++ /dev/null
@@ -1,30 +0,0 @@
-apply plugin: 'com.android.application'
-
-android {
-    compileSdkVersion rootProject.ext.compileSdkVersion
-
-    defaultConfig {
-        applicationId "com.otaliastudios.transcoder.demo"
-        minSdkVersion rootProject.ext.minSdkVersion
-        targetSdkVersion rootProject.ext.targetSdkVersion
-        versionCode 1
-        versionName "1.0"
-    }
-    buildTypes {
-        release {
-            minifyEnabled false
-            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
-        }
-    }
-
-    compileOptions {
-        sourceCompatibility 1.8
-        targetCompatibility 1.8
-    }
-}
-
-dependencies {
-    api project(':lib')
-    implementation 'com.google.android.material:material:1.1.0-alpha09'
-    implementation 'androidx.appcompat:appcompat:1.1.0-rc01'
-}
diff --git a/demo/build.gradle.kts b/demo/build.gradle.kts
new file mode 100644
index 00000000..93fcf8ac
--- /dev/null
+++ b/demo/build.gradle.kts
@@ -0,0 +1,21 @@
+plugins {
+    id("com.android.application")
+    kotlin("android")
+}
+
+android {
+    namespace = "com.otaliastudios.transcoder.demo"
+    compileSdk = 34
+    defaultConfig {
+        minSdk = 21
+        targetSdk = 34
+        versionCode = 1
+        versionName = "1.0"
+    }
+}
+
+dependencies {
+    implementation(project(":lib"))
+    implementation("com.google.android.material:material:1.12.0")
+    implementation("androidx.appcompat:appcompat:1.7.0")
+}
diff --git a/demo/src/main/AndroidManifest.xml b/demo/src/main/AndroidManifest.xml
index 591d668c..5da9cd95 100644
--- a/demo/src/main/AndroidManifest.xml
+++ b/demo/src/main/AndroidManifest.xml
@@ -1,7 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:tools="http://schemas.android.com/tools"
-    package="com.otaliastudios.transcoder.demo">
+    xmlns:tools="http://schemas.android.com/tools">
 
     <uses-permission
         android:name="android.permission.WRITE_EXTERNAL_STORAGE"
@@ -13,15 +12,26 @@
         android:label="@string/app_name"
         android:theme="@style/AppTheme"
         tools:ignore="AllowBackup,GoogleAppIndexingWarning">
+
         <activity
             android:name=".TranscoderActivity"
-            android:label="@string/app_name">
+            android:label="Transcoder"
+            android:exported="true">
             <intent-filter>
                 <action android:name="android.intent.action.MAIN" />
                 <category android:name="android.intent.category.LAUNCHER" />
             </intent-filter>
         </activity>
 
+        <activity
+            android:name=".ThumbnailerActivity"
+            android:label="Thumbnailer"
+            android:exported="true">
+            <intent-filter>
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+
         <provider
             android:name="androidx.core.content.FileProvider"
             android:authorities="com.otaliastudios.transcoder.demo.fileprovider"
diff --git a/demo/src/main/java/com/otaliastudios/transcoder/demo/ThumbnailerActivity.java b/demo/src/main/java/com/otaliastudios/transcoder/demo/ThumbnailerActivity.java
new file mode 100644
index 00000000..3cdb367e
--- /dev/null
+++ b/demo/src/main/java/com/otaliastudios/transcoder/demo/ThumbnailerActivity.java
@@ -0,0 +1,217 @@
+package com.otaliastudios.transcoder.demo;
+
+import android.annotation.SuppressLint;
+import android.content.ClipData;
+import android.content.Intent;
+import android.graphics.Color;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.SystemClock;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.util.TypedValue;
+import android.view.ViewGroup;
+import android.widget.EditText;
+import android.widget.ImageView;
+import android.widget.ProgressBar;
+import android.widget.RadioGroup;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.otaliastudios.transcoder.ThumbnailerListener;
+import com.otaliastudios.transcoder.ThumbnailerOptions;
+import com.otaliastudios.transcoder.internal.utils.Logger;
+import com.otaliastudios.transcoder.resize.AspectRatioResizer;
+import com.otaliastudios.transcoder.resize.FractionResizer;
+import com.otaliastudios.transcoder.resize.MultiResizer;
+import com.otaliastudios.transcoder.resize.PassThroughResizer;
+import com.otaliastudios.transcoder.source.DataSource;
+import com.otaliastudios.transcoder.source.TrimDataSource;
+import com.otaliastudios.transcoder.source.UriDataSource;
+import com.otaliastudios.transcoder.thumbnail.Thumbnail;
+import com.otaliastudios.transcoder.thumbnail.UniformThumbnailRequest;
+
+import com.otaliastudios.transcoder.demo.R;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Future;
+import java.util.function.Consumer;
+
+import kotlin.collections.ArraysKt;
+import kotlin.jvm.functions.Function1;
+
+import static android.util.TypedValue.COMPLEX_UNIT_DIP;
+
+
+public class ThumbnailerActivity extends AppCompatActivity implements
+        ThumbnailerListener {
+
+    private static final Logger LOG = new Logger("TranscoderActivity");
+
+    private static final int REQUEST_CODE_PICK = 1;
+    private static final int PROGRESS_BAR_MAX = 1000;
+
+    private RadioGroup mVideoResolutionGroup;
+    private RadioGroup mVideoAspectGroup;
+    private RadioGroup mVideoRotationGroup;
+
+    private ProgressBar mProgressView;
+    private TextView mButtonView;
+    private EditText mTrimStartView;
+    private EditText mTrimEndView;
+    private ViewGroup mThumbnailsView;
+
+    private boolean mIsTranscoding;
+    private Future<Void> mTranscodeFuture;
+
+    @SuppressLint("SetTextI18n")
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        Logger.setLogLevel(Logger.LEVEL_VERBOSE);
+        setContentView(R.layout.activity_thumbnailer);
+
+        mThumbnailsView = findViewById(R.id.thumbnails);
+        mButtonView = findViewById(R.id.button);
+        mButtonView.setOnClickListener(v -> {
+            if (!mIsTranscoding) {
+                startActivityForResult(new Intent(Intent.ACTION_GET_CONTENT)
+                        .setType("video/*")
+                        .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true), REQUEST_CODE_PICK);
+            } else {
+                mTranscodeFuture.cancel(true);
+            }
+        });
+
+        mProgressView = findViewById(R.id.progress);
+        mTrimStartView = findViewById(R.id.trim_start);
+        mTrimEndView = findViewById(R.id.trim_end);
+        mVideoResolutionGroup = findViewById(R.id.resolution);
+        mVideoAspectGroup = findViewById(R.id.aspect);
+        mVideoRotationGroup = findViewById(R.id.rotation);
+        setIsTranscoding(false, null);
+    }
+
+    @Override
+    protected void onActivityResult(int requestCode, int resultCode, final Intent data) {
+        super.onActivityResult(requestCode, resultCode, data);
+        if (requestCode == REQUEST_CODE_PICK
+                && resultCode == RESULT_OK
+                && data != null) {
+            if (data.getData() != null) {
+                thumbnails(data.getData());
+            } else if (data.getClipData() != null) {
+                ClipData clipData = data.getClipData();
+                List<Uri> uris = new ArrayList<>();
+                for (int i = 0; i < clipData.getItemCount(); i++) {
+                    uris.add(clipData.getItemAt(i).getUri());
+                }
+                thumbnails(uris.toArray(new Uri[0]));
+            }
+        }
+    }
+
+    private void thumbnails(@NonNull Uri... uris) {
+        LOG.e("Building sources...");
+        List<DataSource> sources = ArraysKt.map(uris, uri -> new UriDataSource(this, uri));
+        long trimStart = 0, trimEnd = 0;
+        try {
+            trimStart = Long.parseLong(mTrimStartView.getText().toString());
+        } catch (NumberFormatException e) {
+            LOG.w("Failed to read trimStart value.", e);
+        }
+        try {
+            trimEnd = Long.parseLong(mTrimEndView.getText().toString());
+        } catch (NumberFormatException e) {
+            LOG.w("Failed to read trimEnd value.", e);
+        }
+        trimStart = Math.max(0, trimStart) * 1000000;
+        trimEnd = Math.max(0, trimEnd) * 1000000;
+        sources.set(0, new TrimDataSource(sources.get(0), trimStart, trimEnd));
+
+        LOG.e("Building options...");
+        ThumbnailerOptions.Builder builder = new ThumbnailerOptions.Builder();
+        builder.addThumbnailRequest(new UniformThumbnailRequest(8));
+        builder.setListener(this);
+        for (DataSource source : sources) {
+            builder.addDataSource(source);
+        }
+
+        float aspectRatio = 0F;
+        int aspectRatioId = mVideoAspectGroup.getCheckedRadioButtonId();
+        if (aspectRatioId == R.id.aspect_169) aspectRatio = 16F / 9F;
+        else if (aspectRatioId == R.id.aspect_43) aspectRatio = 4F / 3F;
+        else if (aspectRatioId == R.id.aspect_square) aspectRatio = 1F;
+        if (aspectRatio > 0) {
+            builder.addResizer(new AspectRatioResizer(aspectRatio));
+        }
+
+        float fraction = 1F;
+        int fractionId = mVideoResolutionGroup.getCheckedRadioButtonId();
+        if (fractionId == R.id.resolution_half) fraction = 0.5F;
+        else if (fractionId == R.id.resolution_third) fraction = 1F / 3F;
+        builder.addResizer(new FractionResizer(fraction));
+
+
+        int rotation = 0;
+        int rotationId = mVideoRotationGroup.getCheckedRadioButtonId();
+        if (rotationId == R.id.rotation_90) rotation = 90;
+        else if (rotationId == R.id.rotation_180) rotation = 180;
+        else if (rotationId == R.id.rotation_270) rotation = 270;
+        builder.setRotation(rotation);
+
+        // Launch the transcoding operation.
+        LOG.e("Starting transcoding!");
+        setIsTranscoding(true, null);
+        mTranscodeFuture = builder.thumbnails();
+    }
+
+    @Override
+    public void onThumbnail(@NotNull Thumbnail thumbnail) {
+        float size = TypedValue.applyDimension(COMPLEX_UNIT_DIP, 96, getResources().getDisplayMetrics());
+        ImageView view = new ImageView(this);
+        view.setLayoutParams(new ViewGroup.LayoutParams((int) size, (int) size));
+        view.setScaleType(ImageView.ScaleType.CENTER_INSIDE);
+        view.setBackgroundColor(getResources().getColor(R.color.colorPrimary));
+        view.setImageBitmap(thumbnail.getBitmap());
+        mThumbnailsView.addView(view);
+        double progress = (float) mThumbnailsView.getChildCount() / 8;
+        mProgressView.setIndeterminate(false);
+        mProgressView.setProgress((int) Math.round(progress * PROGRESS_BAR_MAX));
+    }
+
+    @Override
+    public void onThumbnailsCanceled() {
+        setIsTranscoding(false, "Operation canceled.");
+    }
+
+    @Override
+    public void onThumbnailsFailed(@NotNull Throwable exception) {
+        setIsTranscoding(false, "Error occurred. " + exception.getMessage());
+    }
+
+    @Override
+    public void onThumbnailsCompleted(@NotNull List<Thumbnail> thumbnails) {
+        setIsTranscoding(false, "Extracted " + thumbnails.size() + " thumbnails.");
+    }
+
+    private void setIsTranscoding(boolean isTranscoding, @Nullable String message) {
+        mProgressView.setMax(PROGRESS_BAR_MAX);
+        mProgressView.setProgress(0);
+        if (isTranscoding) {
+            mThumbnailsView.removeAllViews();
+        }
+        if (message != null) {
+            Toast.makeText(this, message, Toast.LENGTH_LONG).show();
+        }
+        mIsTranscoding = isTranscoding;
+        mButtonView.setText(mIsTranscoding ? "Cancel Thumbnails" : "Select Videos & Transcode");
+    }
+}
diff --git a/demo/src/main/java/com/otaliastudios/transcoder/demo/TranscoderActivity.java b/demo/src/main/java/com/otaliastudios/transcoder/demo/TranscoderActivity.java
index f5e70b86..c07ef4bb 100644
--- a/demo/src/main/java/com/otaliastudios/transcoder/demo/TranscoderActivity.java
+++ b/demo/src/main/java/com/otaliastudios/transcoder/demo/TranscoderActivity.java
@@ -6,6 +6,7 @@
 import android.net.Uri;
 import android.os.Bundle;
 import android.os.SystemClock;
+import android.provider.MediaStore;
 import android.text.Editable;
 import android.text.TextWatcher;
 import android.widget.EditText;
@@ -17,11 +18,9 @@
 import com.otaliastudios.transcoder.Transcoder;
 import com.otaliastudios.transcoder.TranscoderListener;
 import com.otaliastudios.transcoder.TranscoderOptions;
-import com.otaliastudios.transcoder.engine.TrackStatus;
-import com.otaliastudios.transcoder.engine.TrackType;
-import com.otaliastudios.transcoder.internal.Logger;
-import com.otaliastudios.transcoder.sink.DataSink;
-import com.otaliastudios.transcoder.sink.DefaultDataSink;
+import com.otaliastudios.transcoder.common.TrackStatus;
+import com.otaliastudios.transcoder.common.TrackType;
+import com.otaliastudios.transcoder.internal.utils.Logger;
 import com.otaliastudios.transcoder.source.DataSource;
 import com.otaliastudios.transcoder.source.TrimDataSource;
 import com.otaliastudios.transcoder.source.UriDataSource;
@@ -29,25 +28,28 @@
 import com.otaliastudios.transcoder.strategy.DefaultVideoStrategy;
 import com.otaliastudios.transcoder.strategy.RemoveTrackStrategy;
 import com.otaliastudios.transcoder.strategy.TrackStrategy;
-import com.otaliastudios.transcoder.strategy.size.AspectRatioResizer;
-import com.otaliastudios.transcoder.strategy.size.FractionResizer;
-import com.otaliastudios.transcoder.strategy.size.PassThroughResizer;
+import com.otaliastudios.transcoder.resize.AspectRatioResizer;
+import com.otaliastudios.transcoder.resize.FractionResizer;
+import com.otaliastudios.transcoder.resize.PassThroughResizer;
 import com.otaliastudios.transcoder.validator.DefaultValidator;
 
 import java.io.File;
 import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.concurrent.Future;
 
 import androidx.annotation.NonNull;
 import androidx.appcompat.app.AppCompatActivity;
 import androidx.core.content.FileProvider;
 
+import kotlin.collections.ArraysKt;
+
 
 public class TranscoderActivity extends AppCompatActivity implements
         TranscoderListener {
 
-    private static final String TAG = "DemoApp";
-    private static final Logger LOG = new Logger(TAG);
+    private static final Logger LOG = new Logger("TranscoderActivity");
 
     private static final String FILE_PROVIDER_AUTHORITY = "com.otaliastudios.transcoder.demo.fileprovider";
     private static final int REQUEST_CODE_PICK = 1;
@@ -72,9 +74,6 @@ public class TranscoderActivity extends AppCompatActivity implements
     private boolean mIsTranscoding;
     private boolean mIsAudioOnly;
     private Future<Void> mTranscodeFuture;
-    private Uri mTranscodeInputUri1;
-    private Uri mTranscodeInputUri2;
-    private Uri mTranscodeInputUri3;
     private Uri mAudioReplacementUri;
     private File mTranscodeOutputFile;
     private long mTranscodeStartTime;
@@ -83,15 +82,10 @@ public class TranscoderActivity extends AppCompatActivity implements
     private long mTrimStartUs = 0;
     private long mTrimEndUs = 0;
 
-    private RadioGroup.OnCheckedChangeListener mRadioGroupListener
-            = new RadioGroup.OnCheckedChangeListener() {
-        @Override
-        public void onCheckedChanged(RadioGroup group, int checkedId) {
-            syncParameters();
-        }
-    };
+    private final RadioGroup.OnCheckedChangeListener mRadioGroupListener
+            = (group, checkedId) -> syncParameters();
 
-    private TextWatcher mTextListener = new TextWatcher() {
+    private final TextWatcher mTextListener = new TextWatcher() {
         @Override
         public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
 
@@ -114,9 +108,10 @@ protected void onCreate(Bundle savedInstanceState) {
         mButtonView = findViewById(R.id.button);
         mButtonView.setOnClickListener(v -> {
             if (!mIsTranscoding) {
-                startActivityForResult(new Intent(Intent.ACTION_GET_CONTENT)
-                        .setType("video/*")
-                        .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true), REQUEST_CODE_PICK);
+                Intent intent = new Intent(Intent.ACTION_PICK, MediaStore.Video.Media.EXTERNAL_CONTENT_URI)
+                        .setDataAndType(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, "video/*")
+                        .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
+                startActivityForResult(intent, REQUEST_CODE_PICK);
             } else {
                 mTranscodeFuture.cancel(true);
             }
@@ -164,24 +159,21 @@ protected void onCreate(Bundle savedInstanceState) {
     }
 
     private void syncParameters() {
-        int channels;
-        switch (mAudioChannelsGroup.getCheckedRadioButtonId()) {
-            case R.id.channels_mono: channels = 1; break;
-            case R.id.channels_stereo: channels = 2; break;
-            default: channels = DefaultAudioStrategy.CHANNELS_AS_INPUT;
-        }
-        int sampleRate;
-        switch (mAudioSampleRateGroup.getCheckedRadioButtonId()) {
-            case R.id.sampleRate_32: sampleRate = 32000; break;
-            case R.id.sampleRate_48: sampleRate = 48000; break;
-            default: sampleRate = DefaultAudioStrategy.SAMPLE_RATE_AS_INPUT;
-        }
-        boolean removeAudio;
-        switch (mAudioReplaceGroup.getCheckedRadioButtonId()) {
-            case R.id.replace_remove: removeAudio = true; break;
-            case R.id.replace_yes: removeAudio = false; break;
-            default: removeAudio = false;
-        }
+        int channels = DefaultAudioStrategy.CHANNELS_AS_INPUT;
+        int channelsId = mAudioChannelsGroup.getCheckedRadioButtonId();
+        if (channelsId == R.id.channels_mono) channels = 1;
+        else if (channelsId == R.id.channels_stereo) channels = 2;
+
+        int sampleRate = DefaultAudioStrategy.SAMPLE_RATE_AS_INPUT;
+        int sampleRateId = mAudioSampleRateGroup.getCheckedRadioButtonId();
+        if (sampleRateId == R.id.sampleRate_32) sampleRate = 32000;
+        else if (sampleRateId == R.id.sampleRate_48) sampleRate = 48000;
+
+        boolean removeAudio = false;
+        int removeAudioId = mAudioReplaceGroup.getCheckedRadioButtonId();
+        if (removeAudioId == R.id.replace_remove) removeAudio = true;
+        else if (removeAudioId == R.id.replace_yes) removeAudio = false;
+
         if (removeAudio) {
             mTranscodeAudioStrategy = new RemoveTrackStrategy();
         } else {
@@ -191,43 +183,41 @@ private void syncParameters() {
                     .build();
         }
 
-        int frames;
-        switch (mVideoFramesGroup.getCheckedRadioButtonId()) {
-            case R.id.frames_24: frames = 24; break;
-            case R.id.frames_30: frames = 30; break;
-            case R.id.frames_60: frames = 60; break;
-            default: frames = DefaultVideoStrategy.DEFAULT_FRAME_RATE;
-        }
-        float fraction;
-        switch (mVideoResolutionGroup.getCheckedRadioButtonId()) {
-            case R.id.resolution_half: fraction = 0.5F; break;
-            case R.id.resolution_third: fraction = 1F / 3F; break;
-            default: fraction = 1F;
-        }
-        float aspectRatio;
-        switch (mVideoAspectGroup.getCheckedRadioButtonId()) {
-            case R.id.aspect_169: aspectRatio = 16F / 9F; break;
-            case R.id.aspect_43: aspectRatio = 4F / 3F; break;
-            case R.id.aspect_square: aspectRatio = 1F; break;
-            default: aspectRatio = 0F;
-        }
+        int frames = DefaultVideoStrategy.DEFAULT_FRAME_RATE;
+        int framesId = mVideoFramesGroup.getCheckedRadioButtonId();
+        if (framesId == R.id.frames_24) frames = 24;
+        else if (framesId == R.id.frames_30) frames = 30;
+        else if (framesId == R.id.frames_60) frames = 60;
+
+        float fraction = 1F;
+        int fractionId = mVideoResolutionGroup.getCheckedRadioButtonId();
+        if (fractionId == R.id.resolution_half) fraction = 0.5F;
+        else if (fractionId == R.id.resolution_third) fraction = 1F / 3F;
+
+        float aspectRatio = 0F;
+        int aspectRatioId = mVideoAspectGroup.getCheckedRadioButtonId();
+        if (aspectRatioId == R.id.aspect_169) aspectRatio = 16F / 9F;
+        else if (aspectRatioId == R.id.aspect_43) aspectRatio = 4F / 3F;
+        else if (aspectRatioId == R.id.aspect_square) aspectRatio = 1F;
+
         mTranscodeVideoStrategy = new DefaultVideoStrategy.Builder()
                 .addResizer(aspectRatio > 0 ? new AspectRatioResizer(aspectRatio) : new PassThroughResizer())
                 .addResizer(new FractionResizer(fraction))
                 .frameRate(frames)
+                // .keyFrameInterval(4F)
                 .build();
 
         try {
             mTrimStartUs = Long.valueOf(mTrimStartView.getText().toString()) * 1000000;
         } catch (NumberFormatException e) {
             mTrimStartUs = 0;
-            LOG.w("Failed to read trimStart value.");
+            LOG.w("Failed to read trimStart value.", e);
         }
         try {
             mTrimEndUs = Long.valueOf(mTrimEndView.getText().toString()) * 1000000;
         } catch (NumberFormatException e) {
             mTrimEndUs = 0;
-            LOG.w("Failed to read trimEnd value.");
+            LOG.w("Failed to read trimEnd value.", e);
         }
         if (mTrimStartUs < 0) mTrimStartUs = 0;
         if (mTrimEndUs < 0) mTrimEndUs = 0;
@@ -244,17 +234,15 @@ protected void onActivityResult(int requestCode, int resultCode, final Intent da
         if (requestCode == REQUEST_CODE_PICK
                 && resultCode == RESULT_OK
                 && data != null) {
-            if (data.getData() != null) {
-                mTranscodeInputUri1 = data.getData();
-                mTranscodeInputUri2 = null;
-                mTranscodeInputUri3 = null;
-                transcode();
-            } else if (data.getClipData() != null) {
+            if (data.getClipData() != null) {
                 ClipData clipData = data.getClipData();
-                mTranscodeInputUri1 = clipData.getItemAt(0).getUri();
-                mTranscodeInputUri2 = clipData.getItemCount() >= 2 ? clipData.getItemAt(1).getUri() : null;
-                mTranscodeInputUri3 = clipData.getItemCount() >= 3 ? clipData.getItemAt(2).getUri() : null;
-                transcode();
+                List<Uri> uris = new ArrayList<>();
+                for (int i = 0; i < clipData.getItemCount(); i++) {
+                    uris.add(clipData.getItemAt(i).getUri());
+                }
+                transcode(uris.toArray(new Uri[0]));
+            } else if (data.getData() != null) {
+                transcode(data.getData());
             }
         }
         if (requestCode == REQUEST_CODE_PICK_AUDIO
@@ -267,7 +255,7 @@ protected void onActivityResult(int requestCode, int resultCode, final Intent da
         }
     }
 
-    private void transcode() {
+    private void transcode(@NonNull Uri... uris) {
         // Create a temporary file for output.
         try {
             File outputDir = new File(getExternalFilesDir(null), "outputs");
@@ -281,42 +269,35 @@ private void transcode() {
             return;
         }
 
-        int rotation;
-        switch (mVideoRotationGroup.getCheckedRadioButtonId()) {
-            case R.id.rotation_90: rotation = 90; break;
-            case R.id.rotation_180: rotation = 180; break;
-            case R.id.rotation_270: rotation = 270; break;
-            default: rotation = 0;
-        }
+        int rotation = 0;
+        int rotationId = mVideoRotationGroup.getCheckedRadioButtonId();
+        if (rotationId == R.id.rotation_90) rotation = 90;
+        else if (rotationId == R.id.rotation_180) rotation = 180;
+        else if (rotationId == R.id.rotation_270) rotation = 270;
 
-        float speed;
-        switch (mSpeedGroup.getCheckedRadioButtonId()) {
-            case R.id.speed_05x: speed = 0.5F; break;
-            case R.id.speed_2x: speed = 2F; break;
-            default: speed = 1F;
-        }
+        float speed = 1F;
+        int speedId = mSpeedGroup.getCheckedRadioButtonId();
+        if (speedId == R.id.speed_05x) speed = 0.5F;
+        else if (speedId == R.id.speed_2x) speed = 2F;
 
         // Launch the transcoding operation.
         mTranscodeStartTime = SystemClock.uptimeMillis();
         setIsTranscoding(true);
-        DataSink sink = new DefaultDataSink(mTranscodeOutputFile.getAbsolutePath());
-        TranscoderOptions.Builder builder = Transcoder.into(sink);
+        LOG.e("Building transcoding options...");
+        TranscoderOptions.Builder builder = Transcoder.into(mTranscodeOutputFile.getAbsolutePath());
+        List<DataSource> sources = ArraysKt.map(uris, uri -> new UriDataSource(this, uri));
+        sources.set(0, new TrimDataSource(sources.get(0), mTrimStartUs, mTrimEndUs));
         if (mAudioReplacementUri == null) {
-            if (mTranscodeInputUri1 != null) {
-                DataSource source = new UriDataSource(this, mTranscodeInputUri1);
-                builder.addDataSource(new TrimDataSource(source, mTrimStartUs, mTrimEndUs));
+            for (DataSource source : sources) {
+                builder.addDataSource(source);
             }
-            if (mTranscodeInputUri2 != null) builder.addDataSource(this, mTranscodeInputUri2);
-            if (mTranscodeInputUri3 != null) builder.addDataSource(this, mTranscodeInputUri3);
         } else {
-            if (mTranscodeInputUri1 != null) {
-                DataSource source = new UriDataSource(this, mTranscodeInputUri1);
-                builder.addDataSource(TrackType.VIDEO, new TrimDataSource(source, mTrimStartUs, mTrimEndUs));
+            for (DataSource source : sources) {
+                builder.addDataSource(TrackType.VIDEO, source);
             }
-            if (mTranscodeInputUri2 != null) builder.addDataSource(TrackType.VIDEO, this, mTranscodeInputUri2);
-            if (mTranscodeInputUri3 != null) builder.addDataSource(TrackType.VIDEO, this, mTranscodeInputUri3);
             builder.addDataSource(TrackType.AUDIO, this, mAudioReplacementUri);
         }
+        LOG.e("Starting transcoding!");
         mTranscodeFuture = builder.setListener(this)
                 .setAudioTrackStrategy(mTranscodeAudioStrategy)
                 .setVideoTrackStrategy(mTranscodeVideoStrategy)
diff --git a/demo/src/main/res/layout/activity_thumbnailer.xml b/demo/src/main/res/layout/activity_thumbnailer.xml
new file mode 100644
index 00000000..1c20e3ca
--- /dev/null
+++ b/demo/src/main/res/layout/activity_thumbnailer.xml
@@ -0,0 +1,229 @@
+<androidx.coordinatorlayout.widget.CoordinatorLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:ignore="HardcodedText"
+    tools:context=".TranscoderActivity">
+
+    <androidx.core.widget.NestedScrollView
+        android:padding="16dp"
+        android:clipToPadding="false"
+        android:fillViewport="true"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent">
+        <LinearLayout
+            android:orientation="vertical"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content">
+
+            <!-- SIZE COMPRESSION -->
+            <TextView
+                android:padding="16dp"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:text="Size" />
+            <RadioGroup
+                android:id="@+id/resolution"
+                android:checkedButton="@id/resolution_input"
+                android:orientation="horizontal"
+                android:gravity="center"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content">
+                <com.google.android.material.radiobutton.MaterialRadioButton
+                    android:id="@+id/resolution_input"
+                    android:text="As input"
+                    android:paddingLeft="8dp"
+                    android:paddingRight="8dp"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content" />
+                <com.google.android.material.radiobutton.MaterialRadioButton
+                    android:id="@+id/resolution_half"
+                    android:text="0.5x"
+                    android:paddingLeft="8dp"
+                    android:paddingRight="8dp"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content" />
+                <com.google.android.material.radiobutton.MaterialRadioButton
+                    android:id="@+id/resolution_third"
+                    android:text="0.33x"
+                    android:paddingLeft="8dp"
+                    android:paddingRight="8dp"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content" />
+            </RadioGroup>
+
+            <!-- ASPECT RATIO -->
+            <TextView
+                android:padding="16dp"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:text="Aspect ratio" />
+            <RadioGroup
+                android:id="@+id/aspect"
+                android:checkedButton="@id/aspect_input"
+                android:orientation="horizontal"
+                android:gravity="center"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content">
+                <com.google.android.material.radiobutton.MaterialRadioButton
+                    android:id="@+id/aspect_input"
+                    android:text="As input"
+                    android:paddingLeft="8dp"
+                    android:paddingRight="8dp"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content" />
+                <com.google.android.material.radiobutton.MaterialRadioButton
+                    android:id="@+id/aspect_169"
+                    android:text="16:9"
+                    android:paddingLeft="8dp"
+                    android:paddingRight="8dp"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content" />
+                <com.google.android.material.radiobutton.MaterialRadioButton
+                    android:id="@+id/aspect_43"
+                    android:text="4:3"
+                    android:paddingLeft="8dp"
+                    android:paddingRight="8dp"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content" />
+                <com.google.android.material.radiobutton.MaterialRadioButton
+                    android:id="@+id/aspect_square"
+                    android:text="Square"
+                    android:paddingLeft="8dp"
+                    android:paddingRight="8dp"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content" />
+            </RadioGroup>
+
+            <!-- VIDEO ROTATION -->
+            <TextView
+                android:padding="16dp"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:text="Rotation" />
+            <RadioGroup
+                android:id="@+id/rotation"
+                android:checkedButton="@id/rotation_input"
+                android:orientation="horizontal"
+                android:gravity="center"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content">
+                <com.google.android.material.radiobutton.MaterialRadioButton
+                    android:id="@+id/rotation_input"
+                    android:text="As input"
+                    android:paddingLeft="8dp"
+                    android:paddingRight="8dp"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content" />
+                <com.google.android.material.radiobutton.MaterialRadioButton
+                    android:id="@+id/rotation_90"
+                    android:text="90°"
+                    android:paddingLeft="8dp"
+                    android:paddingRight="8dp"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content" />
+                <com.google.android.material.radiobutton.MaterialRadioButton
+                    android:id="@+id/rotation_180"
+                    android:text="180°"
+                    android:paddingLeft="8dp"
+                    android:paddingRight="8dp"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content" />
+                <com.google.android.material.radiobutton.MaterialRadioButton
+                    android:id="@+id/rotation_270"
+                    android:text="270°"
+                    android:paddingLeft="8dp"
+                    android:paddingRight="8dp"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content" />
+            </RadioGroup>
+
+            <!-- TRIM -->
+            <TextView
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:padding="16dp"
+                android:text="Trim" />
+
+            <LinearLayout
+                android:layout_width="match_parent"
+                android:layout_height="?attr/listPreferredItemHeightSmall"
+                android:gravity="center"
+                android:orientation="horizontal">
+
+                <TextView
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:layout_marginEnd="8dp"
+                    android:text="Start (s):" />
+
+                <com.google.android.material.textfield.TextInputEditText
+                    android:id="@+id/trim_start"
+                    android:layout_width="wrap_content"
+                    android:layout_height="match_parent"
+                    android:inputType="number"
+                    android:maxLines="1"
+                    android:gravity="center_horizontal"
+                    android:minWidth="48dp"
+                    android:singleLine="true"
+                    android:text="0" />
+
+                <TextView
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:layout_marginStart="32dp"
+                    android:layout_marginEnd="8dp"
+                    android:text="End (s):" />
+
+                <com.google.android.material.textfield.TextInputEditText
+                    android:id="@+id/trim_end"
+                    android:layout_width="wrap_content"
+                    android:layout_height="match_parent"
+                    android:inputType="number"
+                    android:maxLines="1"
+                    android:gravity="center_horizontal"
+                    android:minWidth="48dp"
+                    android:singleLine="true"
+                    android:text="0" />
+            </LinearLayout>
+
+            <!-- INFO TEXT -->
+            <TextView
+                android:padding="16dp"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:text="Note: our API offers many more options than these!\n\nNote: When clicking below, you can select more than one video. If you do, videos will be concatenated together." />
+
+            <!-- SPACE AND BUTTONS -->
+            <Space
+                android:layout_width="match_parent"
+                android:layout_weight="1"
+                android:layout_height="0dp" />
+            <com.google.android.material.button.MaterialButton
+                android:id="@+id/button"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_gravity="center_horizontal" />
+            <ProgressBar
+                android:id="@+id/progress"
+                style="@style/Widget.AppCompat.ProgressBar.Horizontal"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:progress="0" />
+
+            <HorizontalScrollView
+                android:layout_marginTop="16dp"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content">
+                <LinearLayout
+                    android:id="@+id/thumbnails"
+                    android:orientation="horizontal"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"/>
+            </HorizontalScrollView>
+        </LinearLayout>
+    </androidx.core.widget.NestedScrollView>
+</androidx.coordinatorlayout.widget.CoordinatorLayout>
+
diff --git a/demo/src/main/res/values/strings.xml b/demo/src/main/res/values/strings.xml
index c5565be5..a98df502 100644
--- a/demo/src/main/res/values/strings.xml
+++ b/demo/src/main/res/values/strings.xml
@@ -1,4 +1,4 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
-    <string name="app_name">Transcoder Demo</string>
+    <string name="app_name">Transcoder</string>
 </resources>
diff --git a/docs/.gitignore b/docs/.gitignore
deleted file mode 100644
index d8a620e8..00000000
--- a/docs/.gitignore
+++ /dev/null
@@ -1,5 +0,0 @@
-_site
-_pages
-*.sw?
-.sass-cache
-.jekyll-metadata
diff --git a/docs/Gemfile b/docs/Gemfile
deleted file mode 100644
index 37f5eaa4..00000000
--- a/docs/Gemfile
+++ /dev/null
@@ -1,2 +0,0 @@
-source 'https://rubygems.org'
-gem 'github-pages', group: :jekyll_plugins
diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock
deleted file mode 100644
index fc5feb87..00000000
--- a/docs/Gemfile.lock
+++ /dev/null
@@ -1,248 +0,0 @@
-GEM
-  remote: https://rubygems.org/
-  specs:
-    activesupport (4.2.10)
-      i18n (~> 0.7)
-      minitest (~> 5.1)
-      thread_safe (~> 0.3, >= 0.3.4)
-      tzinfo (~> 1.1)
-    addressable (2.5.2)
-      public_suffix (>= 2.0.2, < 4.0)
-    coffee-script (2.4.1)
-      coffee-script-source
-      execjs
-    coffee-script-source (1.11.1)
-    colorator (1.1.0)
-    commonmarker (0.17.13)
-      ruby-enum (~> 0.5)
-    concurrent-ruby (1.1.4)
-    dnsruby (1.61.2)
-      addressable (~> 2.5)
-    em-websocket (0.5.1)
-      eventmachine (>= 0.12.9)
-      http_parser.rb (~> 0.6.0)
-    ethon (0.11.0)
-      ffi (>= 1.3.0)
-    eventmachine (1.2.7)
-    execjs (2.7.0)
-    faraday (0.15.4)
-      multipart-post (>= 1.2, < 3)
-    ffi (1.9.25)
-    forwardable-extended (2.6.0)
-    gemoji (3.0.0)
-    github-pages (193)
-      activesupport (= 4.2.10)
-      github-pages-health-check (= 1.8.1)
-      jekyll (= 3.7.4)
-      jekyll-avatar (= 0.6.0)
-      jekyll-coffeescript (= 1.1.1)
-      jekyll-commonmark-ghpages (= 0.1.5)
-      jekyll-default-layout (= 0.1.4)
-      jekyll-feed (= 0.11.0)
-      jekyll-gist (= 1.5.0)
-      jekyll-github-metadata (= 2.9.4)
-      jekyll-mentions (= 1.4.1)
-      jekyll-optional-front-matter (= 0.3.0)
-      jekyll-paginate (= 1.1.0)
-      jekyll-readme-index (= 0.2.0)
-      jekyll-redirect-from (= 0.14.0)
-      jekyll-relative-links (= 0.5.3)
-      jekyll-remote-theme (= 0.3.1)
-      jekyll-sass-converter (= 1.5.2)
-      jekyll-seo-tag (= 2.5.0)
-      jekyll-sitemap (= 1.2.0)
-      jekyll-swiss (= 0.4.0)
-      jekyll-theme-architect (= 0.1.1)
-      jekyll-theme-cayman (= 0.1.1)
-      jekyll-theme-dinky (= 0.1.1)
-      jekyll-theme-hacker (= 0.1.1)
-      jekyll-theme-leap-day (= 0.1.1)
-      jekyll-theme-merlot (= 0.1.1)
-      jekyll-theme-midnight (= 0.1.1)
-      jekyll-theme-minimal (= 0.1.1)
-      jekyll-theme-modernist (= 0.1.1)
-      jekyll-theme-primer (= 0.5.3)
-      jekyll-theme-slate (= 0.1.1)
-      jekyll-theme-tactile (= 0.1.1)
-      jekyll-theme-time-machine (= 0.1.1)
-      jekyll-titles-from-headings (= 0.5.1)
-      jemoji (= 0.10.1)
-      kramdown (= 1.17.0)
-      liquid (= 4.0.0)
-      listen (= 3.1.5)
-      mercenary (~> 0.3)
-      minima (= 2.5.0)
-      nokogiri (>= 1.8.2, < 2.0)
-      rouge (= 2.2.1)
-      terminal-table (~> 1.4)
-    github-pages-health-check (1.8.1)
-      addressable (~> 2.3)
-      dnsruby (~> 1.60)
-      octokit (~> 4.0)
-      public_suffix (~> 2.0)
-      typhoeus (~> 1.3)
-    html-pipeline (2.9.1)
-      activesupport (>= 2)
-      nokogiri (>= 1.4)
-    http_parser.rb (0.6.0)
-    i18n (0.9.5)
-      concurrent-ruby (~> 1.0)
-    jekyll (3.7.4)
-      addressable (~> 2.4)
-      colorator (~> 1.0)
-      em-websocket (~> 0.5)
-      i18n (~> 0.7)
-      jekyll-sass-converter (~> 1.0)
-      jekyll-watch (~> 2.0)
-      kramdown (~> 1.14)
-      liquid (~> 4.0)
-      mercenary (~> 0.3.3)
-      pathutil (~> 0.9)
-      rouge (>= 1.7, < 4)
-      safe_yaml (~> 1.0)
-    jekyll-avatar (0.6.0)
-      jekyll (~> 3.0)
-    jekyll-coffeescript (1.1.1)
-      coffee-script (~> 2.2)
-      coffee-script-source (~> 1.11.1)
-    jekyll-commonmark (1.2.0)
-      commonmarker (~> 0.14)
-      jekyll (>= 3.0, < 4.0)
-    jekyll-commonmark-ghpages (0.1.5)
-      commonmarker (~> 0.17.6)
-      jekyll-commonmark (~> 1)
-      rouge (~> 2)
-    jekyll-default-layout (0.1.4)
-      jekyll (~> 3.0)
-    jekyll-feed (0.11.0)
-      jekyll (~> 3.3)
-    jekyll-gist (1.5.0)
-      octokit (~> 4.2)
-    jekyll-github-metadata (2.9.4)
-      jekyll (~> 3.1)
-      octokit (~> 4.0, != 4.4.0)
-    jekyll-mentions (1.4.1)
-      html-pipeline (~> 2.3)
-      jekyll (~> 3.0)
-    jekyll-optional-front-matter (0.3.0)
-      jekyll (~> 3.0)
-    jekyll-paginate (1.1.0)
-    jekyll-readme-index (0.2.0)
-      jekyll (~> 3.0)
-    jekyll-redirect-from (0.14.0)
-      jekyll (~> 3.3)
-    jekyll-relative-links (0.5.3)
-      jekyll (~> 3.3)
-    jekyll-remote-theme (0.3.1)
-      jekyll (~> 3.5)
-      rubyzip (>= 1.2.1, < 3.0)
-    jekyll-sass-converter (1.5.2)
-      sass (~> 3.4)
-    jekyll-seo-tag (2.5.0)
-      jekyll (~> 3.3)
-    jekyll-sitemap (1.2.0)
-      jekyll (~> 3.3)
-    jekyll-swiss (0.4.0)
-    jekyll-theme-architect (0.1.1)
-      jekyll (~> 3.5)
-      jekyll-seo-tag (~> 2.0)
-    jekyll-theme-cayman (0.1.1)
-      jekyll (~> 3.5)
-      jekyll-seo-tag (~> 2.0)
-    jekyll-theme-dinky (0.1.1)
-      jekyll (~> 3.5)
-      jekyll-seo-tag (~> 2.0)
-    jekyll-theme-hacker (0.1.1)
-      jekyll (~> 3.5)
-      jekyll-seo-tag (~> 2.0)
-    jekyll-theme-leap-day (0.1.1)
-      jekyll (~> 3.5)
-      jekyll-seo-tag (~> 2.0)
-    jekyll-theme-merlot (0.1.1)
-      jekyll (~> 3.5)
-      jekyll-seo-tag (~> 2.0)
-    jekyll-theme-midnight (0.1.1)
-      jekyll (~> 3.5)
-      jekyll-seo-tag (~> 2.0)
-    jekyll-theme-minimal (0.1.1)
-      jekyll (~> 3.5)
-      jekyll-seo-tag (~> 2.0)
-    jekyll-theme-modernist (0.1.1)
-      jekyll (~> 3.5)
-      jekyll-seo-tag (~> 2.0)
-    jekyll-theme-primer (0.5.3)
-      jekyll (~> 3.5)
-      jekyll-github-metadata (~> 2.9)
-      jekyll-seo-tag (~> 2.0)
-    jekyll-theme-slate (0.1.1)
-      jekyll (~> 3.5)
-      jekyll-seo-tag (~> 2.0)
-    jekyll-theme-tactile (0.1.1)
-      jekyll (~> 3.5)
-      jekyll-seo-tag (~> 2.0)
-    jekyll-theme-time-machine (0.1.1)
-      jekyll (~> 3.5)
-      jekyll-seo-tag (~> 2.0)
-    jekyll-titles-from-headings (0.5.1)
-      jekyll (~> 3.3)
-    jekyll-watch (2.1.2)
-      listen (~> 3.0)
-    jemoji (0.10.1)
-      gemoji (~> 3.0)
-      html-pipeline (~> 2.2)
-      jekyll (~> 3.0)
-    kramdown (1.17.0)
-    liquid (4.0.0)
-    listen (3.1.5)
-      rb-fsevent (~> 0.9, >= 0.9.4)
-      rb-inotify (~> 0.9, >= 0.9.7)
-      ruby_dep (~> 1.2)
-    mercenary (0.3.6)
-    mini_portile2 (2.4.0)
-    minima (2.5.0)
-      jekyll (~> 3.5)
-      jekyll-feed (~> 0.9)
-      jekyll-seo-tag (~> 2.1)
-    minitest (5.11.3)
-    multipart-post (2.0.0)
-    nokogiri (1.10.4)
-      mini_portile2 (~> 2.4.0)
-    octokit (4.13.0)
-      sawyer (~> 0.8.0, >= 0.5.3)
-    pathutil (0.16.2)
-      forwardable-extended (~> 2.6)
-    public_suffix (2.0.5)
-    rb-fsevent (0.10.3)
-    rb-inotify (0.10.0)
-      ffi (~> 1.0)
-    rouge (2.2.1)
-    ruby-enum (0.7.2)
-      i18n
-    ruby_dep (1.5.0)
-    rubyzip (2.0.0)
-    safe_yaml (1.0.4)
-    sass (3.7.2)
-      sass-listen (~> 4.0.0)
-    sass-listen (4.0.0)
-      rb-fsevent (~> 0.9, >= 0.9.4)
-      rb-inotify (~> 0.9, >= 0.9.7)
-    sawyer (0.8.1)
-      addressable (>= 2.3.5, < 2.6)
-      faraday (~> 0.8, < 1.0)
-    terminal-table (1.8.0)
-      unicode-display_width (~> 1.1, >= 1.1.1)
-    thread_safe (0.3.6)
-    typhoeus (1.3.1)
-      ethon (>= 0.9.0)
-    tzinfo (1.2.5)
-      thread_safe (~> 0.1)
-    unicode-display_width (1.4.0)
-
-PLATFORMS
-  ruby
-
-DEPENDENCIES
-  github-pages
-
-BUNDLED WITH
-   1.17.2
diff --git a/docs/README.md b/docs/README.md
index 9312dc35..995fe576 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1 +1 @@
-Read the docs at https://natario1.github.io/Transcoder .
+Read the docs at https://opensource.deepmedia.io/transcoder.
diff --git a/docs/_about/changelog.md b/docs/_about/changelog.md
deleted file mode 100644
index a718ec51..00000000
--- a/docs/_about/changelog.md
+++ /dev/null
@@ -1,108 +0,0 @@
----
-layout: page
-title: "Changelog"
-order: 3
----
-
-New versions are released through GitHub, so the reference page is the [GitHub Releases](https://github.com/natario1/Transcoder/releases) page.
-
-> Starting from 0.7.0, you can now [support development](https://github.com/sponsors/natario1) through the GitHub Sponsors program. 
-Companies can share a tiny part of their revenue and get private support hours in return. Thanks!
-
-### v0.8.0
-
-- New: `TrimDataSource` to trim segments. Use it to wrap your original source. Thanks to [@mudar][mudar] ([#50][50])
-- New: `ClipDataSource`, just likes `TrimDataSource` but selects trim values with respect to video start ([#54][54])
-
-> Transcoder will trim video segments only at the closest video sync frame. If your video has few sync
-frames, the trim timestamp might be different than what was selected.
-
-<https://github.com/natario1/Transcoder/compare/v0.7.4...v0.8.0>
-
-##### v0.7.4
-
-- Fix: fixed Xamarin incompatibility, thanks to [@aweck][aweck] ([#41][41])
-- Fix: fixed small bugs with specific API versions / media files ([#47][47])
-- Fix: fixed issues with specific media files, ensure consistent onProgress callback ([#48][48])
-
-<https://github.com/natario1/Transcoder/compare/v0.7.3...v0.7.4>
-
-##### v0.7.3
-
-- Fix: fixed bug with files that do not have an audio track, thanks to [@pawegio][pawegio] ([#31][31])
-- Fix: fixed possible issues with FilePathDataSource ([#32][32])
-
-<https://github.com/natario1/Transcoder/compare/v0.7.2...v0.7.3>
-
-##### v0.7.2
-
-- Improvement: better input format detection. Fixes bugs with certain files ([#29][29])
-- Improvement: added `DefaultAudioStrategy.Builder.bitRate()` option ([#29][29])
-
-<https://github.com/natario1/Transcoder/compare/v0.7.1...v0.7.2>
-
-##### v0.7.1
-
-- Improvement: update the underlying OpenGL library ([#20][20])
-
-<https://github.com/natario1/Transcoder/compare/v0.7.0...v0.7.1>
-
-### v0.7.0
-
-- New: video concatenation to stitch together multiple media ([#14][14])
-- New: select a specific track type (`VIDEO` or `AUDIO`) for sources ([#14][14])
-- New: audio resampling through `DefaultAudioStrategy` ([#16][16])
-- New: custom resampling through `TranscoderOptions.setAudioResampler()` ([#16][16])
-- Breaking change: `TranscoderOptions.setDataSource()` renamed to `addDataSource()` ([#14][14])
-- Breaking change: `TranscoderOptions.setRotation()` renamed to `setVideoRotation()` ([#14][14])
-- Breaking change: `DefaultVideoStrategy.iFrameInterval()` renamed to `keyFrameInterval()` ([#14][14])
-- Breaking change: `DefaultAudioStrategy` now uses a builder - removed old constructor ([#16][16])
-- Improvement: rotate videos through OpenGL instead of using metadata ([#14][14])
-- Improvement: when concatenating multiple sources, automatically clip the longer track (audio or video) ([#17][17])
-- Improvement: various bug fixed ([#18][18])
-
-<https://github.com/natario1/Transcoder/compare/v0.6.0...v0.7.0>
-
-### v0.6.0
-
-- New: ability to change video/audio speed and change each frame timestamp ([#10][10])
-- New: ability to set the video output rotation ([#8][8])
-- Improvement: new frame dropping algorithm, thanks to [@Saqrag][Saqrag] ([#9][9])
-- Improvement: avoid format validation on tracks coming from PassThroughTrackTranscoder, thanks to [@Saqrag][Saqrag] ([#11][11])
-
-<https://github.com/natario1/Transcoder/compare/v0.5.0...v0.6.0>
-
-### v0.5.0
-
-- New: video cropping to any dimension. Encoder will crop the exceeding size. ([#6][6])
-- New: `AspectRatioResizer` to crop to a given aspect ratio. ([#6][6])
-- Breaking change: `MediaTranscoder` renamed to `Transcoder`. ([#6][6])
-- Breaking change: `MediaTranscoderOptions` renamed to `TranscoderOptions`. ([#6][6])
-- Breaking change: `MediaTranscoder.Listener` renamed to `TranscoderListener`. ([#6][6])
-- Improvement: use [EglCore](https://github.com/natario1/EglCore) to replace GL logic. ([#5][5])
-- Improvement: bug fixes and a new demo app to test transcoding options easily ([#4][4])
-
-[Saqrag]: https://github.com/Saqrag
-[pawegio]: https://github.com/pawegio
-[aweck]: https://github.com/aweck
-[mudar]: https://github.com/mudar
-
-[4]: https://github.com/natario1/Transcoder/pull/4
-[5]: https://github.com/natario1/Transcoder/pull/5
-[6]: https://github.com/natario1/Transcoder/pull/6
-[8]: https://github.com/natario1/Transcoder/pull/8
-[9]: https://github.com/natario1/Transcoder/pull/9
-[10]: https://github.com/natario1/Transcoder/pull/10
-[14]: https://github.com/natario1/Transcoder/pull/14
-[16]: https://github.com/natario1/Transcoder/pull/16
-[17]: https://github.com/natario1/Transcoder/pull/17
-[18]: https://github.com/natario1/Transcoder/pull/18
-[20]: https://github.com/natario1/Transcoder/pull/20
-[29]: https://github.com/natario1/Transcoder/pull/29
-[31]: https://github.com/natario1/Transcoder/pull/31
-[32]: https://github.com/natario1/Transcoder/pull/32
-[41]: https://github.com/natario1/Transcoder/pull/41
-[47]: https://github.com/natario1/Transcoder/pull/47
-[48]: https://github.com/natario1/Transcoder/pull/48
-[50]: https://github.com/natario1/Transcoder/pull/50
-[54]: https://github.com/natario1/Transcoder/pull/54
diff --git a/docs/_about/getting-started.md b/docs/_about/getting-started.md
deleted file mode 100644
index 826130d9..00000000
--- a/docs/_about/getting-started.md
+++ /dev/null
@@ -1,42 +0,0 @@
----
-layout: page
-title: "Getting Started"
-description: "Simple guide to transcode your first video"
-order: 2
-disqus: 1
----
-
-### Before you start
-
-If your app targets versions older than API 18, you can override the minSdkVersion by
-adding this line to your manifest file:
-
-```xml
-<uses-sdk tools:overrideLibrary="com.otaliastudios.transcoder" />
-```
-
-In this case you should check at runtime that API level is at least 18, before
-calling any method here.
-
-### Transcoding your first video
-
-Transcoding happens through the `Transcoder` class by passing it an output file path,
-and one of more input data sources. It's pretty simple:
-
-```java
-Transcoder.into(filePath)
-        .addDataSource(context, uri) // or...
-        .addDataSource(filePath) // or...
-        .addDataSource(fileDescriptor) // or...
-        .addDataSource(dataSource)
-        .setListener(new TranscoderListener() {
-             public void onTranscodeProgress(double progress) {}
-             public void onTranscodeCompleted(int successCode) {}
-             public void onTranscodeCanceled() {}
-             public void onTranscodeFailed(@NonNull Throwable exception) {}
-        }).transcode()
-```
-
-However, we offer many APIs and additional features on top that you can read about in the
-in-depth documentation.
-
diff --git a/docs/_about/install.md b/docs/_about/install.md
deleted file mode 100644
index b55ed375..00000000
--- a/docs/_about/install.md
+++ /dev/null
@@ -1,27 +0,0 @@
----
-layout: page
-title: "Install"
-description: "Integrate in your project"
-order: 1
----
-
-The library works on API 18+, which is the only requirement and should be met by many projects nowadays.
-
-The project is publicly hosted on [JCenter](https://bintray.com/natario/android/Transcoder), where you
-can download the AAR package. To fetch with Gradle, make sure you add the JCenter repository in your root projects `build.gradle` file:
-
-```groovy
-allprojects {
-  repositories {
-    jcenter()
-  }
-}
-```
-
-Then simply download the latest version:
-
-```groovy
-api 'com.otaliastudios:transcoder:{{ site.github_version }}'
-```
-
-No other configuration steps are needed.
\ No newline at end of file
diff --git a/docs/_config.yml b/docs/_config.yml
deleted file mode 100644
index e38c10e9..00000000
--- a/docs/_config.yml
+++ /dev/null
@@ -1,45 +0,0 @@
-# Glide: https://github.com/bumptech/glide/blob/gh-pages/_config.yml
-# Source repo: https://github.com/bruth/jekyll-docs-template
-# Source site: http://bruth.github.io/jekyll-docs-template/
-# Ref guide: https://visualstudiomagazine.com/Articles/2015/03/01/GitHub-Pages.aspx?Page=2
-
-# Used by us
-title: Transcoder
-color: '#f8f8f8'
-description: A well documented Android library providing hardware-accelerated video transcoding, using MediaCodec APIs instead of native code (no FFMPEG patent issues). Supports cropping to any dimension, concatenation, clipping, audio processing, video speed and much more. # used by ourselves and by seo tag.
-disqus_shortname: 'natario1-transcoder'
-google_analytics_id: 'UA-155077779-2'
-google_site_verification: '4x49i17ABIrSvUl52SeL0-t0341aTnWWaC62-FYCRT4'
-github: [metadata] # TODO What's this?
-github_repo: Transcoder
-github_version: 0.8.0
-github_branch: master
-baseurl: '/Transcoder' # Keep as an empty string if served up at the root
-collections:
-  about:
-    name: Overview
-    output: true
-  docs:
-    name: Documentation
-    output: true
-  extra:
-    name: More
-    output: true
-screenshots:
-  - 'screenshot-1.png'
-  - 'screenshot-2.png'
-
-# Jekyll specific stuff
-author:
-  name: Mattia Iavarone # Should appear in <head>.
-  email: mat.iavarone@gmail.com
-  github: natario1
-  website: https://natario.dev
-plugins:
-  - jekyll-seo-tag # Add SEO tags
-permalink: /:categories/:title # Ensure permalinks have no date nor extension
-exclude: ['script', 'README.md'] # Exclude non-site files
-highlighter: rouge # Syntax highlighting
-markdown: kramdown # Use the kramdown Markdown renderer
-kramdown:
-  input: GFM # Use Github Flavored Markdown
\ No newline at end of file
diff --git a/docs/_docs/clipping.md b/docs/_docs/clipping.md
deleted file mode 100644
index 041e3a9b..00000000
--- a/docs/_docs/clipping.md
+++ /dev/null
@@ -1,61 +0,0 @@
----
-layout: page
-title: "Clipping and trimming"
-description: "How to clip each segment individually on both ends"
-order: 2
-disqus: 1
----
-
-Starting from `v0.8.0`, Transcoder offers the option to clip or trim video segments, on one or
-both ends. This is done by using special `DataSource` objects that wrap you original source,
-so that, in case of [concatenation](concatenation) of multiple media files, the trimming values
-can be set individually for each segment.
-
-> When video tracks are involved, Transcoder will always move the clipping position to the 
-closest video sync frame. This means that sync frames define the granularity of clipping!
-If the rate of sync frames in your media is very low, there might be a significant difference
-with respect to what you would expect.
-
-For example, if your media has sync frames at seconds 2 and 4 and you try to clip the first 2.5
-seconds, Transcoder will actually only clip the first 2.
-
-### TrimDataSource
-
-The `TrimDataSource` class lets you trim segments by specifying the amount of time to be trimmed
-at both ends. For example, the code below will trim the file by 1 second at the beginning, and 
-2 seconds at the end:
-
-```java
-DataSource source = new UriDataSource(context, uri);
-DataSource trim = new TrimDataSource(source, 1000 * 1000, 2 * 1000 * 1000);
-Transcoder.into(filePath)
-        .addDataSource(trim)
-        .transcode()
-```
-
-It is recommended to always check `source.getDurationUs()` to compute the correct values.
- 
-### ClipDataSource
-
-The `ClipDataSource` class lets you clip segments by specifying a time window. For example, 
-the code below clip the file from second 1 until second 5:
-
-```java
-DataSource source = new UriDataSource(context, uri);
-DataSource clip = new ClipDataSource(source, 1000 * 1000, 5 * 1000 * 1000);
-Transcoder.into(filePath)
-        .addDataSource(clip)
-        .transcode()
-```
-
-It is recommended to always check `source.getDurationUs()` to compute the correct values.
- 
-### Related APIs
-
-|Method|Description|
-|------|-----------|
-|`new TrimDataSource(source, long)`|Creates a new data source trimmed on start.|
-|`new TrimDataSource(source, long, long)`|Creates a new data source trimmed on both ends.|
-|`new ClipDataSource(source, long)`|Creates a new data source clipped on start.|
-|`new ClipDataSource(source, long, long)`|Creates a new data source clipped on both ends.|
-
diff --git a/docs/_extra/contact.md b/docs/_extra/contact.md
deleted file mode 100644
index 409dfe15..00000000
--- a/docs/_extra/contact.md
+++ /dev/null
@@ -1,12 +0,0 @@
----
-layout: page
-title: "Contact"
-order: 3
----
-
-This library is maintained by [Mattia Iavarone](https://github.com/natario1) (@natario1).
-Feel free to contact me privately by <a href="mailto:mat.iavarone@gmail.com">sending an email</a>,
-for support, consulting, or have any other business-related question.
-
-To report issues, please use the [project GitHub page](https://github.com/natario1/Transcoder).
-
diff --git a/docs/_extra/contributing.md b/docs/_extra/contributing.md
deleted file mode 100644
index cf62752b..00000000
--- a/docs/_extra/contributing.md
+++ /dev/null
@@ -1,60 +0,0 @@
----
-layout: page
-title: "Contributing & License"
-order: 1
----
-
-Everyone is welcome to contribute with suggestions or pull requests, as the library is under active development.
-
-We are grateful to anyone who will contribute with fixes, features or feature requests. If you don't
-want to get involved but still want to support the project, please [consider donating](donate).
-
-### Bug reports
-
-Please make sure to fill the bug report issue template on GitHub, if applicable.
-We highly recommend to try to reproduce the bug in the demo app, as this helps a lot in debugging
-and excludes programming errors from your side.
-
-Make sure to include:
-
-- A clear and concise description of what the bug is
-- Transcoder version, device type, Android API level
-- Exact steps to reproduce the issue
-- Description of the expected behavior
-- The original media file(s) that manifest the problem
-
-Recommended extras:
-
-- LogCat logs (use `Logger.setLogLevel(LEVEL_VERBOSE)` to print all)
-- Link to a GitHub repo where the bug is reproducible
-
-### Pull Requests
-
-Please open an issue first!
-
-Unless your PR is a simple fix (typos, documentation, bugs with obvious solution), opening an issue
-will let us discuss the problem, take design decisions and have a reference to the issue description.
-
-If you can, please write tests. We are planning to work on improving the library test coverage soon.
-
-### License
-
-This project is licensed under Apache 2.0. It consists of improvements over
-the [ypresto/android-transcoder](https://github.com/ypresto/android-transcoder)
-project which was licensed under Apache 2.0 as well:
-
-```
-Copyright (C) 2014-2016 Yuya Tanaka
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-```
diff --git a/docs/_extra/donate.md b/docs/_extra/donate.md
deleted file mode 100644
index 3400e886..00000000
--- a/docs/_extra/donate.md
+++ /dev/null
@@ -1,16 +0,0 @@
----
-layout: page
-title: "Donate"
-order: 2
----
-
-Transcoder is maintained and, for the most part, developed by Mattia Iavarone ([contact me!](contact)). If you like the project, 
-use it with profit, or simply want to thank back, please consider 
-[sponsoring me](https://github.com/sponsors/natario1) through the GitHub Sponsors program!
-
-I offer private support hours through the sponsorship program and I'm open to help your 
-company with features that are not currently supported by the open source project.
-
-Thank you for any contribution!
-
-
diff --git a/docs/_includes/disqus.html b/docs/_includes/disqus.html
deleted file mode 100644
index b561eef0..00000000
--- a/docs/_includes/disqus.html
+++ /dev/null
@@ -1,12 +0,0 @@
-<div id="disqus_thread"></div>
-<script type="text/javascript">
-    /* * * CONFIGURATION VARIABLES: EDIT BEFORE PASTING INTO YOUR WEBPAGE * * */
-    var disqus_shortname = '{{ site.disqus_shortname }}'; // required: replace example with your forum shortname
-    /* * * DON'T EDIT BELOW THIS LINE * * */
-    (function() {
-        var dsq = document.createElement('script'); dsq.type = 'text/javascript'; dsq.async = true;
-        dsq.src = '//' + disqus_shortname + '.disqus.com/embed.js';
-        (document.getElementsByTagName('head')[0] || document.getElementsByTagName('body')[0]).appendChild(dsq);
-    })();
-</script>
-<noscript>Please enable JavaScript to view the <a href="https://disqus.com/?ref_noscript">comments powered by Disqus.</a></noscript>
diff --git a/docs/_includes/footer.html b/docs/_includes/footer.html
deleted file mode 100644
index be732e0b..00000000
--- a/docs/_includes/footer.html
+++ /dev/null
@@ -1,4 +0,0 @@
-<footer class="text-center py-3 border-top has-divider">
-    <span>View on <a href="https://github.com/{{ site.author.github }}/{{ site.github_repo }}">GitHub</a> or become <a href="{{ site.baseurl }}/extra/donate">a sponsor</a>!</span>
-    <span class="d-block">Made by <a href="{{ site.author.website }}">{{ site.author.name }}</a></span>
-</footer>
diff --git a/docs/_includes/google_analytics.html b/docs/_includes/google_analytics.html
deleted file mode 100644
index 174548d1..00000000
--- a/docs/_includes/google_analytics.html
+++ /dev/null
@@ -1,7 +0,0 @@
-<script async src="https://www.googletagmanager.com/gtag/js?id={{ site.google_analytics_id }}"></script>
-<script>
-    window.dataLayer = window.dataLayer || [];
-    function gtag(){dataLayer.push(arguments);}
-    gtag('js', new Date());
-    gtag('config', '{{ site.google_analytics_id }}');
-</script>
diff --git a/docs/_includes/head.html b/docs/_includes/head.html
deleted file mode 100644
index d3f33018..00000000
--- a/docs/_includes/head.html
+++ /dev/null
@@ -1,21 +0,0 @@
-<meta charset="utf-8">
-<meta http-equiv="content-type" content="text/html; charset=utf-8">
-<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
-<meta name="viewport" content="width=device-width, initial-scale=1">
-
-<meta name="theme-color" content="{{ site.color }}">
-<meta name="msapplication-navbutton-color" content="{{ site.color }}">
-<meta name="msapplication-TileColor" content="{{ site.color }}">
-<meta name="apple-mobile-web-app-capable" content="yes">
-<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
-
-<meta name="description" content="{{ site.description }}">
-<meta name="google-site-verification" content="{{ site.google_site_verification }}" />
-
-<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
-
-{% seo %}
-
-{% if site.google_analytics_id != "" %}
-{% include google_analytics.html %}
-{% endif %}
\ No newline at end of file
diff --git a/docs/_includes/header.html b/docs/_includes/header.html
deleted file mode 100644
index 7e135e05..00000000
--- a/docs/_includes/header.html
+++ /dev/null
@@ -1,21 +0,0 @@
-<header class="text-center py-3 border-bottom has-divider">
-    <!-- center -->
-    <img class="logo align-middle" alt="Logo" src="{{ site.baseurl }}/static/icon_foreground.png"/>
-    <a class="h3 mx-2 align-middle text-decoration-none" href="{{ site.baseurl }}/home">{{ site.title }}</a>
-
-    <!-- left -->
-    <div class="left align-middle">
-        <img class="drawer-toggle p-2 mx-2 d-md-none"
-             alt="Menu toggle"
-             src="{{ site.baseurl }}/icons/menu.svg"
-             onclick="document.getElementById('drawer').classList.toggle('drawer-closed');"/>
-    </div>
-
-    <!-- right -->
-    <div class="right align-middle">
-        <span class="version d-none d-sm-inline-block">latest: v{{ site.github_version }}</span>
-        <a class="p-2 mx-2" href="https://github.com/{{ site.author.github }}/{{ site.github_repo }}">
-            <img alt="GitHub" src="{{ site.baseurl }}/icons/github.svg">
-        </a>
-    </div>
-</header>
diff --git a/docs/_includes/navigation.html b/docs/_includes/navigation.html
deleted file mode 100644
index 366fa27e..00000000
--- a/docs/_includes/navigation.html
+++ /dev/null
@@ -1,27 +0,0 @@
-<ul id="navigation" class="py-4">
-    <li><a href="{{ site.baseurl }}/home">Home</a></li>
-    {% for collection in site.collections %}
-    {% if collection.label != "posts" %}
-    <li class="mt-3"><h5 class="mt-0 mb-1">{{ collection.name }}</h5>
-        <ul>
-            {% assign docs = (collection.docs | sort: "order") %}
-            {% for doc in docs %}
-            <li class="pt-2 pb-2">
-                <a href="{{ site.baseurl }}{{ doc.url }}">{{ doc.title }}</a>
-            </li>
-            {% endfor %}
-        </ul>
-    </li>
-    {% endif %}
-    {% endfor %}
-</ul>
-<script>
-    /* find current item and make it active */
-    const current = document.location.href;
-    const links = document.querySelectorAll('#navigation a');
-    links.forEach((link) => {
-        if (current && link && current.endsWith(link.getAttribute('href'))) {
-            link.parentElement.classList.add('active')
-        }
-    });
-</script>
diff --git a/docs/_layouts/landing.html b/docs/_layouts/landing.html
deleted file mode 100644
index 130ce1fa..00000000
--- a/docs/_layouts/landing.html
+++ /dev/null
@@ -1,33 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-    <head>
-        {% include head.html %}
-        <title>{{ site.title }}</title>
-        <link rel="stylesheet" href="{{ site.baseurl }}/css/landing.css">
-        <link rel="stylesheet" href="{{ site.baseurl }}/css/fonts_responsive.css">
-    </head>
-    <body>
-        <div class="container p-5">
-            <img class="d-block mx-auto" id="logo" src="{{ site.baseurl }}/static/icon_foreground.png" alt="Logo">
-            <div class="text-center">
-                <h1 id="title" class="display-2 font-weight-bold mt-2">{{ site.title }}</h1>
-                {{ content }}
-            </div>
-            <div class="text-center">
-                <a class="btn rounded-pill px-3 py-2 mx-2 mt-2" href="{{ site.baseurl }}/home">Documentation</a>
-                <a class="btn rounded-pill px-3 py-2 mx-2 mt-2" href="{{ site.baseurl }}/about/changelog">Changelog</a>
-                <a class="btn rounded-pill px-3 py-2 mx-2 mt-2" href="https://github.com/{{ site.author.github }}/{{ site.github_repo }}">GitHub</a>
-                <a class="btn rounded-pill px-3 py-2 mx-2 mt-2" href="{{ site.baseurl }}/extra/donate">Support</a>
-            </div>
-            <br/>
-            <div class="row">
-                {% assign col = 12 | divided_by: site.screenshots.size %}
-                {% for screenshot in site.screenshots %}
-                <div class="col-12 col-sm-{{ col }} mt-4">
-                    <img class="img-fluid" src="{{ site.baseurl }}/static/{{ screenshot }}" alt="Screenshot">
-                </div>
-                {% endfor %}
-            </div>
-        </div>
-    </body>
-</html>
diff --git a/docs/_layouts/main.html b/docs/_layouts/main.html
deleted file mode 100644
index 6f0bab15..00000000
--- a/docs/_layouts/main.html
+++ /dev/null
@@ -1,37 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-    <head>
-        {% include head.html %}
-        <title>{{ site.title }}{% if page.title %} | {{ page.title }}{% endif %}</title>
-        <link rel="stylesheet" href="{{ site.baseurl }}/css/main.css">
-        <link rel="stylesheet" href="{{ site.baseurl }}/css/fonts_responsive.css">
-    </head>
-    <body>
-        {% include header.html %}
-        <div class="container-fluid container-md">
-            <div class="row">
-                <div id="drawer" class="col-md-3 px-4 border-right has-divider drawer drawer-closed">
-                    {% include navigation.html %}
-                </div>
-                <div class="col-12 col-md-9">
-                    <div class="content py-3 px-2">
-                        {{ content }}
-                    </div>
-                    {% if page.disqus == 1 %}
-                    <div class="mt-4">
-                        {% include disqus.html %}
-                    </div>
-                    {% endif %}
-                </div>
-            </div>
-        </div>
-
-        <div class="container-fluid">
-            <div class="row">
-                <div class="col-12 p-0">
-                    {% include footer.html %}
-                </div>
-            </div>
-        </div>
-    </body>
-</html>
diff --git a/docs/_layouts/page.html b/docs/_layouts/page.html
deleted file mode 100644
index e7005d0a..00000000
--- a/docs/_layouts/page.html
+++ /dev/null
@@ -1,30 +0,0 @@
----
-layout: main
----
-
-<div class="page-header border-bottom has-divider pb-3">
-    <h1 class="d-inline-block mt-1 mb-0 mr-2">{{ page.title }}</h1>
-    {% if page.description %}<span class="mr-2">{{ page.description }}</span>{% endif %}
-    <span><a href="https://github.com/{{ site.author.github }}/{{ site.github_repo }}/edit/{{ site.github_branch }}/docs/{{ page.path }}">[edit this page]</a></span>
-</div>
-<div class="page my-4">
-    {{ content }}
-</div>
-<div class="page-footer border-top has-divider">
-    <div class="row text-center">
-        {% assign collection = site.collections | where:"label", page.collection | first %}
-        {% assign next = page.order | plus: 1 %}
-        {% assign previous = page.order | minus: 1 %}
-        {% for doc in collection.docs %}
-        {% if doc.order == previous %}
-        <a class="previous col mt-3" href="{{ site.baseurl }}{{ doc.url }}">< {{ doc.title }}</a>
-        {% endif %}
-        {% endfor %}
-
-        {% for doc in collection.docs %}
-        {% if doc.order == next %}
-        <a class="next col mt-3" href="{{ site.baseurl }}{{ doc.url }}">{{ doc.title }} ></a>
-        {% endif %}
-        {% endfor %}
-    </div>
-</div>
\ No newline at end of file
diff --git a/docs/_docs/advanced-options.md b/docs/advanced-options.mdx
similarity index 73%
rename from docs/_docs/advanced-options.md
rename to docs/advanced-options.mdx
index 0902d23b..0da292d4 100644
--- a/docs/_docs/advanced-options.md
+++ b/docs/advanced-options.mdx
@@ -1,49 +1,46 @@
 ---
-layout: page
-title: "Advanced Options"
-description: "Advanced transcoding options"
-order: 7
-disqus: 1
+title: Advanced Options
 ---
 
-### Video rotation
+# Advanced Options
+
+## Video rotation
 
 You can set the output video rotation with the `setRotation(int)` method. This will apply a clockwise
 rotation to the input video frames. Accepted values are `0`, `90`, `180`, `270`:
 
-```java
+```kotlin
 Transcoder.into(filePath)
-        .setVideoRotation(rotation) // 0, 90, 180, 270
-        // ...
+    .setVideoRotation(rotation) // 0, 90, 180, 270
+    // ...
 ```
 
-### Time interpolation
+## Time interpolation
 
 We offer APIs to change the timestamp of each video and audio frame. You can pass a `TimeInterpolator`
 to the transcoder builder to be able to receive the frame timestamp as input, and return a new one
 as output.
 
-```java
+```kotlin
 Transcoder.into(filePath)
-        .setTimeInterpolator(timeInterpolator)
-        // ...
+    .setTimeInterpolator(timeInterpolator)
+    // ...
 ```
 
 As an example, this is the implementation of the default interpolator, called `DefaultTimeInterpolator`,
 that will just return the input time unchanged:
 
-```java
-@Override
-public long interpolate(@NonNull TrackType type, long time) {
+```kotlin
+override fun interpolate(type: TrackType, time: Long): Long {
     // Receive input time in microseconds and return a possibly different one.
-    return time;
+    return time
 }
 ```
 
 It should be obvious that returning invalid times can make the process crash at any point, or at least
 the transcoding operation fail.
 
-### Video speed
+## Video speed
 
 We also offer a special time interpolator called `SpeedTimeInterpolator` that accepts a `float` parameter
 and will modify the video speed.
@@ -52,26 +49,26 @@ and will modify the video speed.
 - A speed factor < 1 will slow the video down
 - A speed factor > 1 will accelerate the video
 
-This interpolator can be set using `setTimeInterpolator(TimeInterpolator)`, or, as a shorthand, 
+This interpolator can be set using `setTimeInterpolator(TimeInterpolator)`, or, as a shorthand,
 using `setSpeed(float)`:
 
-```java
+```kotlin
 Transcoder.into(filePath)
-        .setSpeed(0.5F) // 0.5x
-        .setSpeed(1F) // Unchanged
-        .setSpeed(2F) // Twice as fast
-        // ...
+    .setSpeed(0.5F) // 0.5x
+    .setSpeed(1F) // Unchanged
+    .setSpeed(2F) // Twice as fast
+    // ...
 ```
 
-### Audio stretching
+## Audio stretching
 
 When a time interpolator alters the frames and samples timestamps, you can either remove audio or
 stretch the audio samples to the new length. This is done through the `AudioStretcher` interface:
 
-```java
+```kotlin
 Transcoder.into(filePath)
-        .setAudioStretcher(audioStretcher)
-        // ...
+    .setAudioStretcher(audioStretcher)
+    // ...
 ```
 
 The default audio stretcher, `DefaultAudioStretcher`, will:
@@ -81,19 +78,19 @@ The default audio stretcher, `DefaultAudioStretcher`, will:
 
 Please take a look at the implementation and read class documentation.
 
-### Audio resampling
+## Audio resampling
 
 When a sample rate different than the input is specified (by the `TrackStrategy`, or, when using the
-default audio strategy, by `DefaultAudioStategy.Builder.sampleRate()`), this library will automatically
-perform sample rate conversion for you. 
+default audio strategy, by `DefaultAudioStrategy.Builder.sampleRate()`), this library will automatically
+perform sample rate conversion for you.
 
 This operation is performed by a class called `AudioResampler`. We offer the option to pass your
 own resamplers through the transcoder builder:
 
-```java
+```kotlin
 Transcoder.into(filePath)
-        .setAudioResampler(audioResampler)
-        // ...
+    .setAudioResampler(audioResampler)
+    // ...
 ```
 
 The default audio resampler, `DefaultAudioResampler`, will perform both upsampling and downsampling
@@ -101,5 +98,4 @@ with very basic algorithms (drop samples when downsampling, repeat samples when
 Upsampling is generally discouraged - implementing a real upsampling algorithm is probably out of
 the scope of this library.
 
-Please take a look at the implementation and read class documentation.
-
+Please take a look at the implementation and read class documentation.
\ No newline at end of file
diff --git a/docs/changelog.mdx b/docs/changelog.mdx
new file mode 100644
index 00000000..6defa6f1
--- /dev/null
+++ b/docs/changelog.mdx
@@ -0,0 +1,224 @@
+---
+title: Changelog
+---
+
+# Changelog
+
+New versions are released through GitHub, so the reference page is the [GitHub Releases](https://github.com/deepmedia/Transcoder/releases) page.
+
+## 0.11.X
+
+### 0.11.2
+
+- Enhancement: support pass-through audio with more than 2 channels, thanks to [@natario1](https://github.com/natario1) ([#209](https://github.com/deepmedia/Transcoder/pull/209))
+
+[Compare 0.11.1...0.11.2](https://github.com/deepmedia/Transcoder/compare/v0.11.1...v0.11.2).
+
+### 0.11.1
+
+- Fix: add unbounded queue for drifted tracks during initialization, thanks to [@jumperson](https://github.com/jumperson) ([#166](https://github.com/deepmedia/Transcoder/pull/166))
+
+[Compare 0.11.0...0.11.1](https://github.com/deepmedia/Transcoder/compare/v0.11.0...v0.11.1).
+
+### 0.11.0
+
+This release focuses on stability and performance, fixing old bugs that were making the library unstable and unreliable.
+In addition, the library is now available at new maven coordinates `io.deepmedia.community:transcoder-android`.
+The old coordinates will still work and receive updates.
+
+- Enhancement: Revisit transcoding pipeline ([#203](https://github.com/deepmedia/Transcoder/pull/203))
+- Enhancement: Allow SpeedTimeInterpolator reuse, thanks to [@vaibhavpandeyvpz](https://github.com/vaibhavpandeyvpz) ([#199](https://github.com/deepmedia/Transcoder/pull/199))
+- Fix: fix video artifacts, thanks to [@jumperson](https://github.com/jumperson) ([#199](https://github.com/deepmedia/Transcoder/pull/200))
+- Fix: fix end-of-stream flag not sent ([#201](https://github.com/deepmedia/Transcoder/pull/201))
+- Fix: fix performance issues, thanks to [@jumperson](https://github.com/jumperson) ([#202](https://github.com/deepmedia/Transcoder/pull/202))
+- Fix: handle null buffers ([#203](https://github.com/deepmedia/Transcoder/pull/203))
+- Fix: avoid pipeline stalls ([#203](https://github.com/deepmedia/Transcoder/pull/203))
+- Fix: ensure monotonic muxer timestamps ([#203](https://github.com/deepmedia/Transcoder/pull/203))
+
+[Compare 0.10.5...0.11.0](https://github.com/deepmedia/Transcoder/compare/v0.10.5...v0.11.0).
+
+## 0.10.X
+
+### 0.10.5
+
+- Fix: Honor buffer.hasRemaining() and otherwise release the buffer. ([#182][182])
+- Fix: Call progress even when not advanced. ([#170][170])
+- Fix: FilePathDataSource crash before initialize. ([#160][160])
+
+[Compare 0.10.4...0.10.5](https://github.com/deepmedia/Transcoder/compare/v0.10.4...v0.10.5).
+
+### 0.10.4
+
+- Fix: fixed crash in specific conditions ([#140][140])
+- New: AssetFileDescriptorDataSource, can be used to transcode from AssetFileDescriptors ([#140][140])
+
+[Compare 0.10.3...0.10.4](https://github.com/deepmedia/Transcoder/compare/v0.10.3...v0.10.4).
+
+### 0.10.3
+
+- Fix: error when merging many files, thanks to [@DamonChen117][DamonChen117] ([#134][134])
+- Fix: more concatenation issues
+
+[Compare 0.10.2...0.10.3](https://github.com/deepmedia/Transcoder/compare/v0.10.2...v0.10.3).
+
+### 0.10.2
+
+- Fix: error when merging many files ([#132][132])
+
+[Compare 0.10.1...0.10.2](https://github.com/deepmedia/Transcoder/compare/v0.10.1...v0.10.2).
+
+### 0.10.1
+
+- Fix: thumbnails upside down ([#125][125])
+- Fix: clip/trim issues ([#127][127])
+- Fix: seeking issues ([#128][128])
+- Fix: concatenation failure ([#130][130])
+
+[Compare 0.10.0...0.10.1](https://github.com/deepmedia/Transcoder/compare/v0.10.0...v0.10.1).
+
+### 0.10.0
+
+> Transcoder is now distributed through Maven Central. Snapshot releases are available as well.
+
+- New: thumbnails support ([#119][119])
+- Improvement: rewritten transcoding pipeline ([#118][118])
+- Fix: many bugs fixed while rewriting the pipeline ([#118][118])
+
+[Compare 0.9.1...0.10.0](https://github.com/deepmedia/Transcoder/compare/v0.9.1...v0.10.0).
+
+## 0.9.X
+
+### 0.9.1
+
+- Improvement: `DefaultDataSink` new constructor with support for FileDescriptor. ([#87][87])
+
+[Compare 0.9.0...0.9.1](https://github.com/deepmedia/Transcoder/compare/v0.9.0...v0.9.1).
+
+### 0.9.0
+
+- New: `BlankAudioDataSource` can be used to add muted audio to a video-only track, thanks to [@mudar][mudar] ([#64][64])
+- Enhancement: you can now concatenate multiple files even if some of them have no audio, thanks to [@mudar][mudar] ([#64][64])
+- Enhancement: you can now concatenate multiple files without audio track, thanks to [@cbernier2][cbernier2] ([#61][61])
+
+[Compare 0.8.0...0.9.0](https://github.com/deepmedia/Transcoder/compare/v0.8.0...v0.9.0).
+
+## 0.8.X
+
+### 0.8.0
+
+- New: `TrimDataSource` to trim segments. Use it to wrap your original source. Thanks to [@mudar][mudar] ([#50][50])
+- New: `ClipDataSource`, just likes `TrimDataSource` but selects trim values with respect to video start ([#54][54])
+
+> Transcoder will trim video segments only at the closest video sync frame. If your video has few sync
+frames, the trim timestamp might be different than what was selected.
+
+[Compare 0.7.4...0.8.0](https://github.com/deepmedia/Transcoder/compare/v0.7.4...v0.8.0).
+
+## Older versions
+
+### 0.7.4
+
+- Fix: fixed Xamarin incompatibility, thanks to [@aweck][aweck] ([#41][41])
+- Fix: fixed small bugs with specific API versions / media files ([#47][47])
+- Fix: fixed issues with specific media files, ensure consistent onProgress callback ([#48][48])
+
+[Compare 0.7.3...0.7.4](https://github.com/deepmedia/Transcoder/compare/v0.7.3...v0.7.4).
+
+### 0.7.3
+
+- Fix: fixed bug with files that do not have an audio track, thanks to [@pawegio][pawegio] ([#31][31])
+- Fix: fixed possible issues with FilePathDataSource ([#32][32])
+
+[Compare 0.7.2...0.7.3](https://github.com/deepmedia/Transcoder/compare/v0.7.2...v0.7.3).
+
+### 0.7.2
+
+- Improvement: better input format detection. Fixes bugs with certain files ([#29][29])
+- Improvement: added `DefaultAudioStrategy.Builder.bitRate()` option ([#29][29])
+
+[Compare 0.7.1...0.7.2](https://github.com/deepmedia/Transcoder/compare/v0.7.1...v0.7.2).
+
+### 0.7.1
+
+- Improvement: update the underlying OpenGL library ([#20][20])
+
+[Compare 0.7.0...0.7.1](https://github.com/deepmedia/Transcoder/compare/v0.7.0...v0.7.1).
+
+### 0.7.0
+
+- New: video concatenation to stitch together multiple media ([#14][14])
+- New: select a specific track type (`VIDEO` or `AUDIO`) for sources ([#14][14])
+- New: audio resampling through `DefaultAudioStrategy` ([#16][16])
+- New: custom resampling through `TranscoderOptions.setAudioResampler()` ([#16][16])
+- Breaking change: `TranscoderOptions.setDataSource()` renamed to `addDataSource()` ([#14][14])
+- Breaking change: `TranscoderOptions.setRotation()` renamed to `setVideoRotation()` ([#14][14])
+- Breaking change: `DefaultVideoStrategy.iFrameInterval()` renamed to `keyFrameInterval()` ([#14][14])
+- Breaking change: `DefaultAudioStrategy` now uses a builder - removed old constructor ([#16][16])
+- Improvement: rotate videos through OpenGL instead of using metadata ([#14][14])
+- Improvement: when concatenating multiple sources, automatically clip the longer track (audio or video) ([#17][17])
+- Improvement: various bug fixed ([#18][18])
+
+[Compare 0.6.0...0.7.0](https://github.com/deepmedia/Transcoder/compare/v0.6.0...v0.7.0).
+
+### 0.6.0
+
+- New: ability to change video/audio speed and change each frame timestamp ([#10][10])
+- New: ability to set the video output rotation ([#8][8])
+- Improvement: new frame dropping algorithm, thanks to [@Saqrag][Saqrag] ([#9][9])
+- Improvement: avoid format validation on tracks coming from PassThroughTrackTranscoder, thanks to [@Saqrag][Saqrag] ([#11][11])
+
+[Compare 0.5.0...0.6.0](https://github.com/deepmedia/Transcoder/compare/v0.5.0...v0.6.0).
+
+### 0.5.0
+
+- New: video cropping to any dimension. Encoder will crop the exceeding size. ([#6][6])
+- New: `AspectRatioResizer` to crop to a given aspect ratio. ([#6][6])
+- Breaking change: `MediaTranscoder` renamed to `Transcoder`. ([#6][6])
+- Breaking change: `MediaTranscoderOptions` renamed to `TranscoderOptions`. ([#6][6])
+- Breaking change: `MediaTranscoder.Listener` renamed to `TranscoderListener`. ([#6][6])
+- Improvement: use [EglCore](https://github.com/natario1/EglCore) to replace GL logic. ([#5][5])
+- Improvement: bug fixes and a new demo app to test transcoding options easily ([#4][4])
+
+[Saqrag]: https://github.com/Saqrag
+[pawegio]: https://github.com/pawegio
+[aweck]: https://github.com/aweck
+[mudar]: https://github.com/mudar
+[cbernier2]: https://github.com/cbernier2
+[DamonChen117]: https://github.com/DamonChen117
+
+[4]: https://github.com/deepmedia/Transcoder/pull/4
+[5]: https://github.com/deepmedia/Transcoder/pull/5
+[6]: https://github.com/deepmedia/Transcoder/pull/6
+[8]: https://github.com/deepmedia/Transcoder/pull/8
+[9]: https://github.com/deepmedia/Transcoder/pull/9
+[10]: https://github.com/deepmedia/Transcoder/pull/10
+[11]: https://github.com/deepmedia/Transcoder/pull/11
+[14]: https://github.com/deepmedia/Transcoder/pull/14
+[16]: https://github.com/deepmedia/Transcoder/pull/16
+[17]: https://github.com/deepmedia/Transcoder/pull/17
+[18]: https://github.com/deepmedia/Transcoder/pull/18
+[20]: https://github.com/deepmedia/Transcoder/pull/20
+[29]: https://github.com/deepmedia/Transcoder/pull/29
+[31]: https://github.com/deepmedia/Transcoder/pull/31
+[32]: https://github.com/deepmedia/Transcoder/pull/32
+[41]: https://github.com/deepmedia/Transcoder/pull/41
+[47]: https://github.com/deepmedia/Transcoder/pull/47
+[48]: https://github.com/deepmedia/Transcoder/pull/48
+[50]: https://github.com/deepmedia/Transcoder/pull/50
+[54]: https://github.com/deepmedia/Transcoder/pull/54
+[61]: https://github.com/deepmedia/Transcoder/pull/61
+[64]: https://github.com/deepmedia/Transcoder/pull/64
+[87]: https://github.com/deepmedia/Transcoder/pull/87
+[118]: https://github.com/deepmedia/Transcoder/pull/118
+[119]: https://github.com/deepmedia/Transcoder/pull/119
+[125]: https://github.com/deepmedia/Transcoder/pull/125
+[127]: https://github.com/deepmedia/Transcoder/pull/127
+[128]: https://github.com/deepmedia/Transcoder/pull/128
+[130]: https://github.com/deepmedia/Transcoder/pull/130
+[132]: https://github.com/deepmedia/Transcoder/pull/132
+[134]: https://github.com/deepmedia/Transcoder/pull/134
+[140]: https://github.com/deepmedia/Transcoder/pull/140
+[160]: https://github.com/deepmedia/Transcoder/pull/160
+[170]: https://github.com/deepmedia/Transcoder/pull/170
+[182]: https://github.com/deepmedia/Transcoder/pull/182
+
diff --git a/docs/clipping.mdx b/docs/clipping.mdx
new file mode 100644
index 00000000..d2fa03c7
--- /dev/null
+++ b/docs/clipping.mdx
@@ -0,0 +1,56 @@
+---
+title: Clip & Trim
+---
+
+# Clip & Trim
+
+Starting from `v0.8.0`, Transcoder offers the option to clip or trim video segments, on one or
+both ends. This is done by using special `DataSource` objects that wrap you original source,
+so that, in case of [concatenation](concatenation) of multiple media files, the trimming values
+can be set individually for each segment.
+
+> If Transcoder determines that the video should be decoded and re-encoded (status is `TrackStatus.COMPRESSING`)
+the clipping position is respected precisely. However, if your [strategy](track-strategies) does not
+include video decoding / re-encoding, the clipping position will be moved to the closest video sync frame.
+This means that the clipped output duration might be different than expected,
+depending on the frequency of sync frames in your original file.
+
+## TrimDataSource
+
+The `TrimDataSource` class lets you trim segments by specifying the amount of time to be trimmed
+at both ends. For example, the code below will trim the file by 1 second at the beginning, and
+2 seconds at the end:
+
+```kotlin
+let source: DataSource = UriDataSource(context, uri)
+let trim: DataSource = TrimDataSource(source, 1000 * 1000, 2 * 1000 * 1000)
+Transcoder.into(filePath)
+    .addDataSource(trim)
+    .transcode()
+```
+
+It is recommended to always check `source.getDurationUs()` to compute the correct values.
+
+## ClipDataSource
+
+The `ClipDataSource` class lets you clip segments by specifying a time window. For example,
+the code below clip the file from second 1 until second 5:
+
+```kotlin
+let source: DataSource = UriDataSource(context, uri)
+let clip: DataSource = ClipDataSource(source, 1000 * 1000, 5 * 1000 * 1000)
+Transcoder.into(filePath)
+    .addDataSource(clip)
+    .transcode()
+```
+
+It is recommended to always check `source.getDurationUs()` to compute the correct values.
+
+## Related APIs
+
+|Method|Description|
+|------|-----------|
+|`TrimDataSource(source, long)`|Creates a new data source trimmed on start.|
+|`TrimDataSource(source, long, long)`|Creates a new data source trimmed on both ends.|
+|`ClipDataSource(source, long)`|Creates a new data source clipped on start.|
+|`ClipDataSource(source, long, long)`|Creates a new data source clipped on both ends.|
\ No newline at end of file
diff --git a/docs/_docs/concatenation.md b/docs/concatenation.mdx
similarity index 60%
rename from docs/_docs/concatenation.md
rename to docs/concatenation.mdx
index d311e90a..559cb38d 100644
--- a/docs/_docs/concatenation.md
+++ b/docs/concatenation.mdx
@@ -1,20 +1,20 @@
 ---
-layout: page
-title: "Concatenation"
-description: "How to concatenate video segments"
-order: 3
-disqus: 1
+title: Concatenation
 ---
 
-As you might have guessed from the previous section, you can use `addDataSource(source)` multiple times. 
+# Concatenation
+
+## Appending sources
+
+As you might have guessed from the previous section, you can use `addDataSource(source)` multiple times.
 All the source files will be stitched together:
 
-```java
+```kotlin
 Transcoder.into(filePath)
-        .addDataSource(source1)
-        .addDataSource(source2)
-        .addDataSource(source3)
-        // ...
+    .addDataSource(source1)
+    .addDataSource(source2)
+    .addDataSource(source3)
+    // ...
 ```
 
 In the above example, the three videos will be stitched together in the order they are added
@@ -23,25 +23,25 @@ of applying consistent parameters (frame rate, bit rate, sample rate) during the
 
 This is a powerful tool since it can be used per-track:
 
-```java
+```kotlin
 Transcoder.into(filePath)
-        .addDataSource(source1) // Audio & Video, 20 seconds
-        .addDataSource(TrackType.VIDEO, source2) // Video, 5 seconds
-        .addDataSource(TrackType.VIDEO, source3) // Video, 5 seconds
-        .addDataSource(TrackType.AUDIO, source4) // Audio, 10 sceonds
-        // ...
+    .addDataSource(source1) // Audio & Video, 20 seconds
+    .addDataSource(TrackType.VIDEO, source2) // Video, 5 seconds
+    .addDataSource(TrackType.VIDEO, source3) // Video, 5 seconds
+    .addDataSource(TrackType.AUDIO, source4) // Audio, 10 sceonds
+    // ...
 ```
 
 In the above example, the output file will be 30 seconds long:
 
 ```
-Video: | •••••••••••••••••• source1 •••••••••••••••••• | •••• source2 •••• | •••• source3 •••• |  
-Audio: | •••••••••••••••••• source1 •••••••••••••••••• | •••••••••••••• source4 •••••••••••••• | 
+Video: | •••••••••••••••••• source1 •••••••••••••••••• | •••• source2 •••• | •••• source3 •••• |
+Audio: | •••••••••••••••••• source1 •••••••••••••••••• | •••••••••••••• source4 •••••••••••••• |
 ```
 
 And that's all you need to do.
 
-### Automatic clipping
+## Automatic clipping
 
 When concatenating data from multiple sources and on different tracks, it's common to have
 a total audio length that is different than the total video length.
@@ -49,13 +49,12 @@ a total audio length that is different than the total video length.
 In this case, `Transcoder` will automatically clip the longest track to match the shorter.
 For example:
 
-```java
+```kotlin
 Transcoder.into(filePath)
-        .addDataSource(TrackType.VIDEO, video1) // Video, 30 seconds
-        .addDataSource(TrackType.VIDEO, video2) // Video, 30 seconds
-        .addDataSource(TrackType.AUDIO, music) // Audio, 3 minutes
-        // ...
+    .addDataSource(TrackType.VIDEO, video1) // Video, 30 seconds
+    .addDataSource(TrackType.VIDEO, video2) // Video, 30 seconds
+    .addDataSource(TrackType.AUDIO, music) // Audio, 3 minutes
+    // ...
 ```
 
-In the situation above, we won't use the full music track, but only the first minute of it.
-
+In the situation above, we won't use the full music track, but only the first minute of it.
\ No newline at end of file
diff --git a/docs/css/colors.css b/docs/css/colors.css
deleted file mode 100644
index 4bef97de..00000000
--- a/docs/css/colors.css
+++ /dev/null
@@ -1,65 +0,0 @@
-:root {
-  --color-primary: #204853;
-  --color-primary-active: #102833;
-  --color-primary-hover: #103843;
-  --color-secondary: #030607;
-  --color-accent: #0e95e3;
-  --color-accent-light: #f5fcff;
-  --color-accent-dark: #0e3375;
-  --color-background: #FFFFFF;
-  --color-code: var(--color-primary);
-  --color-code-background: #f8f8f8;
-  --color-text-muted: #6c757d;
-}
-
-body {
-  background-color: var(--color-background);
-}
-
-a {
-  color: var(--color-primary);
-}
-
-code, pre {
-  background-color: var(--color-code-background);
-}
-
-:not(pre) > code {
-  color: var(--color-code);
-}
-
-a:hover {
-  color: var(--color-primary-hover) !important;
-}
-
-.btn-primary {
-    background-color: var(--color-primary) !important;
-    border-color: var(--color-primary) !important;
-    color: white !important;
-}
-
-.btn-primary.active, .btn-primary:active {
-  background-color: var(--color-primary-active) !important;
-  border-color: var(--color-primary-active) !important;
-}
-
-.btn-primary:hover {
-  background-color: var(--color-primary-hover) !important;
-  border-color: var(--color-primary-hover) !important;
-  color: white !important;
-}
-
-.btn-outline-primary {
-    border-color: var(--color-primary) !important;
-}
-
-.btn-outline-primary.active, .btn-outline-primary:active  {
-  background-color: var(--color-primary-active) !important;
-  border-color: var(--color-primary-active) !important;
-}
-
-.btn-outline-primary:hover {
-  border-color: var(--color-primary-hover) !important;
-  background-color: var(--color-primary-hover) !important;
-  color: white !important;
-}
diff --git a/docs/css/fonts.css b/docs/css/fonts.css
deleted file mode 100644
index 8dd13ccb..00000000
--- a/docs/css/fonts.css
+++ /dev/null
@@ -1,33 +0,0 @@
-@import url('https://fonts.googleapis.com/css?family=Lobster+Two:400i,700i|Roboto+Mono|Source+Sans+Pro:400,700&display=swap');
-@import "fonts_responsive.css";
-
-:root {
-  --font-mono: 'Roboto Mono';
-  --font-sans: 'Source Sans Pro';
-  --font-display: 'Lobster Two';
-}
-
-* {
-  font-family: var(--font-sans), sans-serif;
-  font-weight: 400;
-}
-
-h1, .h1, h2, .h2, h3, .h3, h4, .h4, h5, .h5, h6, .h6 {
-  font-family: var(--font-display), cursive;
-  font-style: italic;
-  font-weight: 700 !important;
-}
-
-h4, .h4, h5, .h5, h6, .h6 {
-  font-weight: 400;
-}
-
-button, .btn {
-  font-family: var(--font-display), cursive !important;
-  font-style: italic !important;
-  font-weight: 700 !important;
-}
-
-code, code * {
-  font-family: var(--font-mono) !important;
-}
\ No newline at end of file
diff --git a/docs/css/fonts_responsive.css b/docs/css/fonts_responsive.css
deleted file mode 100644
index 62ecacb3..00000000
--- a/docs/css/fonts_responsive.css
+++ /dev/null
@@ -1,34 +0,0 @@
-/* https://christianoliff.com/blog/bootstrap-with-rfs */
-/* either apply after everything else or add !important here */
-@media (max-width: 1200px) {
-  legend {
-    font-size: calc(1.275rem + 0.3vw);
-  }
-  h1, .h1 {
-    font-size: calc(1.375rem + 1.5vw);
-  }
-  h2, .h2 {
-    font-size: calc(1.325rem + 0.9vw);
-  }
-  h3, .h3 {
-    font-size: calc(1.3rem + 0.6vw);
-  }
-  h4, .h4 {
-    font-size: calc(1.275rem + 0.3vw);
-  }
-  .display-1 {
-    font-size: calc(1.725rem + 5.7vw);
-  }
-  .display-2 {
-    font-size: calc(1.675rem + 5.1vw);
-  }
-  .display-3 {
-    font-size: calc(1.575rem + 3.9vw);
-  }
-  .display-4 {
-    font-size: calc(1.475rem + 2.7vw);
-  }
-  .close {
-    font-size: calc(1.275rem + 0.3vw);
-  }
-}
diff --git a/docs/css/landing.css b/docs/css/landing.css
deleted file mode 100644
index e3da9c69..00000000
--- a/docs/css/landing.css
+++ /dev/null
@@ -1,43 +0,0 @@
-@import "fonts.css";
-@import "colors.css";
-
-:root {
-    --color-gradient-1: var(--color-secondary);
-    --color-gradient-2: var(--color-primary);
-}
-
-html {
-    width: 100%;
-    height: 100%;
-    margin: 0;
-}
-
-body {
-    background: linear-gradient(45deg, var(--color-gradient-1), var(--color-gradient-2)) fixed !important;
-}
-
-#logo {
-    width: 45%;
-    max-width: 340px;
-}
-
-h1 {
-    color: white;
-}
-
-p {
-    color: rgba(255, 255, 255, 0.7);
-    font-size: 1.2em;
-    line-height: 100%;
-}
-
-.btn {
-    color: white !important;
-    background-color: rgba(240, 240, 240, 0.25);
-    font-size: 1.3em;
-}
-
-.btn:hover {
-    color: white !important;
-    background-color: rgba(240, 240, 240, 0.4);
-}
\ No newline at end of file
diff --git a/docs/css/main.css b/docs/css/main.css
deleted file mode 100644
index 6aed50ba..00000000
--- a/docs/css/main.css
+++ /dev/null
@@ -1,291 +0,0 @@
-@import "fonts.css";
-@import "colors.css";
-@import "syntax.css";
-
-:root {
-    --color-footer: var(--color-code-background);
-    --color-table-head: var(--color-code-background);
-    --color-divider: rgba(230, 230, 230, 0.7);
-    --header-height: 65px; /* kind of */
-    --cards-radius: 4px;
-}
-
-html, body {
-    height: 100%;
-}
-
-/* dividers */
-
-.has-divider {
-    border-color: var(--color-divider) !important;
-}
-
-/* header */
-
-header {
-    background: linear-gradient(45deg, var(--color-secondary), var(--color-primary)) fixed;
-    position: fixed;
-    top: 0;
-    width: 100%;
-    z-index: 10;
-}
-
-header .left {
-    position: absolute;
-    left: 0;
-    top: 50%;
-    transform: translateY(-50%);
-}
-
-header .right {
-    position: absolute;
-    right: 0;
-    top: 50%;
-    transform: translateY(-50%);
-}
-
-header a {
-    color: white !important;
-}
-
-header a:hover {
-    color: white !important;
-}
-
-header .logo {
-    height: 32px;
-    width: auto;
-}
-
-header .version {
-    font-size: 0.9em;
-    color: rgba(255, 255, 255, 0.8);
-}
-
-body {
-    /* to offset wrt sticky header */
-    padding-top: var(--header-height);
-}
-
-/* footer */
-
-footer {
-    background-color: var(--color-footer);
-    color: var(--color-text-muted);
-    font-size: 0.9em;
-}
-
-/* drawer */
-
-@media (hover: hover) {
-    .drawer-toggle:hover {
-        background-color: rgba(240, 240, 240, 0.15);
-        border-radius: 50%;
-    }
-}
-
-@media (max-width: 768px) {
-    .drawer {
-        position: fixed;
-        top: 0;
-        left: 0;
-        width: 300px;
-        height: 100%;
-        overflow-x: hidden;
-        overflow-y: auto;
-        background-color: var(--color-background);
-        transition: transform 0.4s cubic-bezier(0.4, 0, 0, 1);
-        z-index: 5;
-        padding-top: var(--header-height);
-    }
-
-    .drawer-closed {
-        transform: translateX(-100%);
-    }
-}
-
-@media (max-width: 480px) {
-    .drawer {
-        width: 100%;
-    }
-}
-
-.drawer ul {
-    list-style: none;
-    margin: 0;
-    padding: 0;
-    line-height: 100%;
-}
-
-.drawer a {
-    color: inherit;
-}
-
-.drawer a:hover {
-    color: var(--color-primary-hover) !important;
-}
-
-.drawer li.active a {
-    color: var(--color-primary);
-    position: relative;
-}
-
-/* .drawer li.active a:hover {
-    color: var(--color-primary-active) !important;
-    text-decoration: none;
-} */
-
-.drawer li.active {
-    position: relative;
-}
-
-.drawer li.active::after {
-    content: '';
-    display: inline-block;
-    position: absolute;
-    right: 0;
-    top: 50%;
-    transform: translateY(-50%);
-    width: 8px;
-    height: 8px;
-    background-color: var(--color-primary);
-    border-radius: 50%;
-}
-
-/* tables */
-
-table {
-    /* same margins that reboot gives to pre */
-    margin-top: 0;
-    margin-bottom: 1rem;
-    border-collapse: collapse;
-    /* make it scrollable if needed */
-    display: block;
-    overflow-x: auto;
-}
-
-thead {
-    background-color: var(--color-table-head);
-}
-
-th {
-    padding: 8px;
-    border: 1px solid var(--color-divider);
-    font-family: var(--font-display), sans-serif;
-    font-weight: 700;
-}
-
-td {
-    padding: 8px;
-    border: 1px solid var(--color-divider);
-}
-
-/* page and content */
-
-.content p {
-    overflow-x: auto; /* for changelog compare links */
-}
-
-.content a, footer a {
-    color: var(--color-accent);
-}
-
-.content a:hover, footer a:hover {
-    color: var(--color-accent) !important;
-}
-
-.content ul {
-    padding-left: 24px;
-}
-
-.content blockquote {
-    background-color: var(--color-code-background);
-    border-radius: var(--cards-radius);
-    border-left: 4px;
-    border-left-style: solid;
-    border-left-color: var(--color-accent);
-    font-size: 0.9em;
-    color: var(--color-text-muted);
-    padding: 0.8rem;
-    /* text-align: justify;
-    position: relative;
-    padding: 0.5rem 32px 0.5rem 0.5rem;
-    text-justify: inter-word; */
-}
-
-/* .content blockquote::after {
-    content: '!';
-    position: absolute;
-    top: 50%;
-    transform: translateY(-50%);
-    right: 16px;
-    font-size: 1rem !important;
-    font-weight: 700 !important;
-    color: var(--color-accent);
-} */
-
-.content blockquote p, .content blockquote ul {
-    margin: 0;
-}
-
-.content h1, .content h2, .content h3, .content h4, .content h5, .content h6 {
-    margin-top: 1.4rem;
-    margin-bottom: 0.8rem;
-}
-
-.page-header span {
-    font-size: 0.9em;
-    color: var(--color-text-muted);
-}
-
-.page-footer a {
-    color: black;
-    font-family: var(--font-display), cursive;
-    font-weight: 700;
-    font-size: 1.2em;
-}
-
-/* code */
-
-pre {
-    border-radius: var(--cards-radius);
-    padding: 0.8rem;
-    font-size: 0.8rem !important;
-    line-height: 1.6;
-}
-
-:not(pre) > code {
-    border-radius: var(--cards-radius);
-    padding: 2px;
-    font-weight: 700 !important;
-    font-size: 0.8rem !important;
-}
-
-.language-java, .language-xml, .language-kotlin, .language-groovy {
-    position: relative;
-}
-
-.language-java::after, .language-xml::after, .language-kotlin::after, .language-groovy::after {
-    position: absolute;
-    top: 0;
-    right: 0;
-    padding: 6px;
-    font-size: 0.65rem;
-    color: var(--color-text-muted);
-}
-
-.language-java::after {
-    content: 'java';
-}
-
-.language-xml::after {
-    content: 'xml';
-}
-
-.language-groovy::after {
-    content: 'groovy';
-}
-
-.language-kotlin::after {
-    content: 'kotlin';
-}
diff --git a/docs/css/syntax.css b/docs/css/syntax.css
deleted file mode 100644
index e41961f4..00000000
--- a/docs/css/syntax.css
+++ /dev/null
@@ -1,86 +0,0 @@
-/* https://github.com/richleland/pygments-css/ */
-@import "colors.css";
-:root {
-    --syntax-muted: #999999;
-    --syntax-annotations: #a49848;
-    --syntax-keyword: #007020;
-    --syntax-operators: #606060;
-    --syntax-numbers: var(--syntax-keyword);
-    --syntax-xml-tags: var(--syntax-keyword);
-}
-
-.highlight .c { color: var(--syntax-muted); font-style: italic } /* Comment */
-.highlight .ch { color: var(--syntax-muted); font-style: italic } /* Comment.Hashbang */
-.highlight .cm { color: var(--syntax-muted); font-style: italic } /* Comment.Multiline */
-.highlight .cp { color: var(--syntax-muted); } /* Comment.Preproc */
-.highlight .cpf { color: var(--syntax-muted); font-style: italic } /* Comment.PreprocFile */
-.highlight .c1 { color: var(--syntax-muted); font-style: italic } /* Comment.Single */
-.highlight .cs { color: var(--syntax-muted); background-color: #fff0f0 } /* Comment.Special */
-
-.highlight .nt { color: var(--syntax-xml-tags); font-weight: bold } /* Name.Tag */
-.highlight .na { color: inherit; /* var(--color-accent) */ } /* Name.Attribute */
-.highlight .nf { color: inherit; /* var(--color-accent) */ } /* Name.Function */
-
-.highlight .mb { color: var(--syntax-numbers) } /* Literal.Number.Bin */
-.highlight .mf { color: var(--syntax-numbers) } /* Literal.Number.Float */
-.highlight .mh { color: var(--syntax-numbers) } /* Literal.Number.Hex */
-.highlight .mi { color: var(--syntax-numbers) } /* Literal.Number.Integer */
-.highlight .mo { color: var(--syntax-numbers) } /* Literal.Number.Oct */
-
-.highlight .nd { color: var(--syntax-annotations); } /* Name.Decorator */
-
-.highlight .k { color: var(--syntax-keyword); font-weight: bold } /* Keyword */
-.highlight .kd { color: var(--syntax-keyword); font-weight: bold } /* Keyword.Declaration */
-.highlight .kt { color: var(--syntax-keyword); font-weight: bold } /* Keyword.Type */
-.highlight .kc { color: var(--syntax-keyword); font-weight: bold } /* Keyword.Constant */
-.highlight .kn { color: var(--syntax-keyword); font-weight: bold } /* Keyword.Namespace */
-.highlight .kp { color: var(--syntax-keyword); font-weight: bold } /* Keyword.Pseudo */
-.highlight .kr { color: var(--syntax-keyword); font-weight: bold } /* Keyword.Reserved */
-
-.highlight .s { color: var(--color-accent-dark); background-color: var(--color-accent-light); } /* Literal.String */
-.highlight .s1 { color: var(--color-accent-dark); background-color: var(--color-accent-light); } /* Literal.String.Single */
-.highlight .sa { color: var(--color-accent-dark) } /* Literal.String.Affix */
-.highlight .sb { color: var(--color-accent-dark) } /* Literal.String.Backtick */
-.highlight .sc { color: var(--color-accent-dark) } /* Literal.String.Char */
-.highlight .dl { color: var(--color-accent-dark) } /* Literal.String.Delimiter */
-.highlight .sd { color: var(--color-accent-dark); font-style: italic } /* Literal.String.Doc */
-.highlight .s2 { color: var(--color-accent-dark) } /* Literal.String.Double */
-.highlight .se { color: var(--color-accent-dark); font-weight: bold } /* Literal.String.Escape */
-.highlight .sh { color: var(--color-accent-dark) } /* Literal.String.Heredoc */
-.highlight .si { color: var(--color-accent-dark); font-style: italic } /* Literal.String.Interpol */
-.highlight .sx { color: var(--color-accent-dark) } /* Literal.String.Other */
-.highlight .sr { color: var(--color-accent-dark) } /* Literal.String.Regex */
-.highlight .ss { color: var(--color-accent-dark) } /* Literal.String.Symbol */
-
-.highlight .o { color: var(--syntax-operators) } /* Operator */
-
-.highlight .hll { background-color: #ffffcc }
-.highlight .err { border: 1px solid #FF0000 } /* Error */
-.highlight .gd { color: #A00000 } /* Generic.Deleted */
-.highlight .ge { font-style: italic } /* Generic.Emph */
-.highlight .gr { color: #FF0000 } /* Generic.Error */
-.highlight .gh { color: #000080; font-weight: bold } /* Generic.Heading */
-.highlight .gi { color: #00A000 } /* Generic.Inserted */
-.highlight .go { color: #888888 } /* Generic.Output */
-.highlight .gp { color: #c65d09; font-weight: bold } /* Generic.Prompt */
-.highlight .gs { font-weight: bold } /* Generic.Strong */
-.highlight .gu { color: #800080; font-weight: bold } /* Generic.Subheading */
-.highlight .gt { color: #0044DD } /* Generic.Traceback */
-.highlight .m { color: #40a070 } /* Literal.Number */
-.highlight .nb { color: #007020 } /* Name.Builtin */
-.highlight .nc { color: #0e84b5; font-weight: bold } /* Name.Class */
-.highlight .no { color: #60add5 } /* Name.Constant */
-.highlight .ni { color: #d55537; font-weight: bold } /* Name.Entity */
-.highlight .ne { color: #007020 } /* Name.Exception */
-.highlight .nl { color: #002070; font-weight: bold } /* Name.Label */
-.highlight .nn { color: #0e84b5; font-weight: bold } /* Name.Namespace */
-.highlight .nv { color: #bb60d5 } /* Name.Variable */
-.highlight .ow { color: #007020; font-weight: bold } /* Operator.Word */
-.highlight .w { color: #bbbbbb } /* Text.Whitespace */
-.highlight .bp { color: #007020 } /* Name.Builtin.Pseudo */
-.highlight .fm { color: #06287e } /* Name.Function.Magic */
-.highlight .vc { color: #bb60d5 } /* Name.Variable.Class */
-.highlight .vg { color: #bb60d5 } /* Name.Variable.Global */
-.highlight .vi { color: #bb60d5 } /* Name.Variable.Instance */
-.highlight .vm { color: #bb60d5 } /* Name.Variable.Magic */
-.highlight .il { color: #40a070 } /* Literal.Number.Integer.Long */
\ No newline at end of file
diff --git a/docs/_docs/data-sources.md b/docs/data-sources.mdx
similarity index 50%
rename from docs/_docs/data-sources.md
rename to docs/data-sources.mdx
index 0c31d840..a32b6146 100644
--- a/docs/_docs/data-sources.md
+++ b/docs/data-sources.mdx
@@ -1,48 +1,55 @@
 ---
-layout: page
-title: "Data Sources"
-description: "Sources of media data"
-order: 1
-disqus: 1
+title: Data Sources
 ---
 
+# Data Sources
+
 Starting a transcoding operation will require a source for our data, which is not necessarily
 a `File`. The `DataSource` objects will automatically take care about releasing streams / resources,
 which is convenient but it means that they can not be used twice.
 
-```java
+```kotlin
 Transcoder.into(filePath)
-        .addDataSource(source1)
-        .transcode()
+    .addDataSource(source1)
+    .transcode()
 ```
 
-##### UriDataSource
+## Source Types
 
-The Android friendly source can be created with `new UriDataSource(context, uri)` or simply
+#### UriDataSource
+
+The Android friendly source can be created with `UriDataSource(context, uri)` or simply
 using `addDataSource(context, uri)` in the transcoding builder.
 
-##### FileDescriptorDataSource
+#### FileDescriptorDataSource
 
-A data source backed by a file descriptor. Use `new FileDescriptorDataSource(descriptor)` or
-simply `addDataSource(descriptor)` in the transcoding builder.
+A data source backed by a file descriptor. Use `FileDescriptorDataSource(descriptor)` or
+simply `addDataSource(descriptor)` in the transcoding builder. Note that it is the caller
+responsibility to close the file descriptor.
 
-##### FilePathDataSource
+#### FilePathDataSource
 
-A data source backed by a file absolute path. Use `new FilePathDataSource(path)` or
+A data source backed by a file absolute path. Use `FilePathDataSource(path)` or
 simply `addDataSource(path)` in the transcoding builder.
 
-### Track specific sources
+#### AssetFileDescriptorDataSource
+
+A data source backed by Android's AssetFileDescriptor. Use `AssetFileDescriptorDataSource(descriptor)`
+or simply `addDataSource(descriptor)` in the transcoding builder. Note that it is the caller
+responsibility to close the file descriptor.
+
+## Track specific sources
 
 Although a media source can have both audio and video, you can select a specific track
 for transcoding and exclude the other(s). For example, to select the video track only:
- 
+
 ```java
 Transcoder.into(filePath)
-        .addDataSource(TrackType.VIDEO, source)
-        .transcode()
+    .addDataSource(TrackType.VIDEO, source)
+    .transcode()
 ```
- 
-### Related APIs
+
+## Related APIs
 
 |Method|Description|
 |------|-----------|
@@ -50,5 +57,4 @@ Transcoder.into(filePath)
 |`addDataSource(FileDescriptor)`|Adds a new source for the given FileDescriptor.|
 |`addDataSource(String)`|Adds a new source for the given file path.|
 |`addDataSource(DataSource)`|Adds a new source.|
-|`addDataSource(TrackType, DataSource)`|Adds a new source restricted to the given TrackType.|
-
+|`addDataSource(TrackType, DataSource)`|Adds a new source restricted to the given TrackType.|
\ No newline at end of file
diff --git a/docs/_docs/events.md b/docs/events.mdx
similarity index 69%
rename from docs/_docs/events.md
rename to docs/events.mdx
index a2f79b9f..995a7715 100644
--- a/docs/_docs/events.md
+++ b/docs/events.mdx
@@ -1,25 +1,22 @@
 ---
-layout: page
-title: "Transcoding Events"
-description: "Listening to transcoding events"
-order: 4
-disqus: 1
+title: Events
 ---
 
+# Events
 
 Transcoding will happen on a background thread, but we will send updates through the `TranscoderListener`
 interface, which can be applied when building the request:
 
-```java
+```kotlin
 Transcoder.into(filePath)
-        .setListenerHandler(handler)
-        .setListener(new TranscoderListener() {
-             public void onTranscodeProgress(double progress) {}
-             public void onTranscodeCompleted(int successCode) {}
-             public void onTranscodeCanceled() {}
-             public void onTranscodeFailed(@NonNull Throwable exception) {}
-        })
-        // ...
+    .setListenerHandler(handler)
+    .setListener(object: TranscoderListener {
+         override fun onTranscodeProgress(progress: Double) = Unit
+         override fun onTranscodeCompleted(successCode: Int) = Unit
+         override fun onTranscodeCanceled() = Unit
+         override fun onTranscodeFailed(exception: Throwable) = Unit
+    })
+    // ...
 ```
 
 All of the listener callbacks are called:
@@ -28,19 +25,19 @@ All of the listener callbacks are called:
 - If it has a handler, on the thread that started the `transcode()` call
 - As a last resort, on the UI thread
 
-##### onTranscodeProgress
+### onTranscodeProgress
 
 This simply sends a double indicating the current progress. The value is typically between 0 and 1,
 but can be a negative value to indicate that we are not able to compute progress (yet?).
 
 This is the right place to update a ProgressBar, for example.
 
-##### onTranscodeCanceled
+### onTranscodeCanceled
 
 The transcoding operation was canceled. This can happen when the `Future` returned by `transcode()`
 is cancelled by the user.
 
-##### onTranscodeFailed
+### onTranscodeFailed
 
 This can happen in a number of cases and is typically out of our control. Input options might be
 wrong, write permissions might be missing, codec might be absent, input file might be not supported
@@ -48,7 +45,7 @@ or simply corrupted.
 
 You can take a look at the `Throwable` being passed to know more about the exception.
 
-##### onTranscodeCompleted
+### onTranscodeCompleted
 
 Transcoding operation did succeed. The success code can be:
 
@@ -56,6 +53,3 @@ Transcoding operation did succeed. The success code can be:
 |----|-------|
 |`Transcoder.SUCCESS_TRANSCODED`|Transcoding was executed successfully. Transcoded file was written to the output path.|
 |`Transcoder.SUCCESS_NOT_NEEDED`|Transcoding was not executed because it was considered **not needed** by the `Validator`.|
-
-[Keep reading](validators) to know about `Validator`s.
-
diff --git a/docs/home.md b/docs/home.md
deleted file mode 100644
index d5ad2786..00000000
--- a/docs/home.md
+++ /dev/null
@@ -1,49 +0,0 @@
----
-layout: main
-title: "Transcoder"
----
-
-# Transcoder
-
-Transcoder is a well documented Android library providing hardware-accelerated video transcoding, 
-using MediaCodec APIs instead of native code (no FFMPEG patent issues). 
-
-<p align="center">
-  <img src="static/banner.png" vspace="10" width="100%">
-</p>
-
-- Fast transcoding to AAC/AVC
-- Hardware accelerated
-- Multithreaded
-- Convenient, fluent API
-- Concatenate multiple video and audio tracks [[docs]](docs/concatenation)
-- Clip or trim video segments [[docs]](docs/clipping)
-- Choose output size, with automatic cropping [[docs]](docs/track-strategies#video-size)
-- Choose output rotation [[docs]](docs/advanced-options#video-rotation) 
-- Choose output speed [[docs]](docs/advanced-options#video-speed)
-- Choose output frame rate [[docs]](docs/track-strategies#other-options)
-- Choose output audio channels [[docs]](docs/track-strategies#audio-strategies)
-- Choose output audio sample rate [[docs]](docs/track-strategies#audio-strategies)
-- Override frames timestamp, e.g. to slow down the middle part of the video [[docs]](docs/advanced-options#time-interpolation) 
-- Error handling [[docs]](docs/events)
-- Configurable validators to e.g. avoid transcoding if the source is already compressed enough [[docs]](docs/validators)
-- Configurable video and audio strategies [[docs]](docs/track-strategies)
-
-### Get started
-
-Get started with [install info](about/install), [quick setup](about/getting-started), or
-start reading the in-depth [documentation](docs/data-sources).
-
-### Notes
-
-This project started as a fork of [ypresto/android-transcoder](https://github.com/ypresto/android-transcoder).
-With respect to the source project, which misses most of the functionality listed above, 
-we have also fixed a huge number of bugs and are much less conservative when choosing options 
-that might not be supported. The source project will always throw - for example, accepting only 16:9,
-AVC Baseline Profile videos - we prefer to try and let the codec fail if it wants to.
-
-### Support
-
-If you like the project, use it with profit, and want to thank back, please consider [donating or
-becoming a supporter](extra/donate).
-
diff --git a/docs/icons/github.svg b/docs/icons/github.svg
deleted file mode 100644
index c8a122a7..00000000
--- a/docs/icons/github.svg
+++ /dev/null
@@ -1,10 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg"
-     width="24"
-     height="24"
-     viewBox="0 0 512 499.36">
-    <path
-            fill="#fff"
-            fill-rule="evenodd"
-            fill-opacity="0.8"
-            d="M256 0C114.64 0 0 114.61 0 256c0 113.09 73.34 209 175.08 242.9 12.8 2.35 17.47-5.56 17.47-12.34 0-6.08-.22-22.18-.35-43.54-71.2 15.49-86.2-34.34-86.2-34.34-11.64-29.57-28.42-37.45-28.42-37.45-23.27-15.84 1.73-15.55 1.73-15.55 25.69 1.81 39.21 26.38 39.21 26.38 22.84 39.12 59.92 27.82 74.5 21.27 2.33-16.54 8.94-27.82 16.25-34.22-56.84-6.43-116.6-28.43-116.6-126.49 0-27.95 10-50.8 26.35-68.69-2.63-6.48-11.42-32.5 2.51-67.75 0 0 21.49-6.88 70.4 26.24a242.65 242.65 0 0 1 128.18 0c48.87-33.13 70.33-26.24 70.33-26.24 14 35.25 5.18 61.27 2.55 67.75 16.41 17.9 26.31 40.75 26.31 68.69 0 98.35-59.85 120-116.88 126.32 9.19 7.9 17.38 23.53 17.38 47.41 0 34.22-.31 61.83-.31 70.23 0 6.85 4.61 14.81 17.6 12.31C438.72 464.97 512 369.08 512 256.02 512 114.62 397.37 0 256 0z"/>
-</svg>
\ No newline at end of file
diff --git a/docs/icons/menu.svg b/docs/icons/menu.svg
deleted file mode 100644
index 45918677..00000000
--- a/docs/icons/menu.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg"
-     width="24"
-     height="24"
-     viewBox="0 0 24 24">
-    <path fill="none" d="M0 0h24v24H0V0z"/>
-    <path fill="#fff" fill-opacity="0.8" d="M4 18h16c.55 0 1-.45 1-1s-.45-1-1-1H4c-.55 0-1 .45-1 1s.45 1 1 1zm0-5h16c.55 0 1-.45 1-1s-.45-1-1-1H4c-.55 0-1 .45-1 1s.45 1 1 1zM3 7c0 .55.45 1 1 1h16c.55 0 1-.45 1-1s-.45-1-1-1H4c-.55 0-1 .45-1 1z"/>
-</svg>
\ No newline at end of file
diff --git a/docs/index.md b/docs/index.md
deleted file mode 100644
index ecfd1b98..00000000
--- a/docs/index.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-layout: landing
-title: "Transcoder"
----
-
-Accelerated video transcoding on Android using MediaCodec APIs, without native code (no FFMPEG patent issues). Supports cropping to any dimension, concatenation, audio processing and much more.
\ No newline at end of file
diff --git a/docs/index.mdx b/docs/index.mdx
new file mode 100644
index 00000000..fe762288
--- /dev/null
+++ b/docs/index.mdx
@@ -0,0 +1,81 @@
+---
+title: Intro
+docs:
+ - install
+ - changelog
+ - data-sources
+ - clipping
+ - concatenation
+ - events
+ - validators
+ - track-strategies
+ - advanced-options
+---
+
+# Intro
+
+The Transcoder library transcodes and compresses video files into the MP4 format, with audio support, using hardware-accelerated
+Android codecs available on the device. Works on API 19+ and supports the following set of features:
+
+- Fast transcoding to AAC/AVC
+- Hardware accelerated
+- Convenient, fluent API
+- Thumbnails support
+- [Concatenate](concatenation) multiple video and audio tracks
+- [Clip or trim](clipping) video segments
+- Configure [output size](track-strategies#video-size), with automatic cropping
+- Configure [output rotation](advanced-options#video-rotation)
+- Configure [output speed](advanced-options#video-speed)
+- Configure [output frame rate](track-strategies#other-options)
+- Configure [output audio channels](track-strategies#audio-strategies) and sample rate
+- [Override timestamp](advanced-options#time-interpolation) of frames, for example to slow down parts of the video
+- [Error handling](events)
+- Configurable [validators](validators) to e.g. avoid transcoding if the source is already compressed enough
+- Configurable video and audio [strategies](track-strategies)
+
+> This project started as a fork of [ypresto/android-transcoder](https://github.com/ypresto/android-transcoder).
+With respect to the source project, which misses most of the functionality listed above,
+we have also fixed a huge number of bugs and are much less conservative when choosing options
+that might not be supported. The source project will always throw - for example, accepting only 16:9,
+AVC Baseline Profile videos - we prefer to try and let the codec fail if it wants to.
+
+## Minimal example
+
+```kotlin
+Transcoder.into(filePath)
+    .addDataSource(context, uri) // or...
+    .addDataSource(filePath) // or...
+    .addDataSource(fileDescriptor) // or...
+    .addDataSource(dataSource)
+    .setListener(object : TranscoderListener {
+         override fun onTranscodeProgress(progress: Double) = Unit
+         override fun onTranscodeCompleted(successCode: Int) = Unit
+         override fun onTranscodeCanceled() = Unit
+         override fun onTranscodeFailed(exception: Throwable) = Unit
+    }).transcode()
+```
+
+Please keep reading the documentation to learn about [install instructions](install), configuration options and APIs.
+
+
+## License
+
+This project is licensed under Apache 2.0. It consists of improvements over
+the [ypresto/android-transcoder](https://github.com/ypresto/android-transcoder)
+project which was licensed under Apache 2.0 as well:
+
+```
+Copyright (C) 2014-2016 Yuya Tanaka
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+```
\ No newline at end of file
diff --git a/docs/install.mdx b/docs/install.mdx
new file mode 100644
index 00000000..3086658d
--- /dev/null
+++ b/docs/install.mdx
@@ -0,0 +1,43 @@
+---
+title: Install
+---
+
+# Installation
+
+Transcoder is publicly hosted on the [Maven Central](https://repo1.maven.org/maven2/io/deepmedia/community/)
+repository, where you can download the AAR package. To fetch with Gradle, assuming that `mavenCentral()` is already
+one of your repository sources, simply declare a new dependency:
+
+```kotlin
+dependencies {
+    api("io.deepmedia.community:transcoder-android:LATEST_VERSION")
+
+    // Or use the legacy coordinates:
+    // api("com.otaliastudios:transcoder:LATEST_VERSION")
+}
+```
+
+Replace `LATEST_VERSION` with the latest version number, {version}.
+
+## Snapshots
+
+We regularly push development snapshots of the library at `https://s01.oss.sonatype.org/content/repositories/snapshots/`
+on each push to main. To use snapshots, add the url as a maven repository and depend on `latest-SNAPSHOT`:
+
+```kotlin
+// settings.gradle.kts
+pluginManagement {
+    repositories {
+        gradlePluginPortal()
+        maven(url = "https://s01.oss.sonatype.org/content/repositories/snapshots/")
+    }
+}
+
+// build.gradle.kts
+dependencies {
+    api("io.deepmedia.community:transcoder-android:latest-SNAPSHOT")
+
+    // Or use the legacy coordinates:
+    // api("com.otaliastudios:transcoder:latest-SNAPSHOT")
+}
+```
\ No newline at end of file
diff --git a/docs/script/launch b/docs/script/launch
deleted file mode 100755
index 080bfe6c..00000000
--- a/docs/script/launch
+++ /dev/null
@@ -1,4 +0,0 @@
-#!/usr/bin/env bash
-#
-# Run a local instance of the site.
-bundle exec jekyll serve
diff --git a/docs/static/banner.png b/docs/static/banner.png
deleted file mode 100644
index 017a4bc1..00000000
Binary files a/docs/static/banner.png and /dev/null differ
diff --git a/docs/static/icon_foreground.png b/docs/static/icon_foreground.png
deleted file mode 100644
index a5b60424..00000000
Binary files a/docs/static/icon_foreground.png and /dev/null differ
diff --git a/docs/static/screenshot-1.png b/docs/static/screenshot-1.png
deleted file mode 100644
index fd47e91f..00000000
Binary files a/docs/static/screenshot-1.png and /dev/null differ
diff --git a/docs/static/screenshot-2.png b/docs/static/screenshot-2.png
deleted file mode 100644
index d43c4f47..00000000
Binary files a/docs/static/screenshot-2.png and /dev/null differ
diff --git a/docs/static/sharechat.png b/docs/static/sharechat.png
deleted file mode 100644
index eaf40c5d..00000000
Binary files a/docs/static/sharechat.png and /dev/null differ
diff --git a/docs/_docs/track-strategies.md b/docs/track-strategies.mdx
similarity index 61%
rename from docs/_docs/track-strategies.md
rename to docs/track-strategies.mdx
index b78fbeb0..d9e24716 100644
--- a/docs/_docs/track-strategies.md
+++ b/docs/track-strategies.mdx
@@ -1,95 +1,92 @@
 ---
-layout: page
-title: "Track Strategies"
-description: "Per-track transcoding options"
-order: 6
-disqus: 1
+title: Track Strategies
 ---
 
+# Track Strategies
 
 Track strategies return options for each track (audio or video) for the engine to understand **how**
 and **if** this track should be transcoded, and whether the whole process should be aborted.
 
-```java
+```kotlin
 Transcoder.into(filePath)
-        .setVideoTrackStrategy(videoStrategy)
-        .setAudioTrackStrategy(audioStrategy)
-        // ...
+    .setVideoTrackStrategy(videoStrategy)
+    .setAudioTrackStrategy(audioStrategy)
+    // ...
 ```
 
 The point of `TrackStrategy` is to inspect the input `android.media.MediaFormat` and return
 the output `android.media.MediaFormat`, filled with required options.
 
+## Basic Strategies
+
 This library offers track specific strategies that help with audio and video options (see
 [Audio Strategies](#audio-strategies) and [Video Strategies](#video-strategies)).
 In addition, we have a few built-in strategies that can work for both audio and video:
 
-##### PassThroughTrackStrategy
+### PassThroughTrackStrategy
 
 A TrackStrategy that asks the encoder to keep this track as is, by returning the same input
 format. Note that this is risky, as the input track format might not be supported my the MP4 container.
 
 This will set the `TrackStatus` to `TrackStatus.PASS_THROUGH`.
 
-##### RemoveTrackStrategy
+### RemoveTrackStrategy
 
 A TrackStrategy that asks the encoder to remove this track from the output container, by returning null.
 For instance, this can be used as an audio strategy to remove audio from video/audio streams.
 
 This will set the `TrackStatus` to `TrackStatus.REMOVING`.
 
-### Audio Strategies
+## Audio Strategies
 
 The default internal strategy for audio is a `DefaultAudioStrategy`, which converts the
 audio stream to AAC format with the specified number of channels and [sample rate](advanced-options).
 
-```java
-DefaultAudioStrategy strategy = DefaultAudioStrategy.builder()
-        .channels(DefaultAudioStrategy.CHANNELS_AS_INPUT)
-        .channels(1)
-        .channels(2)
-        .sampleRate(DefaultAudioStrategy.SAMPLE_RATE_AS_INPUT)
-        .sampleRate(44100)
-        .sampleRate(30000)
-        .bitRate(DefaultAudioStrategy.BITRATE_UNKNOWN)
-        .bitRate(bitRate)
-        .build();
+```kotlin
+val strategy: DefaultAudioStrategy = DefaultAudioStrategy.builder()
+    .channels(DefaultAudioStrategy.CHANNELS_AS_INPUT)
+    .channels(1)
+    .channels(2)
+    .sampleRate(DefaultAudioStrategy.SAMPLE_RATE_AS_INPUT)
+    .sampleRate(44100)
+    .sampleRate(30000)
+    .bitRate(DefaultAudioStrategy.BITRATE_UNKNOWN)
+    .bitRate(bitRate)
+    .build()
 
 Transcoder.into(filePath)
-        .setAudioTrackStrategy(strategy)
-        // ...
+    .setAudioTrackStrategy(strategy)
+    // ...
 ```
 
 Take a look at the source code to understand how to manage the `android.media.MediaFormat` object.
 
-### Video Strategies
+## Video Strategies
 
 The default internal strategy for video is a `DefaultVideoStrategy`, which converts the
 video stream to AVC format and is very configurable. The class helps in defining an output size.
 If the output size does not match the aspect ratio of the input stream size, `Transcoder` will
 crop part of the input so it matches the final ratio.
 
-##### Video Size
+### Video Size
 
 We provide helpers for common tasks:
 
-```java
-DefaultVideoStrategy strategy;
-
+```kotlin
 // Sets an exact size. If aspect ratio does not match, cropping will take place.
-strategy = DefaultVideoStrategy.exact(1080, 720).build();
+val strategy = DefaultVideoStrategy.exact(1080, 720).build()
 
 // Keeps the aspect ratio, but scales down the input size with the given fraction.
-strategy = DefaultVideoStrategy.fraction(0.5F).build();
+val strategy = DefaultVideoStrategy.fraction(0.5F).build()
 
 // Ensures that each video size is at most the given value - scales down otherwise.
-strategy = DefaultVideoStrategy.atMost(1000).build();
+val strategy = DefaultVideoStrategy.atMost(1000).build()
 
 // Ensures that minor and major dimension are at most the given values - scales down otherwise.
-strategy = DefaultVideoStrategy.atMost(500, 1000).build();
+val strategy = DefaultVideoStrategy.atMost(500, 1000).build()
 ```
 
-In fact, all of these will simply call `new DefaultVideoStrategy.Builder(resizer)` with a special
+In fact, all of these will simply call `DefaultVideoStrategy.Builder(resizer)` with a special
 resizer. We offer handy resizers:
 
 |Name|Description|
@@ -102,39 +99,39 @@ resizer. We offer handy resizers:
 
 You can also group resizers through `MultiResizer`, which applies resizers in chain:
 
-```java
+```kotlin
 // First scales down, then ensures size is at most 1000. Order matters!
-Resizer resizer = new MultiResizer();
-resizer.addResizer(new FractionResizer(0.5F));
-resizer.addResizer(new AtMostResizer(1000));
+val resizer: Resizer = MultiResizer()
+resizer.addResizer(FractionResizer(0.5F))
+resizer.addResizer(AtMostResizer(1000))
 
 // First makes it 16:9, then ensures size is at most 1000. Order matters!
-Resizer resizer = new MultiResizer();
-resizer.addResizer(new AspectRatioResizer(16F / 9F));
-resizer.addResizer(new AtMostResizer(1000));
+val resizer: Resizer = MultiResizer()
+resizer.addResizer(AspectRatioResizer(16F / 9F))
+resizer.addResizer(AtMostResizer(1000))
 ```
 
 This option is already available through the DefaultVideoStrategy builder, so you can do:
 
-```java
-DefaultVideoStrategy strategy = new DefaultVideoStrategy.Builder()
-        .addResizer(new AspectRatioResizer(16F / 9F))
-        .addResizer(new FractionResizer(0.5F))
-        .addResizer(new AtMostResizer(1000))
-        .build();
+```kotlin
+val strategy: DefaultVideoStrategy = DefaultVideoStrategy.Builder()
+    .addResizer(AspectRatioResizer(16F / 9F))
+    .addResizer(FractionResizer(0.5F))
+    .addResizer(AtMostResizer(1000))
+    .build()
 ```
 
-##### Other options
+### Other options
 
 You can configure the `DefaultVideoStrategy` with other options unrelated to the video size:
 
-```java
-DefaultVideoStrategy strategy = new DefaultVideoStrategy.Builder()
-        .bitRate(bitRate)
-        .bitRate(DefaultVideoStrategy.BITRATE_UNKNOWN) // tries to estimate
-        .frameRate(frameRate) // will be capped to the input frameRate
-        .keyFrameInterval(interval) // interval between key-frames in seconds
-        .build();
+```kotlin
+val strategy: DefaultVideoStrategy = DefaultVideoStrategy.Builder()
+    .bitRate(bitRate)
+    .bitRate(DefaultVideoStrategy.BITRATE_UNKNOWN) // tries to estimate
+    .frameRate(frameRate) // will be capped to the input frameRate
+    .keyFrameInterval(interval) // interval between key-frames in seconds
+    .build()
 ```
 
 ### Compatibility
@@ -143,14 +140,14 @@ As stated pretty much everywhere, **not all codecs/devices/manufacturers support
 This is a complex issue which is especially important for video strategies, as a wrong size can lead
 to a transcoding error or corrupted file.
 
-Android platform specifies requirements for manufacturers through the [CTS (Compatibility test suite)](https://source.android.com/compatibility/cts).
+Android platform specifies requirements for manufacturers through the [Compatibility test suite](https://source.android.com/compatibility/cts) (CTS).
 Only a few codecs and sizes are **strictly** required to work.
 
 We collect common presets in the `DefaultVideoStrategies` class:
 
-```java
+```kotlin
 Transcoder.into(filePath)
-        .setVideoTrackStrategy(DefaultVideoStrategies.for720x1280()) // 16:9
-        .setVideoTrackStrategy(DefaultVideoStrategies.for360x480()) // 4:3
-        // ...
+    .setVideoTrackStrategy(DefaultVideoStrategies.for720x1280()) // 16:9
+    .setVideoTrackStrategy(DefaultVideoStrategies.for360x480()) // 4:3
+    // ...
 ```
\ No newline at end of file
diff --git a/docs/_docs/validators.md b/docs/validators.mdx
similarity index 88%
rename from docs/_docs/validators.md
rename to docs/validators.mdx
index f28eb3a0..0ee78c4d 100644
--- a/docs/_docs/validators.md
+++ b/docs/validators.mdx
@@ -1,18 +1,16 @@
 ---
-layout: page
-title: "Validators"
-description: "Validate or abort the transcoding process"
-order: 5
-disqus: 1
+title: Validators
 ---
 
+# Validators
+
 Validators tell the engine whether the transcoding process should start or not based on the status
 of the audio and video track.
 
-```java
+```kotlin
 Transcoder.into(filePath)
-        .setValidator(validator)
-        // ...
+    .setValidator(validator)
+    // ...
 ```
 
 This can be used, for example, to:
@@ -35,19 +33,18 @@ The TrackStatus enum contains the following values:
 The `TrackStatus` value depends on the [track strategy](track-strategies) that was used.
 We provide a few validators that can be injected for typical usage.
 
-##### DefaultValidator
+### DefaultValidator
 
 This is the default validator and it returns true when any of the track is `COMPRESSING` or `REMOVING`.
 In the other cases, transcoding is typically not needed so we abort the operation.
 
-##### WriteAlwaysValidator
+### WriteAlwaysValidator
 
 This validator always returns true and as such will always write to target file, no matter the track status,
 presence of tracks and so on. For instance, the output container file might have no tracks.
 
-##### WriteVideoValidator
+### WriteVideoValidator
 
 A Validator that gives priority to the video track. Transcoding will not happen if the video track does not need it,
 even if the audio track might need it. If reducing file size is your only concern, this can avoid compressing
-files that would not benefit so much from compressing the audio track only.
-
+files that would not benefit so much from compressing the audio track only.
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
index a1e45dd8..4cd7d5bf 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,21 +1,6 @@
-# Project-wide Gradle settings.
 
-# IDE (e.g. Android Studio) users:
-# Settings specified in this file will override any Gradle settings
-# configured through the IDE.
-
-# For more details on how to configure your build environment visit
-# http://www.gradle.org/docs/current/userguide/build_environment.html
-
-# Specifies the JVM arguments used for the daemon process.
-# The setting is particularly useful for tweaking memory settings.
-# Default value: -Xmx10248m -XX:MaxPermSize=256m
-# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
-
-# When configured, Gradle will run in incubating parallel mode.
-# This option should only be used with decoupled projects. More details, visit
-# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
-# org.gradle.parallel=true
-
-android.enableJetifier=true
-android.useAndroidX=true
\ No newline at end of file
+android.useAndroidX=true
+org.gradle.caching=true
+org.gradle.caching.debug=false
+org.gradle.parallel=true
+org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 64621343..254dc618 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
 zipStoreBase=GRADLE_USER_HOME
 zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip
diff --git a/lib-legacy/.gitignore b/lib-legacy/.gitignore
new file mode 100644
index 00000000..796b96d1
--- /dev/null
+++ b/lib-legacy/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/lib-legacy/build.gradle.kts b/lib-legacy/build.gradle.kts
new file mode 100644
index 00000000..e7b9a926
--- /dev/null
+++ b/lib-legacy/build.gradle.kts
@@ -0,0 +1,77 @@
+import io.deepmedia.tools.deployer.model.Secret
+
+plugins {
+    id("com.android.library")
+    id("io.deepmedia.tools.deployer")
+}
+
+android {
+    namespace = "com.otaliastudios.transcoder"
+    compileSdk = 34
+    defaultConfig.minSdk = 21
+    publishing { singleVariant("release") }
+}
+
+dependencies {
+    api(project(":lib"))
+}
+
+deployer {
+    content {
+        component {
+            fromSoftwareComponent("release")
+            emptyDocs()
+            emptySources()
+        }
+    }
+
+    projectInfo {
+        groupId = "com.otaliastudios"
+        artifactId = "transcoder"
+        release.version = "0.11.2" // change :lib and README
+        description = "Accelerated video compression and transcoding on Android using MediaCodec APIs (no FFMPEG/LGPL licensing issues). Supports cropping to any dimension, concatenation, audio processing and much more."
+        url = "https://opensource.deepmedia.io/transcoder"
+        scm.fromGithub("deepmedia", "Transcoder")
+        license(apache2)
+        developer("Mattia Iavarone", "mattia@deepmedia.io", "DeepMedia", "https://deepmedia.io")
+    }
+
+    signing {
+        key = secret("SIGNING_KEY")
+        password = secret("SIGNING_PASSWORD")
+    }
+
+    // use "deployLocal" to deploy to local maven repository
+    localSpec {
+        directory.set(rootProject.layout.buildDirectory.get().dir("inspect"))
+        signing {
+            key = absent()
+            password = absent()
+        }
+    }
+
+    // use "deployNexus" to deploy to OSSRH / maven central
+    nexusSpec {
+        auth.user = secret("SONATYPE_USER")
+        auth.password = secret("SONATYPE_PASSWORD")
+        syncToMavenCentral = true
+    }
+
+    // use "deployNexusSnapshot" to deploy to sonatype snapshots repo
+    nexusSpec("snapshot") {
+        auth.user = secret("SONATYPE_USER")
+        auth.password = secret("SONATYPE_PASSWORD")
+        repositoryUrl = ossrhSnapshots1
+        release.version = "latest-SNAPSHOT"
+    }
+
+    // use "deployGithub" to deploy to github packages
+    githubSpec {
+        repository = "Transcoder"
+        owner = "deepmedia"
+        auth {
+            user = secret("GHUB_USER")
+            token = secret("GHUB_PERSONAL_ACCESS_TOKEN")
+        }
+    }
+}
diff --git a/lib/build.gradle b/lib/build.gradle
deleted file mode 100644
index 20e9f665..00000000
--- a/lib/build.gradle
+++ /dev/null
@@ -1,134 +0,0 @@
-apply plugin: 'com.android.library'
-apply plugin: 'com.github.dcendents.android-maven'
-apply plugin: 'com.jfrog.bintray'
-
-// Required by bintray
-version = '0.8.0'
-group = 'com.otaliastudios'
-archivesBaseName = 'transcoder'
-
-android {
-    compileSdkVersion rootProject.ext.compileSdkVersion
-
-    defaultConfig {
-        minSdkVersion rootProject.ext.minSdkVersion
-        targetSdkVersion rootProject.ext.targetSdkVersion
-        versionCode 1
-        versionName project.version
-        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
-
-    }
-
-    buildTypes {
-        release {
-            minifyEnabled false
-            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
-        }
-    }
-}
-
-dependencies {
-    androidTestImplementation 'androidx.test:runner:1.2.0'
-    androidTestImplementation 'androidx.test:rules:1.2.0'
-    androidTestImplementation 'androidx.test.ext:junit:1.1.1'
-    androidTestImplementation 'org.mockito:mockito-android:2.28.2'
-
-    api 'com.otaliastudios.opengl:egloo:0.4.0'
-    api "androidx.annotation:annotation:1.1.0"
-}
-
-install {
-    repositories.mavenInstaller {
-        pom.project {
-            name 'Transcoder'
-            description 'Accelerated video transcoding using Android MediaCodec API without native code (no LGPL/patent issues)'
-            url 'https://github.com/natario1/Transcoder'
-
-            packaging 'aar'
-            groupId project.group
-            artifactId 'transcoder'
-            version project.version
-
-            licenses {
-                license {
-                    name 'The Apache Software License, Version 2.0'
-                    url 'http://www.apache.org/licenses/LICENSE-2.0.txt'
-                }
-            }
-            scm {
-                connection 'https://github.com/natario1/Transcoder.git'
-                developerConnection 'https://github.com/natario1/Transcoder.git'
-                url 'https://github.com/natario1/Transcoder'
-
-            }
-            developers {
-                developer {
-                    id = 'natario'
-                    name 'Mattia Iavarone'
-                }
-            }
-        }
-    }
-}
-
-def bintrayUser
-def bintrayKey
-def travis = System.getenv("TRAVIS")
-if (travis) {
-    bintrayUser = System.getenv("BINTRAY_USER")
-    bintrayKey = System.getenv("BINTRAY_KEY")
-} else {
-    Properties props = new Properties()
-    props.load(project.rootProject.file('local.properties').newDataInputStream())
-    bintrayUser = props.getProperty('bintray.user')
-    bintrayKey = props.get('bintray.key')
-}
-
-bintray {
-    user = bintrayUser
-    key = bintrayKey
-    configurations = ['archives']
-    pkg {
-        repo = 'android'
-        name = 'Transcoder'
-        licenses = ['Apache-2.0']
-        vcsUrl = 'https://github.com/natario1/Transcoder.git'
-        publish = true
-        override = true
-        version {
-            name = project.version
-            desc = 'Transcoder v. '+project.version
-            released = new Date()
-            vcsTag = 'v'+project.version
-        }
-    }
-}
-
-task sourcesJar(type: Jar) {
-    archiveClassifier.set('sources')
-    from android.sourceSets.main.java.sourceFiles
-}
-
-task javadoc(type: Javadoc) {
-    source = android.sourceSets.main.java.srcDirs
-    classpath += project.files(android.getBootClasspath().join(File.pathSeparator))
-    classpath += project.files("${android.sdkDirectory}/platforms/${android.compileSdkVersion}/android.jar")
-    project.android.libraryVariants.all { variant ->
-        if (variant.name == 'release') {
-            classpath += files(variant.javaCompile.classpath)
-        }
-    }
-    exclude '**/BuildConfig.java'
-    exclude '**/R.java'
-    exclude '**/internal/**'
-}
-
-task javadocJar(type: Jar, dependsOn: javadoc) {
-    archiveClassifier.set('javadoc')
-    from javadoc.destinationDir
-}
-
-artifacts {
-    archives javadocJar
-    archives sourcesJar
-}
\ No newline at end of file
diff --git a/lib/build.gradle.kts b/lib/build.gradle.kts
new file mode 100644
index 00000000..ecf39815
--- /dev/null
+++ b/lib/build.gradle.kts
@@ -0,0 +1,112 @@
+import io.deepmedia.tools.deployer.model.Secret
+import org.gradle.api.publish.maven.internal.publication.MavenPublicationInternal
+
+plugins {
+    id("com.android.library")
+    kotlin("android")
+    id("io.deepmedia.tools.deployer")
+    id("org.jetbrains.dokka") version "1.9.20"
+}
+
+android {
+    namespace = "io.deepmedia.transcoder"
+    compileSdk = 34
+    defaultConfig {
+        minSdk = 21
+        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+    }
+    testOptions {
+        targetSdk = 23
+    }
+    publishing {
+        singleVariant("release")
+    }
+}
+
+kotlin {
+    jvmToolchain(17)
+}
+
+dependencies {
+    api("com.otaliastudios.opengl:egloo:0.6.1")
+    api("androidx.annotation:annotation:1.8.2")
+
+    androidTestImplementation("androidx.test:runner:1.6.1")
+    androidTestImplementation("androidx.test:rules:1.6.1")
+    androidTestImplementation("androidx.test.ext:junit:1.2.1")
+    androidTestImplementation("org.mockito:mockito-android:2.28.2")
+
+    dokkaPlugin("org.jetbrains.dokka:android-documentation-plugin:1.9.20")
+}
+
+val javadocs = tasks.register<Jar>("dokkaJavadocJar") {
+    dependsOn(tasks.dokkaJavadoc)
+    from(tasks.dokkaJavadoc.flatMap { it.outputDirectory })
+    archiveClassifier.set("javadoc")
+}
+
+// Ugly workaround because the snapshot publication has different version and maven-publish
+// is then unable to determine the right coordinates for lib-legacy dependency on this project
+publishing.publications.withType<MavenPublicationInternal>().configureEach {
+    isAlias = name != "localReleaseComponent"
+}
+
+deployer {
+    content {
+        component {
+            fromSoftwareComponent("release")
+            kotlinSources()
+            docs(javadocs)
+        }
+    }
+
+    projectInfo {
+        groupId = "io.deepmedia.community"
+        artifactId = "transcoder-android"
+        release.version = "0.11.2" // change :lib-legacy and README
+        description = "Accelerated video compression and transcoding on Android using MediaCodec APIs (no FFMPEG/LGPL licensing issues). Supports cropping to any dimension, concatenation, audio processing and much more."
+        url = "https://opensource.deepmedia.io/transcoder"
+        scm.fromGithub("deepmedia", "Transcoder")
+        license(apache2)
+        developer("Mattia Iavarone", "mattia@deepmedia.io", "DeepMedia", "https://deepmedia.io")
+    }
+
+    signing {
+        key = secret("SIGNING_KEY")
+        password = secret("SIGNING_PASSWORD")
+    }
+
+    // use "deployLocal" to deploy to local maven repository
+    localSpec {
+        directory.set(rootProject.layout.buildDirectory.get().dir("inspect"))
+        signing {
+            key = absent()
+            password = absent()
+        }
+    }
+
+    // use "deployNexus" to deploy to OSSRH / maven central
+    nexusSpec {
+        auth.user = secret("SONATYPE_USER")
+        auth.password = secret("SONATYPE_PASSWORD")
+        syncToMavenCentral = true
+    }
+
+    // use "deployNexusSnapshot" to deploy to sonatype snapshots repo
+    nexusSpec("snapshot") {
+        auth.user = secret("SONATYPE_USER")
+        auth.password = secret("SONATYPE_PASSWORD")
+        repositoryUrl = ossrhSnapshots1
+        release.version = "latest-SNAPSHOT"
+    }
+
+    // use "deployGithub" to deploy to github packages
+    githubSpec {
+        repository = "Transcoder"
+        owner = "deepmedia"
+        auth {
+            user = secret("GHUB_USER")
+            token = secret("GHUB_PERSONAL_ACCESS_TOKEN")
+        }
+    }
+}
diff --git a/lib/src/androidTest/assets/issue_102/sample.mp4 b/lib/src/androidTest/assets/issue_102/sample.mp4
new file mode 100644
index 00000000..8ce533f4
Binary files /dev/null and b/lib/src/androidTest/assets/issue_102/sample.mp4 differ
diff --git a/lib/src/androidTest/assets/issue_137/0.amr b/lib/src/androidTest/assets/issue_137/0.amr
new file mode 100644
index 00000000..f03133a1
Binary files /dev/null and b/lib/src/androidTest/assets/issue_137/0.amr differ
diff --git a/lib/src/androidTest/assets/issue_137/1.amr b/lib/src/androidTest/assets/issue_137/1.amr
new file mode 100644
index 00000000..34ca5bb5
Binary files /dev/null and b/lib/src/androidTest/assets/issue_137/1.amr differ
diff --git a/lib/src/androidTest/assets/issue_137/2.amr b/lib/src/androidTest/assets/issue_137/2.amr
new file mode 100644
index 00000000..509c7a9d
Binary files /dev/null and b/lib/src/androidTest/assets/issue_137/2.amr differ
diff --git a/lib/src/androidTest/assets/issue_137/3.amr b/lib/src/androidTest/assets/issue_137/3.amr
new file mode 100644
index 00000000..7c228f52
Binary files /dev/null and b/lib/src/androidTest/assets/issue_137/3.amr differ
diff --git a/lib/src/androidTest/assets/issue_137/4.amr b/lib/src/androidTest/assets/issue_137/4.amr
new file mode 100644
index 00000000..1e3a4729
Binary files /dev/null and b/lib/src/androidTest/assets/issue_137/4.amr differ
diff --git a/lib/src/androidTest/assets/issue_137/5.amr b/lib/src/androidTest/assets/issue_137/5.amr
new file mode 100644
index 00000000..81935c94
Binary files /dev/null and b/lib/src/androidTest/assets/issue_137/5.amr differ
diff --git a/lib/src/androidTest/assets/issue_137/6.amr b/lib/src/androidTest/assets/issue_137/6.amr
new file mode 100644
index 00000000..4eea56e2
Binary files /dev/null and b/lib/src/androidTest/assets/issue_137/6.amr differ
diff --git a/lib/src/androidTest/assets/issue_137/7.amr b/lib/src/androidTest/assets/issue_137/7.amr
new file mode 100644
index 00000000..da09937e
Binary files /dev/null and b/lib/src/androidTest/assets/issue_137/7.amr differ
diff --git a/lib/src/androidTest/assets/issue_137/8.amr b/lib/src/androidTest/assets/issue_137/8.amr
new file mode 100644
index 00000000..79609eb6
Binary files /dev/null and b/lib/src/androidTest/assets/issue_137/8.amr differ
diff --git a/lib/src/androidTest/assets/issue_137/main.mp3 b/lib/src/androidTest/assets/issue_137/main.mp3
new file mode 100644
index 00000000..4f7ee306
Binary files /dev/null and b/lib/src/androidTest/assets/issue_137/main.mp3 differ
diff --git a/lib/src/androidTest/assets/issue_180/party.mp4 b/lib/src/androidTest/assets/issue_180/party.mp4
new file mode 100644
index 00000000..a8c03b6f
Binary files /dev/null and b/lib/src/androidTest/assets/issue_180/party.mp4 differ
diff --git a/lib/src/androidTest/assets/issue_184/transcode.3gp b/lib/src/androidTest/assets/issue_184/transcode.3gp
new file mode 100644
index 00000000..71a06092
Binary files /dev/null and b/lib/src/androidTest/assets/issue_184/transcode.3gp differ
diff --git a/lib/src/androidTest/assets/issue_75/bbb_720p_30mb.mp4 b/lib/src/androidTest/assets/issue_75/bbb_720p_30mb.mp4
new file mode 100644
index 00000000..f29424ee
Binary files /dev/null and b/lib/src/androidTest/assets/issue_75/bbb_720p_30mb.mp4 differ
diff --git a/lib/src/androidTest/java/com/otaliastudios/transcoder/integration/IssuesTests.kt b/lib/src/androidTest/java/com/otaliastudios/transcoder/integration/IssuesTests.kt
new file mode 100644
index 00000000..1ed497a8
--- /dev/null
+++ b/lib/src/androidTest/java/com/otaliastudios/transcoder/integration/IssuesTests.kt
@@ -0,0 +1,154 @@
+package com.otaliastudios.transcoder.integration
+
+import android.media.MediaFormat
+import android.media.MediaMetadataRetriever.METADATA_KEY_DURATION
+import android.media.MediaMetadataRetriever
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.otaliastudios.transcoder.Transcoder
+import com.otaliastudios.transcoder.TranscoderListener
+import com.otaliastudios.transcoder.TranscoderOptions
+import com.otaliastudios.transcoder.common.TrackType
+import com.otaliastudios.transcoder.internal.utils.Logger
+import com.otaliastudios.transcoder.source.AssetFileDescriptorDataSource
+import com.otaliastudios.transcoder.source.BlankAudioDataSource
+import com.otaliastudios.transcoder.source.ClipDataSource
+import com.otaliastudios.transcoder.source.FileDescriptorDataSource
+import com.otaliastudios.transcoder.strategy.DefaultAudioStrategy
+import com.otaliastudios.transcoder.strategy.DefaultVideoStrategy
+import com.otaliastudios.transcoder.validator.WriteAlwaysValidator
+import org.junit.Assume
+import org.junit.AssumptionViolatedException
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.File
+
+@RunWith(AndroidJUnit4::class)
+class IssuesTests {
+
+    class Helper(val issue: Int) {
+
+        val log = Logger("Issue$issue")
+        val context = InstrumentationRegistry.getInstrumentation().context
+
+        fun output(
+            name: String = System.currentTimeMillis().toString(),
+            extension: String = "mp4"
+        ) = File(context.cacheDir, "$name.$extension").also { it.parentFile!!.mkdirs() }
+
+        fun input(filename: String) = AssetFileDescriptorDataSource(
+            context.assets.openFd("issue_$issue/$filename")
+        )
+
+        fun transcode(
+            output: File = output(),
+            assertTranscoded: Boolean = true,
+            assertDuration: Boolean = true,
+            builder: TranscoderOptions.Builder.() -> Unit,
+        ): File = runCatching {
+            Logger.setLogLevel(Logger.LEVEL_VERBOSE)
+            val transcoder = Transcoder.into(output.absolutePath)
+            transcoder.apply(builder)
+            transcoder.setListener(object : TranscoderListener {
+                override fun onTranscodeCanceled() = Unit
+                override fun onTranscodeCompleted(successCode: Int) {
+                    if (assertTranscoded) {
+                        require(successCode == Transcoder.SUCCESS_TRANSCODED)
+                    }
+                }
+                override fun onTranscodeFailed(exception: Throwable) = Unit
+                override fun onTranscodeProgress(progress: Double) = Unit
+            })
+            transcoder.transcode().get()
+            if (assertDuration) {
+                val retriever = MediaMetadataRetriever()
+                retriever.setDataSource(output.absolutePath)
+                val duration = retriever.extractMetadata(METADATA_KEY_DURATION)!!.toLong()
+                log.e("Video duration is $duration")
+                assert(duration > 0L)
+                retriever.release()
+            }
+            return output
+        }.getOrElse {
+            if (it.toString().contains("c2.android.avc.encoder was unable to create the input surface (1x1)")) {
+                log.w("Hit known emulator bug. Skipping the test.")
+                throw AssumptionViolatedException("Hit known emulator bug.")
+            }
+            throw it
+        }
+    }
+
+
+    @Test(timeout = 16000)
+    fun issue137() = with(Helper(137)) {
+        transcode {
+            addDataSource(ClipDataSource(input("main.mp3"), 0L, 1000_000L))
+            addDataSource(input("0.amr"))
+            addDataSource(ClipDataSource(input("main.mp3"), 2000_000L, 3000_000L))
+            addDataSource(input("1.amr"))
+            addDataSource(ClipDataSource(input("main.mp3"), 4000_000L, 5000_000L))
+            addDataSource(input("2.amr"))
+            addDataSource(ClipDataSource(input("main.mp3"), 6000_000L, 7000_000L))
+            addDataSource(input("3.amr"))
+            addDataSource(ClipDataSource(input("main.mp3"), 8000_000L, 9000_000L))
+            addDataSource(input("4.amr"))
+            addDataSource(ClipDataSource(input("main.mp3"), 10000_000L, 11000_000L))
+            addDataSource(input("5.amr"))
+            addDataSource(ClipDataSource(input("main.mp3"), 12000_000L, 13000_000L))
+            addDataSource(input("6.amr"))
+            addDataSource(ClipDataSource(input("main.mp3"), 14000_000L, 15000_000L))
+            addDataSource(input("7.amr"))
+            addDataSource(ClipDataSource(input("main.mp3"), 16000_000L, 17000_000L))
+            addDataSource(input("8.amr"))
+        }
+        Unit
+    }
+
+    @Test(timeout = 16000)
+    fun issue184() = with(Helper(184)) {
+        transcode {
+            addDataSource(TrackType.VIDEO, input("transcode.3gp"))
+            setVideoTrackStrategy(DefaultVideoStrategy.exact(400, 400).build())
+        }
+        Unit
+    }
+
+    @Test(timeout = 16000)
+    fun issue102() = with(Helper(102)) {
+        transcode {
+            addDataSource(input("sample.mp4"))
+            setValidator(WriteAlwaysValidator())
+        }
+        Unit
+    }
+
+    @Test(timeout = 16000)
+    fun issue180() = with(Helper(180)) {
+        transcode {
+            val vds = input("party.mp4")
+            val duration = run {
+                vds.initialize()
+                vds.durationUs.also { vds.deinitialize() }
+            }
+            check(duration > 0L) { "Invalid duration: $duration" }
+            addDataSource(TrackType.VIDEO, vds)
+            addDataSource(TrackType.AUDIO, BlankAudioDataSource(duration))
+        }
+        Unit
+    }
+
+    @Test(timeout = 16000)
+    fun issue75_workaround() = with(Helper(75)) {
+        transcode {
+            val vds = input("bbb_720p_30mb.mp4")
+            addDataSource(ClipDataSource(vds, 0, 500_000))
+            setVideoTrackStrategy(DefaultVideoStrategy.exact(300, 300).build())
+            // API 23:
+            // This video seems to have wrong number of channels in metadata (6) wrt MediaCodec decoder output (2)
+            // so when using DefaultAudioStrategy.CHANNELS_AS_INPUT we target 6 and try to upscale from 2 to 6, which fails
+            // The workaround below explicitly sets a number of channels different than CHANNELS_AS_INPUT to make it work
+            // setAudioTrackStrategy(DefaultAudioStrategy.builder().channels(1).build())
+        }
+        Unit
+    }
+}
\ No newline at end of file
diff --git a/lib/src/androidTest/java/com/otaliastudios/transcoder/engine/internal/ISO6709LocationParserTest.java b/lib/src/androidTest/java/com/otaliastudios/transcoder/internal/utils/ISO6709LocationParserTest.java
similarity index 92%
rename from lib/src/androidTest/java/com/otaliastudios/transcoder/engine/internal/ISO6709LocationParserTest.java
rename to lib/src/androidTest/java/com/otaliastudios/transcoder/internal/utils/ISO6709LocationParserTest.java
index f8abf949..ef3a64a2 100644
--- a/lib/src/androidTest/java/com/otaliastudios/transcoder/engine/internal/ISO6709LocationParserTest.java
+++ b/lib/src/androidTest/java/com/otaliastudios/transcoder/internal/utils/ISO6709LocationParserTest.java
@@ -1,10 +1,8 @@
-package com.otaliastudios.transcoder.engine.internal;
+package com.otaliastudios.transcoder.internal.utils;
 
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.SmallTest;
 
-import com.otaliastudios.transcoder.internal.ISO6709LocationParser;
-
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
diff --git a/lib/src/main/AndroidManifest.xml b/lib/src/main/AndroidManifest.xml
index f62d05a4..48652056 100644
--- a/lib/src/main/AndroidManifest.xml
+++ b/lib/src/main/AndroidManifest.xml
@@ -1,6 +1,3 @@
-<manifest xmlns:android="http://schemas.android.com/apk/res/android"
-    package="com.otaliastudios.transcoder">
-
+<manifest>
     <application />
-
 </manifest>
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/Thumbnailer.kt b/lib/src/main/java/com/otaliastudios/transcoder/Thumbnailer.kt
new file mode 100644
index 00000000..c519e49a
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/Thumbnailer.kt
@@ -0,0 +1,26 @@
+package com.otaliastudios.transcoder
+
+import com.otaliastudios.transcoder.internal.thumbnails.ThumbnailsEngine
+import com.otaliastudios.transcoder.internal.utils.ThreadPool
+import com.otaliastudios.transcoder.thumbnail.Thumbnail
+import java.util.concurrent.Callable
+import java.util.concurrent.Future
+
+class Thumbnailer private constructor() {
+
+    fun thumbnails(options: ThumbnailerOptions): Future<Void> {
+        return ThreadPool.executor.submit(Callable {
+            ThumbnailsEngine.thumbnails(options)
+            null
+        })
+    }
+
+    fun thumbnails(builder: ThumbnailerOptions.Builder.() -> Unit) = thumbnails(
+            options = ThumbnailerOptions.Builder().apply(builder).build()
+    )
+
+    companion object {
+        // Just for consistency with Transcoder class.
+        fun getInstance() = Thumbnailer()
+    }
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/ThumbnailerListener.kt b/lib/src/main/java/com/otaliastudios/transcoder/ThumbnailerListener.kt
new file mode 100644
index 00000000..b0f89872
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/ThumbnailerListener.kt
@@ -0,0 +1,14 @@
+package com.otaliastudios.transcoder
+
+import com.otaliastudios.transcoder.thumbnail.Thumbnail
+
+interface ThumbnailerListener {
+
+    fun onThumbnail(thumbnail: Thumbnail)
+
+    fun onThumbnailsCompleted(thumbnails: List<Thumbnail>) = Unit
+
+    fun onThumbnailsCanceled() = Unit
+
+    fun onThumbnailsFailed(exception: Throwable)
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/ThumbnailerOptions.kt b/lib/src/main/java/com/otaliastudios/transcoder/ThumbnailerOptions.kt
new file mode 100644
index 00000000..5a0a35d6
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/ThumbnailerOptions.kt
@@ -0,0 +1,111 @@
+package com.otaliastudios.transcoder
+
+import android.content.Context
+import android.net.Uri
+import android.os.Handler
+import android.os.Looper
+import com.otaliastudios.transcoder.resize.ExactResizer
+import com.otaliastudios.transcoder.resize.MultiResizer
+import com.otaliastudios.transcoder.resize.Resizer
+import com.otaliastudios.transcoder.source.DataSource
+import com.otaliastudios.transcoder.source.FileDescriptorDataSource
+import com.otaliastudios.transcoder.source.FilePathDataSource
+import com.otaliastudios.transcoder.source.UriDataSource
+import com.otaliastudios.transcoder.thumbnail.ThumbnailRequest
+import java.io.FileDescriptor
+import java.util.concurrent.Future
+
+@Suppress("unused")
+class ThumbnailerOptions(
+        val dataSources: List<DataSource>,
+        val resizer: Resizer,
+        val rotation: Int,
+        val thumbnailRequests: List<ThumbnailRequest>,
+        val listener: ThumbnailerListener,
+        val listenerHandler: Handler
+) {
+
+    class Builder {
+
+        private val dataSources = mutableListOf<DataSource>()
+        private val thumbnailRequests = mutableListOf<ThumbnailRequest>()
+        private val resizer = MultiResizer()
+        private var resizerSet = false
+        private var rotation = 0
+        private var listener: ThumbnailerListener? = null
+        private var listenerHandler: Handler? = null
+
+        fun addDataSource(dataSource: DataSource) = this.also {
+            dataSources.add(dataSource)
+        }
+
+        fun addDataSource(fileDescriptor: FileDescriptor)
+                = addDataSource(FileDescriptorDataSource(fileDescriptor))
+
+        fun addDataSource(filePath: String)
+                = addDataSource(FilePathDataSource(filePath))
+
+        fun addDataSource(context: Context, uri: Uri)
+                = addDataSource(UriDataSource(context, uri))
+
+        /**
+         * Sets the video output strategy. If absent, this defaults to 320x240 images.
+         */
+        fun addResizer(resizer: Resizer) = this.also {
+            this.resizer.addResizer(resizer)
+            this.resizerSet = true
+        }
+
+        /**
+         * The clockwise rotation to be applied to the input video frames.
+         * Defaults to 0, which leaves the input rotation unchanged.
+         */
+        fun setRotation(rotation: Int) = this.also {
+            this.rotation = rotation
+        }
+
+        fun addThumbnailRequest(request: ThumbnailRequest) = this.also {
+            thumbnailRequests.add(request)
+        }
+
+        /**
+         * Sets an handler for [ThumbnailerListener] callbacks.
+         * If null, this will default to the thread that starts the transcoding, if it
+         * has a looper, or the UI thread otherwise.
+         */
+        fun setListenerHandler(listenerHandler: Handler?) = this.also {
+            this.listenerHandler = listenerHandler
+        }
+
+        fun setListener(listener: ThumbnailerListener) = this.also {
+            this.listener = listener
+        }
+
+        fun build(): ThumbnailerOptions {
+            require(dataSources.isNotEmpty()) {
+                "At least one data source is required!"
+            }
+            require(thumbnailRequests.isNotEmpty()) {
+                "At least one thumbnail request is required!"
+            }
+            val listener = requireNotNull(listener) {
+                "Listener can't be null."
+            }
+            val listenerHandler = listenerHandler
+                    ?: Handler(Looper.myLooper() ?: Looper.getMainLooper())
+            val resizer = if (resizerSet) resizer else ExactResizer(320, 240)
+            return ThumbnailerOptions(
+                    dataSources = dataSources.toList(),
+                    resizer = resizer,
+                    rotation = rotation,
+                    thumbnailRequests = thumbnailRequests.toList(),
+                    listener = listener,
+                    listenerHandler = listenerHandler
+            )
+        }
+
+        fun thumbnails(): Future<Void> {
+            return Thumbnailer.getInstance().thumbnails(build())
+        }
+    }
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/Transcoder.java b/lib/src/main/java/com/otaliastudios/transcoder/Transcoder.java
index 91618d62..12e32dd1 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/Transcoder.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/Transcoder.java
@@ -15,29 +15,21 @@
  */
 package com.otaliastudios.transcoder;
 
-import android.os.Handler;
+import android.os.Build;
 
-import com.otaliastudios.transcoder.engine.Engine;
+import com.otaliastudios.transcoder.internal.transcode.TranscodeEngine;
+import com.otaliastudios.transcoder.internal.utils.ThreadPool;
 import com.otaliastudios.transcoder.sink.DataSink;
-import com.otaliastudios.transcoder.source.DataSource;
-import com.otaliastudios.transcoder.internal.Logger;
 import com.otaliastudios.transcoder.validator.Validator;
-import com.otaliastudios.transcoder.internal.ValidatorException;
 
+import java.io.FileDescriptor;
 import java.util.concurrent.Callable;
 import java.util.concurrent.Future;
-import java.util.concurrent.LinkedBlockingQueue;
-import java.util.concurrent.ThreadFactory;
-import java.util.concurrent.ThreadPoolExecutor;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicInteger;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
 
 public class Transcoder {
-    private static final String TAG = Transcoder.class.getSimpleName();
-    private static final Logger LOG = new Logger(TAG);
-
     /**
      * Constant for {@link TranscoderListener#onTranscodeCompleted(int)}.
      * Transcoding was executed successfully.
@@ -51,42 +43,14 @@ public class Transcoder {
      */
     public static final int SUCCESS_NOT_NEEDED = 1;
 
-    private static volatile Transcoder sTranscoder;
-
-    private class Factory implements ThreadFactory {
-        private AtomicInteger count = new AtomicInteger(1);
-
-        @Override
-        public Thread newThread(@NonNull Runnable runnable) {
-            return new Thread(runnable, TAG + " Thread #" + count.getAndIncrement());
-        }
-    }
-
-    private ThreadPoolExecutor mExecutor;
-
-    private Transcoder() {
-        // This executor will execute at most 'pool' tasks concurrently,
-        // then queue all the others. CPU + 1 is used by AsyncTask.
-        int pool = Runtime.getRuntime().availableProcessors() + 1;
-        mExecutor = new ThreadPoolExecutor(pool, pool,
-                60, TimeUnit.SECONDS,
-                new LinkedBlockingQueue<Runnable>(),
-                new Factory());
-    }
-
     @SuppressWarnings("WeakerAccess")
     @NonNull
     public static Transcoder getInstance() {
-        if (sTranscoder == null) {
-            synchronized (Transcoder.class) {
-                if (sTranscoder == null) {
-                    sTranscoder = new Transcoder();
-                }
-            }
-        }
-        return sTranscoder;
+        return new Transcoder();
     }
 
+    private Transcoder() { /* private */ }
+
     /**
      * Starts building transcoder options.
      * Requires a non null absolute path to the output file.
@@ -99,6 +63,19 @@ public static TranscoderOptions.Builder into(@NonNull String outPath) {
         return new TranscoderOptions.Builder(outPath);
     }
 
+    /**
+     * Starts building transcoder options.
+     * Requires a non null fileDescriptor to the output file or stream
+     *
+     * @param fileDescriptor descriptor of the output file or stream
+     * @return an options builder
+     */
+    @RequiresApi(api = Build.VERSION_CODES.O)
+    @NonNull
+    public static TranscoderOptions.Builder into(@NonNull FileDescriptor fileDescriptor) {
+        return new TranscoderOptions.Builder(fileDescriptor);
+    }
+
     /**
      * Starts building transcoder options.
      * Requires a non null sink.
@@ -119,106 +96,13 @@ public static TranscoderOptions.Builder into(@NonNull DataSink dataSink) {
      */
     @NonNull
     public Future<Void> transcode(@NonNull final TranscoderOptions options) {
-        final TranscoderListener listenerWrapper = new ListenerWrapper(options.listenerHandler,
-                options.listener);
-        return mExecutor.submit(new Callable<Void>() {
+        return ThreadPool.getExecutor().submit(new Callable<Void>() {
             @Override
             public Void call() throws Exception {
-                try {
-                    Engine engine = new Engine(new Engine.ProgressCallback() {
-                        @Override
-                        public void onProgress(final double progress) {
-                            listenerWrapper.onTranscodeProgress(progress);
-                        }
-                    });
-                    engine.transcode(options);
-                    listenerWrapper.onTranscodeCompleted(SUCCESS_TRANSCODED);
-
-                } catch (ValidatorException e) {
-                    LOG.i("Validator has decided that the input is fine and transcoding is not necessary.");
-                    listenerWrapper.onTranscodeCompleted(SUCCESS_NOT_NEEDED);
-
-                } catch (Throwable e) {
-                    // Check InterruptedException in e and in its causes.
-                    Throwable current = e;
-                    boolean isInterrupted = e instanceof InterruptedException;
-                    while (!isInterrupted && current.getCause() != null && !current.getCause().equals(current)) {
-                        current = current.getCause();
-                        if (current instanceof InterruptedException) isInterrupted = true;
-                    }
-                    if (isInterrupted) {
-                        LOG.i("Transcode canceled.", current);
-                        listenerWrapper.onTranscodeCanceled();
-
-                    } else if (e instanceof RuntimeException) {
-                        LOG.e("Fatal error while transcoding, this might be invalid format or bug in engine or Android.", e);
-                        listenerWrapper.onTranscodeFailed(e);
-                        throw e;
-
-                    } else {
-                        LOG.e("Unexpected error while transcoding", e);
-                        listenerWrapper.onTranscodeFailed(e);
-                        throw e;
-                    }
-                }
+                TranscodeEngine.transcode(options);
                 return null;
             }
         });
     }
 
-    /**
-     * Wraps a TranscoderListener and posts events on the given handler.
-     */
-    private static class ListenerWrapper implements TranscoderListener {
-
-        private Handler mHandler;
-        private TranscoderListener mListener;
-
-        private ListenerWrapper(@NonNull Handler handler, @NonNull TranscoderListener listener) {
-            mHandler = handler;
-            mListener = listener;
-        }
-
-        @Override
-        public void onTranscodeCanceled() {
-            mHandler.post(new Runnable() {
-                @Override
-                public void run() {
-                    mListener.onTranscodeCanceled();
-                }
-            });
-        }
-
-        @Override
-        public void onTranscodeCompleted(final int successCode) {
-            mHandler.post(new Runnable() {
-                @Override
-                public void run() {
-                    mListener.onTranscodeCompleted(successCode);
-                }
-            });
-        }
-
-        @Override
-        public void onTranscodeFailed(@NonNull final Throwable exception) {
-            mHandler.post(new Runnable() {
-                @Override
-                public void run() {
-                    mListener.onTranscodeFailed(exception);
-                }
-            });
-        }
-
-        @Override
-        public void onTranscodeProgress(final double progress) {
-            // Don't think there's a safe way to avoid this allocation?
-            // Other than creating a pool of runnables.
-            mHandler.post(new Runnable() {
-                @Override
-                public void run() {
-                    mListener.onTranscodeProgress(progress);
-                }
-            });
-        }
-    }
 }
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/TranscoderOptions.java b/lib/src/main/java/com/otaliastudios/transcoder/TranscoderOptions.java
index 031e641a..c8a47b7a 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/TranscoderOptions.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/TranscoderOptions.java
@@ -1,19 +1,22 @@
 package com.otaliastudios.transcoder;
 
 import android.content.Context;
+import android.content.res.AssetFileDescriptor;
 import android.net.Uri;
+import android.os.Build;
 import android.os.Handler;
 import android.os.Looper;
 
-import com.otaliastudios.transcoder.engine.TrackType;
+import com.otaliastudios.transcoder.common.TrackType;
 import com.otaliastudios.transcoder.resample.AudioResampler;
 import com.otaliastudios.transcoder.resample.DefaultAudioResampler;
 import com.otaliastudios.transcoder.sink.DataSink;
 import com.otaliastudios.transcoder.sink.DefaultDataSink;
+import com.otaliastudios.transcoder.source.AssetFileDescriptorDataSource;
 import com.otaliastudios.transcoder.source.DataSource;
 import com.otaliastudios.transcoder.source.FileDescriptorDataSource;
 import com.otaliastudios.transcoder.source.FilePathDataSource;
-import com.otaliastudios.transcoder.source.TrimDataSource;
+import com.otaliastudios.transcoder.source.BlankAudioDataSource;
 import com.otaliastudios.transcoder.source.UriDataSource;
 import com.otaliastudios.transcoder.strategy.DefaultAudioStrategy;
 import com.otaliastudios.transcoder.strategy.DefaultVideoStrategies;
@@ -33,6 +36,7 @@
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
 
 /**
  * Collects transcoding options consumed by {@link Transcoder}.
@@ -47,13 +51,22 @@ private TranscoderOptions() {}
     private TrackStrategy audioTrackStrategy;
     private TrackStrategy videoTrackStrategy;
     private Validator validator;
-    private int rotation;
+    private int videoRotation;
     private TimeInterpolator timeInterpolator;
     private AudioStretcher audioStretcher;
     private AudioResampler audioResampler;
+    private TranscoderListener listener;
+    private Handler listenerHandler;
 
-    TranscoderListener listener;
-    Handler listenerHandler;
+    @NonNull
+    public TranscoderListener getListener() {
+        return listener;
+    }
+
+    @NonNull
+    public Handler getListenerHandler() {
+        return listenerHandler;
+    }
 
     @NonNull
     public DataSink getDataSink() {
@@ -86,7 +99,7 @@ public Validator getValidator() {
     }
 
     public int getVideoRotation() {
-        return rotation;
+        return videoRotation;
     }
 
     @NonNull
@@ -105,7 +118,7 @@ public AudioResampler getAudioResampler() {
     }
 
     public static class Builder {
-        private DataSink dataSink;
+        private final DataSink dataSink;
         private final List<DataSource> audioDataSources = new ArrayList<>();
         private final List<DataSource> videoDataSources = new ArrayList<>();
         private TranscoderListener listener;
@@ -113,7 +126,7 @@ public static class Builder {
         private TrackStrategy audioTrackStrategy;
         private TrackStrategy videoTrackStrategy;
         private Validator validator;
-        private int rotation;
+        private int videoRotation;
         private TimeInterpolator timeInterpolator;
         private AudioStretcher audioStretcher;
         private AudioResampler audioResampler;
@@ -122,6 +135,11 @@ public static class Builder {
             this.dataSink = new DefaultDataSink(outPath);
         }
 
+        @RequiresApi(api = Build.VERSION_CODES.O)
+        Builder(@NonNull FileDescriptor fileDescriptor) {
+            this.dataSink = new DefaultDataSink(fileDescriptor);
+        }
+
         Builder(@NonNull DataSink dataSink) {
             this.dataSink = dataSink;
         }
@@ -146,37 +164,43 @@ public Builder addDataSource(@NonNull TrackType type, @NonNull DataSource dataSo
         }
 
         @NonNull
-        @SuppressWarnings("unused")
         public Builder addDataSource(@NonNull FileDescriptor fileDescriptor) {
             return addDataSource(new FileDescriptorDataSource(fileDescriptor));
         }
 
         @NonNull
-        @SuppressWarnings("unused")
         public Builder addDataSource(@NonNull TrackType type, @NonNull FileDescriptor fileDescriptor) {
             return addDataSource(type, new FileDescriptorDataSource(fileDescriptor));
         }
 
         @NonNull
-        @SuppressWarnings("unused")
+        public Builder addDataSource(@NonNull AssetFileDescriptor assetFileDescriptor) {
+            return addDataSource(new AssetFileDescriptorDataSource(assetFileDescriptor));
+        }
+
+        @NonNull
+        public Builder addDataSource(@NonNull TrackType type, @NonNull AssetFileDescriptor assetFileDescriptor) {
+            return addDataSource(type, new AssetFileDescriptorDataSource(assetFileDescriptor));
+        }
+
+        @NonNull
         public Builder addDataSource(@NonNull String inPath) {
             return addDataSource(new FilePathDataSource(inPath));
         }
 
         @NonNull
-        @SuppressWarnings("unused")
         public Builder addDataSource(@NonNull TrackType type, @NonNull String inPath) {
             return addDataSource(type, new FilePathDataSource(inPath));
         }
 
         @NonNull
-        @SuppressWarnings({"unused", "UnusedReturnValue"})
+        @SuppressWarnings({"UnusedReturnValue"})
         public Builder addDataSource(@NonNull Context context, @NonNull Uri uri) {
             return addDataSource(new UriDataSource(context, uri));
         }
 
         @NonNull
-        @SuppressWarnings({"unused", "UnusedReturnValue"})
+        @SuppressWarnings({"UnusedReturnValue"})
         public Builder addDataSource(@NonNull TrackType type, @NonNull Context context, @NonNull Uri uri) {
             return addDataSource(type, new UriDataSource(context, uri));
         }
@@ -189,7 +213,6 @@ public Builder addDataSource(@NonNull TrackType type, @NonNull Context context,
          * @return this for chaining
          */
         @NonNull
-        @SuppressWarnings("unused")
         public Builder setAudioTrackStrategy(@Nullable TrackStrategy trackStrategy) {
             this.audioTrackStrategy = trackStrategy;
             return this;
@@ -203,7 +226,6 @@ public Builder setAudioTrackStrategy(@Nullable TrackStrategy trackStrategy) {
          * @return this for chaining
          */
         @NonNull
-        @SuppressWarnings("unused")
         public Builder setVideoTrackStrategy(@Nullable TrackStrategy trackStrategy) {
             this.videoTrackStrategy = trackStrategy;
             return this;
@@ -239,7 +261,6 @@ public Builder setListenerHandler(@Nullable Handler listenerHandler) {
          * @return this for chaining
          */
         @NonNull
-        @SuppressWarnings("unused")
         public Builder setValidator(@Nullable Validator validator) {
             this.validator = validator;
             return this;
@@ -253,9 +274,8 @@ public Builder setValidator(@Nullable Validator validator) {
          * @return this for chaining
          */
         @NonNull
-        @SuppressWarnings("unused")
         public Builder setVideoRotation(int rotation) {
-            this.rotation = rotation;
+            this.videoRotation = rotation;
             return this;
         }
 
@@ -283,7 +303,6 @@ public Builder setTimeInterpolator(@NonNull TimeInterpolator timeInterpolator) {
          * @return this for chaining
          */
         @NonNull
-        @SuppressWarnings("unused")
         public Builder setSpeed(float speedFactor) {
             return setTimeInterpolator(new SpeedTimeInterpolator(speedFactor));
         }
@@ -297,7 +316,6 @@ public Builder setSpeed(float speedFactor) {
          * @return this for chaining
          */
         @NonNull
-        @SuppressWarnings("unused")
         public Builder setAudioStretcher(@NonNull AudioStretcher audioStretcher) {
             this.audioStretcher = audioStretcher;
             return this;
@@ -312,12 +330,12 @@ public Builder setAudioStretcher(@NonNull AudioStretcher audioStretcher) {
          * @return this for chaining
          */
         @NonNull
-        @SuppressWarnings("unused")
         public Builder setAudioResampler(@NonNull AudioResampler audioResampler) {
             this.audioResampler = audioResampler;
             return this;
         }
 
+
         @NonNull
         public TranscoderOptions build() {
             if (listener == null) {
@@ -326,7 +344,7 @@ public TranscoderOptions build() {
             if (audioDataSources.isEmpty() && videoDataSources.isEmpty()) {
                 throw new IllegalStateException("we need at least one data source");
             }
-            if (rotation != 0 && rotation != 90 && rotation != 180 && rotation != 270) {
+            if (videoRotation != 0 && videoRotation != 90 && videoRotation != 180 && videoRotation != 270) {
                 throw new IllegalArgumentException("Accepted values for rotation are 0, 90, 180, 270");
             }
             if (listenerHandler == null) {
@@ -361,7 +379,7 @@ public TranscoderOptions build() {
             options.audioTrackStrategy = audioTrackStrategy;
             options.videoTrackStrategy = videoTrackStrategy;
             options.validator = validator;
-            options.rotation = rotation;
+            options.videoRotation = videoRotation;
             options.timeInterpolator = timeInterpolator;
             options.audioStretcher = audioStretcher;
             options.audioResampler = audioResampler;
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/strategy/size/ExactSize.java b/lib/src/main/java/com/otaliastudios/transcoder/common/ExactSize.java
similarity index 73%
rename from lib/src/main/java/com/otaliastudios/transcoder/strategy/size/ExactSize.java
rename to lib/src/main/java/com/otaliastudios/transcoder/common/ExactSize.java
index bb1a41bf..c180c7dd 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/strategy/size/ExactSize.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/common/ExactSize.java
@@ -1,4 +1,6 @@
-package com.otaliastudios.transcoder.strategy.size;
+package com.otaliastudios.transcoder.common;
+
+import com.otaliastudios.transcoder.resize.Resizer;
 
 /**
  * A special {@link Size} that knows about which dimension is width
@@ -8,8 +10,8 @@
  */
 public class ExactSize extends Size {
 
-    private int mWidth;
-    private int mHeight;
+    private final int mWidth;
+    private final int mHeight;
 
     public ExactSize(int width, int height) {
         super(width, height);
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/strategy/size/Size.java b/lib/src/main/java/com/otaliastudios/transcoder/common/Size.java
similarity index 85%
rename from lib/src/main/java/com/otaliastudios/transcoder/strategy/size/Size.java
rename to lib/src/main/java/com/otaliastudios/transcoder/common/Size.java
index b3d52827..e17f9f31 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/strategy/size/Size.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/common/Size.java
@@ -1,4 +1,4 @@
-package com.otaliastudios.transcoder.strategy.size;
+package com.otaliastudios.transcoder.common;
 
 /**
  * Represents a video size in pixels,
@@ -7,8 +7,8 @@
  */
 public class Size {
 
-    private int mMajor;
-    private int mMinor;
+    private final int mMajor;
+    private final int mMinor;
 
     /**
      * The order does not matter.
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/engine/TrackStatus.java b/lib/src/main/java/com/otaliastudios/transcoder/common/TrackStatus.java
similarity index 79%
rename from lib/src/main/java/com/otaliastudios/transcoder/engine/TrackStatus.java
rename to lib/src/main/java/com/otaliastudios/transcoder/common/TrackStatus.java
index 7456668d..23f8f89a 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/engine/TrackStatus.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/common/TrackStatus.java
@@ -1,4 +1,4 @@
-package com.otaliastudios.transcoder.engine;
+package com.otaliastudios.transcoder.common;
 
 /**
  * Represents the status of a given track inside the transcoding operation.
@@ -34,10 +34,12 @@ public enum TrackStatus {
      */
     public boolean isTranscoding() {
         switch (this) {
-            case PASS_THROUGH: return true;
-            case COMPRESSING: return true;
-            case REMOVING: return false;
-            case ABSENT: return false;
+            case PASS_THROUGH:
+            case COMPRESSING:
+                return true;
+            case REMOVING:
+            case ABSENT:
+                return false;
         }
         throw new RuntimeException("Unexpected track status: " + this);
     }
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/common/TrackType.kt b/lib/src/main/java/com/otaliastudios/transcoder/common/TrackType.kt
new file mode 100644
index 00000000..8fb4e1cc
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/common/TrackType.kt
@@ -0,0 +1,18 @@
+package com.otaliastudios.transcoder.common
+
+import android.media.MediaFormat
+
+enum class TrackType(internal val displayName: String) {
+    AUDIO("Audio"), VIDEO("Video");
+
+}
+
+internal val MediaFormat.trackType get() = requireNotNull(trackTypeOrNull) {
+    "Unexpected mime type: ${getString(MediaFormat.KEY_MIME)}"
+}
+
+internal val MediaFormat.trackTypeOrNull get() = when {
+    getString(MediaFormat.KEY_MIME)!!.startsWith("audio/") -> TrackType.AUDIO
+    getString(MediaFormat.KEY_MIME)!!.startsWith("video/") -> TrackType.VIDEO
+    else -> null
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/engine/Engine.java b/lib/src/main/java/com/otaliastudios/transcoder/engine/Engine.java
deleted file mode 100644
index 5759f50e..00000000
--- a/lib/src/main/java/com/otaliastudios/transcoder/engine/Engine.java
+++ /dev/null
@@ -1,393 +0,0 @@
-/*
- * Copyright (C) 2014 Yuya Tanaka
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.otaliastudios.transcoder.engine;
-
-import android.media.MediaFormat;
-
-import com.otaliastudios.transcoder.TranscoderOptions;
-import com.otaliastudios.transcoder.internal.TrackTypeMap;
-import com.otaliastudios.transcoder.internal.ValidatorException;
-import com.otaliastudios.transcoder.sink.DataSink;
-import com.otaliastudios.transcoder.sink.InvalidOutputFormatException;
-import com.otaliastudios.transcoder.source.DataSource;
-import com.otaliastudios.transcoder.strategy.TrackStrategy;
-import com.otaliastudios.transcoder.time.TimeInterpolator;
-import com.otaliastudios.transcoder.transcode.AudioTrackTranscoder;
-import com.otaliastudios.transcoder.transcode.NoOpTrackTranscoder;
-import com.otaliastudios.transcoder.transcode.PassThroughTrackTranscoder;
-import com.otaliastudios.transcoder.transcode.TrackTranscoder;
-import com.otaliastudios.transcoder.transcode.VideoTrackTranscoder;
-import com.otaliastudios.transcoder.internal.Logger;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-/**
- * Internal engine, do not use this directly.
- */
-public class Engine {
-
-    private static final String TAG = Engine.class.getSimpleName();
-    private static final Logger LOG = new Logger(TAG);
-
-    private static final long SLEEP_TO_WAIT_TRACK_TRANSCODERS = 10;
-    private static final long PROGRESS_INTERVAL_STEPS = 10;
-
-
-    public interface ProgressCallback {
-
-        /**
-         * Called to notify progress. Same thread which initiated transcode is used.
-         * @param progress Progress in [0.0, 1.0] range, or negative value if progress is unknown.
-         */
-        void onProgress(double progress);
-    }
-
-    private DataSink mDataSink;
-    private final TrackTypeMap<List<DataSource>> mDataSources = new TrackTypeMap<>();
-    private final TrackTypeMap<ArrayList<TrackTranscoder>> mTranscoders = new TrackTypeMap<>(new ArrayList<TrackTranscoder>(), new ArrayList<TrackTranscoder>());
-    private final TrackTypeMap<ArrayList<TimeInterpolator>> mInterpolators = new TrackTypeMap<>(new ArrayList<TimeInterpolator>(), new ArrayList<TimeInterpolator>());
-    private final TrackTypeMap<Integer> mCurrentStep = new TrackTypeMap<>(0, 0);
-    private final TrackTypeMap<TrackStatus> mStatuses = new TrackTypeMap<>();
-    private final TrackTypeMap<MediaFormat> mOutputFormats = new TrackTypeMap<>();
-    private volatile double mProgress;
-    private final ProgressCallback mProgressCallback;
-
-    public Engine(@Nullable ProgressCallback progressCallback) {
-        mProgressCallback = progressCallback;
-    }
-
-    /**
-     * Returns the current progress.
-     * Note: This method is thread safe.
-     * @return the current progress
-     */
-    @SuppressWarnings("unused")
-    public double getProgress() {
-        return mProgress;
-    }
-
-    private void setProgress(double progress) {
-        mProgress = progress;
-        if (mProgressCallback != null) {
-            mProgressCallback.onProgress(progress);
-        }
-    }
-
-    private boolean hasVideoSources() {
-        return !mDataSources.requireVideo().isEmpty();
-    }
-
-    private boolean hasAudioSources() {
-        return !mDataSources.requireAudio().isEmpty();
-    }
-
-    private Set<DataSource> getUniqueSources() {
-        Set<DataSource> sources = new HashSet<>();
-        sources.addAll(mDataSources.requireVideo());
-        sources.addAll(mDataSources.requireAudio());
-        return sources;
-    }
-
-    private void computeTrackStatus(@NonNull TrackType type,
-                                    @NonNull TrackStrategy strategy,
-                                    @NonNull List<DataSource> sources) {
-        TrackStatus status = TrackStatus.ABSENT;
-        MediaFormat outputFormat = new MediaFormat();
-        if (!sources.isEmpty()) {
-            MediaFormatProvider provider = new MediaFormatProvider();
-            List<MediaFormat> inputFormats = new ArrayList<>();
-            for (DataSource source : sources) {
-                MediaFormat inputFormat = source.getTrackFormat(type);
-                if (inputFormat != null) {
-                    inputFormats.add(provider.provideMediaFormat(source, type, inputFormat));
-                } else if (sources.size() > 1) {
-                    throw new IllegalArgumentException("More than one source selected for type " + type
-                            + ", but getTrackFormat returned null.");
-                }
-            }
-            status = strategy.createOutputFormat(inputFormats, outputFormat);
-        }
-        mOutputFormats.set(type, outputFormat);
-        mDataSink.setTrackStatus(type, status);
-        mStatuses.set(type, status);
-    }
-
-    private boolean isCompleted(@NonNull TrackType type) {
-        if (mDataSources.require(type).isEmpty()) return true;
-        int current = mCurrentStep.require(type);
-        return current == mDataSources.require(type).size() - 1
-                && current == mTranscoders.require(type).size() - 1
-                && mTranscoders.require(type).get(current).isFinished();
-    }
-
-    private void openCurrentStep(@NonNull TrackType type, @NonNull TranscoderOptions options) {
-        int current = mCurrentStep.require(type);
-        TrackStatus status = mStatuses.require(type);
-
-        // Notify the data source that we'll be transcoding this track.
-        DataSource dataSource = mDataSources.require(type).get(current);
-        if (status.isTranscoding()) {
-            dataSource.selectTrack(type);
-        }
-
-        // Create a TimeInterpolator, wrapping the external one.
-        TimeInterpolator interpolator = createStepTimeInterpolator(type, current,
-                options.getTimeInterpolator());
-        mInterpolators.require(type).add(interpolator);
-
-        // Create a Transcoder for this track.
-        TrackTranscoder transcoder;
-        switch (status) {
-            case PASS_THROUGH: {
-                transcoder = new PassThroughTrackTranscoder(dataSource,
-                        mDataSink, type, interpolator);
-                break;
-            }
-            case COMPRESSING: {
-                switch (type) {
-                    case VIDEO:
-                        transcoder = new VideoTrackTranscoder(dataSource, mDataSink,
-                                interpolator,
-                                options.getVideoRotation());
-                        break;
-                    case AUDIO:
-                        transcoder = new AudioTrackTranscoder(dataSource, mDataSink,
-                                interpolator,
-                                options.getAudioStretcher(),
-                                options.getAudioResampler());
-                        break;
-                    default:
-                        throw new RuntimeException("Unknown type: " + type);
-                }
-                break;
-            }
-            case ABSENT:
-            case REMOVING:
-            default: {
-                transcoder = new NoOpTrackTranscoder();
-                break;
-            }
-        }
-        transcoder.setUp(mOutputFormats.require(type));
-        mTranscoders.require(type).add(transcoder);
-    }
-
-    private void closeCurrentStep(@NonNull TrackType type) {
-        int current = mCurrentStep.require(type);
-        TrackTranscoder transcoder = mTranscoders.require(type).get(current);
-        DataSource dataSource = mDataSources.require(type).get(current);
-        transcoder.release();
-        dataSource.releaseTrack(type);
-        mCurrentStep.set(type, current + 1);
-    }
-
-    @NonNull
-    private TrackTranscoder getCurrentTrackTranscoder(@NonNull TrackType type, @NonNull TranscoderOptions options) {
-        int current = mCurrentStep.require(type);
-        int last = mTranscoders.require(type).size() - 1;
-        if (last == current) {
-            // We have already created a transcoder for this step.
-            // But this step might be completed and we might need to create a new one.
-            TrackTranscoder transcoder = mTranscoders.require(type).get(last);
-            if (transcoder.isFinished()) {
-                closeCurrentStep(type);
-                return getCurrentTrackTranscoder(type, options);
-            } else {
-                return mTranscoders.require(type).get(current);
-            }
-        } else if (last < current) {
-            // We need to create a new step.
-            openCurrentStep(type, options);
-            return mTranscoders.require(type).get(current);
-        } else {
-            throw new IllegalStateException("This should never happen. last:" + last + ", current:" + current);
-        }
-    }
-
-    @NonNull
-    private TimeInterpolator createStepTimeInterpolator(@NonNull TrackType type, int step,
-                                                        final @NonNull TimeInterpolator wrap) {
-        final long timebase;
-        if (step > 0) {
-            TimeInterpolator previous = mInterpolators.require(type).get(step - 1);
-            timebase = previous.interpolate(type, Long.MAX_VALUE);
-        } else {
-            timebase = 0;
-        }
-        return new TimeInterpolator() {
-
-            private long mLastInterpolatedTime;
-            private long mFirstInputTime = Long.MAX_VALUE;
-            private long mTimeBase = timebase + 10;
-
-            @Override
-            public long interpolate(@NonNull TrackType type, long time) {
-                if (time == Long.MAX_VALUE) return mLastInterpolatedTime;
-                if (mFirstInputTime == Long.MAX_VALUE) mFirstInputTime = time;
-                mLastInterpolatedTime = mTimeBase + (time - mFirstInputTime);
-                return wrap.interpolate(type, mLastInterpolatedTime);
-            }
-        };
-    }
-
-    private long getTrackDurationUs(@NonNull TrackType type) {
-        if (!mStatuses.require(type).isTranscoding()) return 0L;
-        int current = mCurrentStep.require(type);
-        long totalDurationUs = 0;
-        for (int i = 0; i < mDataSources.require(type).size(); i++) {
-            DataSource source = mDataSources.require(type).get(i);
-            if (i < current) { // getReadUs() is a better approximation for sure.
-                totalDurationUs += source.getReadUs();
-            } else {
-                totalDurationUs += source.getDurationUs();
-            }
-        }
-        return totalDurationUs;
-    }
-
-    private long getTotalDurationUs() {
-        boolean hasVideo = hasVideoSources() && mStatuses.requireVideo().isTranscoding();
-        boolean hasAudio = hasAudioSources() && mStatuses.requireAudio().isTranscoding();
-        long video = hasVideo ? getTrackDurationUs(TrackType.VIDEO) : Long.MAX_VALUE;
-        long audio = hasAudio ? getTrackDurationUs(TrackType.AUDIO) : Long.MAX_VALUE;
-        return Math.min(video, audio);
-    }
-
-    private long getTrackReadUs(@NonNull TrackType type) {
-        if (!mStatuses.require(type).isTranscoding()) return 0L;
-        int current = mCurrentStep.require(type);
-        long completedDurationUs = 0;
-        for (int i = 0; i < mDataSources.require(type).size(); i++) {
-            DataSource source = mDataSources.require(type).get(i);
-            if (i <= current) {
-                completedDurationUs += source.getReadUs();
-            }
-        }
-        return completedDurationUs;
-    }
-
-    private double getTrackProgress(@NonNull TrackType type) {
-        if (!mStatuses.require(type).isTranscoding()) return 0.0D;
-        long readUs = getTrackReadUs(type);
-        long totalUs = getTotalDurationUs();
-        LOG.v("getTrackProgress - readUs:" + readUs + ", totalUs:" + totalUs);
-        if (totalUs == 0) totalUs = 1; // Avoid NaN
-        return (double) readUs / (double) totalUs;
-    }
-
-    /**
-     * Performs transcoding. Blocks current thread.
-     *
-     * @param options Transcoding options.
-     * @throws InvalidOutputFormatException when output format is not supported.
-     * @throws InterruptedException when cancel to transcode
-     */
-    public void transcode(@NonNull TranscoderOptions options) throws InterruptedException {
-        mDataSink = options.getDataSink();
-        mDataSources.setVideo(options.getVideoDataSources());
-        mDataSources.setAudio(options.getAudioDataSources());
-
-        // Pass metadata from DataSource to DataSink
-        mDataSink.setOrientation(0); // Explicitly set 0 to output - we rotate the textures.
-        for (DataSource locationSource : getUniqueSources()) {
-            double[] location = locationSource.getLocation();
-            if (location != null) {
-                mDataSink.setLocation(location[0], location[1]);
-                break;
-            }
-        }
-
-        // Compute the TrackStatus.
-        int activeTracks = 0;
-        computeTrackStatus(TrackType.AUDIO, options.getAudioTrackStrategy(), options.getAudioDataSources());
-        computeTrackStatus(TrackType.VIDEO, options.getVideoTrackStrategy(), options.getVideoDataSources());
-        TrackStatus videoStatus = mStatuses.requireVideo();
-        TrackStatus audioStatus = mStatuses.requireAudio();
-        if (videoStatus.isTranscoding()) activeTracks++;
-        if (audioStatus.isTranscoding()) activeTracks++;
-        LOG.v("Duration (us): " + getTotalDurationUs());
-
-        // Pass to Validator.
-        //noinspection UnusedAssignment
-        boolean ignoreValidatorResult = false;
-        // If we have to apply some rotation, and the video should be transcoded,
-        // ignore any Validator trying to abort the operation. The operation must happen
-        // because we must apply the rotation.
-        ignoreValidatorResult = videoStatus.isTranscoding() && options.getVideoRotation() != 0;
-        if (!options.getValidator().validate(videoStatus, audioStatus) && !ignoreValidatorResult) {
-            throw new ValidatorException("Validator returned false.");
-        }
-
-        // Do the actual transcoding work.
-        try {
-            long loopCount = 0;
-            boolean stepped;
-            boolean audioCompleted = false, videoCompleted = false;
-            boolean forceAudioEos, forceVideoEos;
-            double audioProgress, videoProgress;
-            TrackTranscoder audioTranscoder, videoTranscoder;
-            while (!(audioCompleted && videoCompleted)) {
-                LOG.v("new step: " + loopCount);
-
-                if (Thread.interrupted()) {
-                    throw new InterruptedException();
-                }
-                stepped = false;
-
-                // First, check if we have to force an input end of stream for some track.
-                // This can happen, for example, if user adds 1 minute (video only) with 20 seconds
-                // of audio. The video track must be stopped once the audio stops.
-                long totalUs = getTotalDurationUs() + 100 /* tolerance */;
-                forceAudioEos = getTrackReadUs(TrackType.AUDIO) > totalUs;
-                forceVideoEos = getTrackReadUs(TrackType.VIDEO) > totalUs;
-
-                // Now step for transcoders that are not completed.
-                audioCompleted = isCompleted(TrackType.AUDIO);
-                videoCompleted = isCompleted(TrackType.VIDEO);
-                audioTranscoder = audioCompleted ? null : getCurrentTrackTranscoder(TrackType.AUDIO, options);
-                videoTranscoder = videoCompleted ? null : getCurrentTrackTranscoder(TrackType.VIDEO, options);
-                if (!audioCompleted) {
-                    stepped |= audioTranscoder.transcode(forceAudioEos);
-                }
-                if (!videoCompleted) {
-                    stepped |= videoTranscoder.transcode(forceVideoEos);
-                }
-                if (++loopCount % PROGRESS_INTERVAL_STEPS == 0) {
-                    audioProgress = getTrackProgress(TrackType.AUDIO);
-                    videoProgress = getTrackProgress(TrackType.VIDEO);
-                    LOG.v("progress - video:" + videoProgress + " audio:" + audioProgress);
-                    setProgress((videoProgress + audioProgress) / activeTracks);
-                }
-                if (!stepped) {
-                    Thread.sleep(SLEEP_TO_WAIT_TRACK_TRANSCODERS);
-                }
-            }
-            mDataSink.stop();
-        } finally {
-            try {
-                closeCurrentStep(TrackType.VIDEO);
-                closeCurrentStep(TrackType.AUDIO);
-            } catch (Exception ignore) {}
-            mDataSink.release();
-        }
-    }
-}
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/engine/TrackType.java b/lib/src/main/java/com/otaliastudios/transcoder/engine/TrackType.java
deleted file mode 100644
index e21b094c..00000000
--- a/lib/src/main/java/com/otaliastudios/transcoder/engine/TrackType.java
+++ /dev/null
@@ -1,8 +0,0 @@
-package com.otaliastudios.transcoder.engine;
-
-/**
- * Represent the types of a track (either video or audio).
- */
-public enum TrackType {
-    VIDEO, AUDIO
-}
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/Codecs.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/Codecs.kt
new file mode 100644
index 00000000..f3c9724a
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/Codecs.kt
@@ -0,0 +1,145 @@
+package com.otaliastudios.transcoder.internal
+
+import android.media.MediaCodec
+import android.media.MediaCodecList
+import android.media.MediaFormat
+import android.opengl.EGL14
+import com.otaliastudios.opengl.core.EglCore
+import com.otaliastudios.opengl.surface.EglWindowSurface
+import com.otaliastudios.transcoder.common.TrackStatus
+import com.otaliastudios.transcoder.common.TrackType
+import com.otaliastudios.transcoder.internal.media.MediaFormatConstants
+import com.otaliastudios.transcoder.internal.utils.Logger
+import com.otaliastudios.transcoder.internal.utils.TrackMap
+import java.nio.ByteBuffer
+import kotlin.properties.Delegates.observable
+
+/**
+ * Encoders are shared between segments. This is not strictly needed but it is more efficient
+ * and solves timestamp issues that arise due to the fact that MediaCodec can alter the timestamps
+ * internally, so if we use different MediaCodec instances we don't have guarantees on monotonic
+ * output timestamps, even if input timestamps are. This would later create crashes when passing
+ * data to MediaMuxer / MPEG4Writer.
+ */
+internal class Codecs(
+        private val sources: DataSources,
+        private val tracks: Tracks,
+        private val current: TrackMap<Int>
+) {
+
+    class Surface(
+        private val context: EglCore,
+        val window: EglWindowSurface,
+    ) {
+        fun release() {
+            window.release()
+            context.release()
+        }
+    }
+
+    class Codec(val codec: MediaCodec, val surface: Surface? = null, var log: Logger? = null) {
+        var dequeuedInputs by observable(0) { _, _, _ -> log?.v(state) }
+        var dequeuedOutputs by observable(0) { _, _, _ -> log?.v(state) }
+        val state get(): String = "dequeuedInputs=$dequeuedInputs dequeuedOutputs=$dequeuedOutputs heldInputs=${heldInputs.size}"
+
+        private val heldInputs = ArrayDeque<Pair<ByteBuffer, Int>>()
+
+        fun getInputBuffer(): Pair<ByteBuffer, Int>? {
+            if (heldInputs.isNotEmpty()) {
+                return heldInputs.removeFirst().also { log?.v(state) }
+            }
+            val id = codec.dequeueInputBuffer(100)
+            return if (id >= 0) {
+                dequeuedInputs++
+                val buf = checkNotNull(codec.getInputBuffer(id)) { "inputBuffer($id) should not be null." }
+                buf to id
+            } else {
+                log?.i("buffer() failed with $id. $state")
+                null
+            }
+        }
+
+        /**
+         * When we're not ready to write into this buffer, it can be held for later.
+         * Previously we were returning it to the codec with timestamp=0, flags=0, but especially
+         * on older Android versions that can create subtle issues.
+         * It's better to just keep the buffer here and reuse it on the next [getInputBuffer] call.
+         */
+        fun holdInputBuffer(buffer: ByteBuffer, id: Int) {
+            heldInputs.addLast(buffer to id)
+        }
+    }
+
+    private val log = Logger("Codecs")
+
+    val encoders = object : TrackMap<Codec> {
+
+        override fun has(type: TrackType) = tracks.all[type] == TrackStatus.COMPRESSING
+
+        private val lazyAudio by lazy {
+            val format = tracks.outputFormats.audio
+            val codec = MediaCodec.createEncoderByType(format.getString(MediaFormat.KEY_MIME)!!)
+            codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
+            Codec(codec, null)
+        }
+
+        private val lazyVideo by lazy {
+            val format = tracks.outputFormats.video
+            val width = format.getInteger(MediaFormat.KEY_WIDTH)
+            val height = format.getInteger(MediaFormat.KEY_HEIGHT)
+            log.i("Destination video surface size: ${width}x${height} @ ${format.getInteger(MediaFormatConstants.KEY_ROTATION_DEGREES)}")
+            log.i("Destination video format: $format")
+
+            val allCodecs = MediaCodecList(MediaCodecList.REGULAR_CODECS)
+            val videoEncoders = allCodecs.codecInfos.filter { it.isEncoder && it.supportedTypes.any { it.startsWith("video/") } }
+            log.i("Available encoders: ${videoEncoders.joinToString { "${it.name} (${it.supportedTypes.joinToString()})" }}")
+
+            // Could consider MediaCodecList(MediaCodecList.REGULAR_CODECS).findEncoderForFormat(format)
+            // But it's trickier, for example, format should not include frame rate on API 21 and maybe other quirks.
+            val codec = MediaCodec.createEncoderByType(format.getString(MediaFormat.KEY_MIME)!!)
+            codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
+            log.i("Selected encoder ${codec.name}")
+            val surface = codec.createInputSurface()
+
+            log.i("Creating OpenGL context on ${Thread.currentThread()} (${surface.isValid})")
+            val eglContext = EglCore(EGL14.EGL_NO_CONTEXT, EglCore.FLAG_RECORDABLE)
+            val eglWindow = EglWindowSurface(eglContext, surface, true)
+            eglWindow.makeCurrent()
+
+            // On API28 (possibly others) emulator, this happens. If we don't throw early, it fails later with unclear
+            // errors - a tombstone dump saying that src.width() & 1 == 0 (basically, complains that surface size is odd)
+            // and an error much later on during encoder's dequeue. Surface size is odd because it's 1x1.
+            val (eglWidth, eglHeight) = eglWindow.getWidth() to eglWindow.getHeight()
+            if (eglWidth != width || eglHeight != height) {
+                log.e("OpenGL surface has wrong size (expected: ${width}x${height}, found: ${eglWindow.getWidth()}x${eglWindow.getHeight()}).")
+                // Throw a clear error in this very specific scenario so we can catch it in tests.
+                if (codec.name == "c2.android.avc.encoder" && eglWidth == 1 && eglHeight == 1) {
+                    error("c2.android.avc.encoder was unable to create the input surface (1x1).")
+                }
+            }
+
+            Codec(codec, Surface(eglContext, eglWindow))
+        }
+
+        override fun get(type: TrackType) = when (type) {
+            TrackType.AUDIO -> lazyAudio
+            TrackType.VIDEO -> lazyVideo
+        }
+    }
+
+    val ownsEncoderStart = object : TrackMap<Boolean> {
+        override fun has(type: TrackType) = true
+        override fun get(type: TrackType) = current[type] == 0
+    }
+
+    val ownsEncoderStop = object : TrackMap<Boolean> {
+        override fun has(type: TrackType) = true
+        override fun get(type: TrackType) = current[type] == sources[type].lastIndex
+    }
+
+    fun release() {
+        encoders.forEach {
+            it.surface?.release()
+        }
+    }
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/DataSources.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/DataSources.kt
new file mode 100644
index 00000000..4201a56c
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/DataSources.kt
@@ -0,0 +1,87 @@
+package com.otaliastudios.transcoder.internal
+
+import com.otaliastudios.transcoder.ThumbnailerOptions
+import com.otaliastudios.transcoder.TranscoderOptions
+import com.otaliastudios.transcoder.common.TrackType
+import com.otaliastudios.transcoder.internal.utils.Logger
+import com.otaliastudios.transcoder.internal.utils.TrackMap
+import com.otaliastudios.transcoder.internal.utils.trackMapOf
+import com.otaliastudios.transcoder.source.BlankAudioDataSource
+import com.otaliastudios.transcoder.source.DataSource
+
+internal class DataSources private constructor(
+        videoSources: List<DataSource>,
+        audioSources: List<DataSource>,
+) : TrackMap<List<DataSource>> {
+
+    constructor(options: TranscoderOptions) : this(options.videoDataSources, options.audioDataSources)
+    constructor(options: ThumbnailerOptions) : this(options.dataSources, listOf())
+
+    private val log = Logger("DataSources")
+
+    private fun DataSource.init() = if (!isInitialized) initialize() else Unit
+    private fun DataSource.deinit() = if (isInitialized) deinitialize() else Unit
+    private fun List<DataSource>.init() = forEach {
+        log.i("initializing $it... (isInit=${it.isInitialized})")
+        it.init()
+    }
+    private fun List<DataSource>.deinit() = forEach {
+        log.i("deinitializing $it... (isInit=${it.isInitialized})")
+        it.deinit()
+    }
+
+    init {
+        log.i("initializing videoSources...")
+        videoSources.init()
+        log.i("initializing audioSources...")
+        audioSources.init()
+    }
+
+    // Save and deinit on release, because a source that is discarded for video
+    // might be active for audio. We don't want to deinit right away.
+    private val discarded = mutableListOf<DataSource>()
+
+    private val videoSources: List<DataSource> = run {
+        val valid = videoSources.count { it.getTrackFormat(TrackType.VIDEO) != null }
+        when (valid) {
+            0 -> listOf<DataSource>().also { discarded += videoSources }
+            videoSources.size -> videoSources
+            else -> videoSources // Tracks will crash
+        }
+    }
+
+    private val audioSources: List<DataSource> = run {
+        val valid = audioSources.count { it.getTrackFormat(TrackType.AUDIO) != null }
+        log.i("computing audioSources, valid=$valid")
+        when (valid) {
+            0 -> listOf<DataSource>().also { discarded += audioSources }
+            audioSources.size -> audioSources
+            else -> {
+                // Some tracks do not have audio, while some do. Replace with BlankAudio.
+                audioSources.map { source ->
+                    if (source.getTrackFormat(TrackType.AUDIO) != null) source
+                    else BlankAudioDataSource(source.durationUs).also {
+                        discarded += source
+                    }
+                }
+            }
+        }
+    }
+
+    override fun get(type: TrackType) = when (type) {
+        TrackType.AUDIO -> audioSources
+        TrackType.VIDEO -> videoSources
+    }
+
+    override fun has(type: TrackType) = this[type].isNotEmpty()
+
+    fun all() = (audio + video).distinct()
+
+    fun release() {
+        log.i("release(): releasing...")
+        video.deinit()
+        audio.deinit()
+        discarded.deinit()
+        log.i("release(): released.")
+    }
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/MediaCodecBuffers.java b/lib/src/main/java/com/otaliastudios/transcoder/internal/MediaCodecBuffers.java
deleted file mode 100644
index 86d60ead..00000000
--- a/lib/src/main/java/com/otaliastudios/transcoder/internal/MediaCodecBuffers.java
+++ /dev/null
@@ -1,59 +0,0 @@
-package com.otaliastudios.transcoder.internal;
-
-import android.media.MediaCodec;
-import android.os.Build;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import java.nio.ByteBuffer;
-
-/**
- * A Wrapper to MediaCodec that facilitates the use of API-dependent get{Input/Output}Buffer methods,
- * in order to prevent: http://stackoverflow.com/q/30646885
- */
-public class MediaCodecBuffers {
-
-    private final MediaCodec mMediaCodec;
-    private final ByteBuffer[] mInputBuffers;
-    private ByteBuffer[] mOutputBuffers;
-
-    public MediaCodecBuffers(@NonNull MediaCodec mediaCodec) {
-        mMediaCodec = mediaCodec;
-
-        if (Build.VERSION.SDK_INT < 21) {
-            mInputBuffers = mediaCodec.getInputBuffers();
-            mOutputBuffers = mediaCodec.getOutputBuffers();
-        } else {
-            mInputBuffers = mOutputBuffers = null;
-        }
-    }
-
-    @NonNull
-    public ByteBuffer getInputBuffer(final int index) {
-        if (Build.VERSION.SDK_INT >= 21) {
-            // This is nullable only for incorrect usage.
-            //noinspection ConstantConditions
-            return mMediaCodec.getInputBuffer(index);
-        }
-        ByteBuffer result = mInputBuffers[index];
-        result.clear();
-        return result;
-    }
-
-    @NonNull
-    public ByteBuffer getOutputBuffer(final int index) {
-        if (Build.VERSION.SDK_INT >= 21) {
-            // This is nullable only for incorrect usage.
-            //noinspection ConstantConditions
-            return mMediaCodec.getOutputBuffer(index);
-        }
-        return mOutputBuffers[index];
-    }
-
-    public void onOutputBuffersChanged() {
-        if (Build.VERSION.SDK_INT < 21) {
-            mOutputBuffers = mMediaCodec.getOutputBuffers();
-        }
-    }
-}
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/Segment.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/Segment.kt
new file mode 100644
index 00000000..74adba3e
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/Segment.kt
@@ -0,0 +1,36 @@
+package com.otaliastudios.transcoder.internal
+
+import com.otaliastudios.transcoder.common.TrackType
+import com.otaliastudios.transcoder.internal.pipeline.Pipeline
+import com.otaliastudios.transcoder.internal.pipeline.State
+
+internal class Segment(
+        val type: TrackType,
+        val index: Int,
+        private val pipeline: Pipeline,
+) {
+
+    // private val log = Logger("Segment($type,$index)")
+    private var state: State<Unit>? = null
+
+    fun advance(): Boolean {
+        state = pipeline.execute()
+        return state is State.Ok
+    }
+
+    fun canAdvance(): Boolean {
+        // log.v("canAdvance(): state=$state")
+        return state == null || state !is State.Eos
+    }
+
+    fun needsSleep(): Boolean {
+        when(val s = state ?: return false) {
+            is State.Ok -> return false
+            is State.Failure -> return s.sleep
+        }
+    }
+
+    fun release() {
+        pipeline.release()
+    }
+}
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/Segments.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/Segments.kt
new file mode 100644
index 00000000..fc666547
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/Segments.kt
@@ -0,0 +1,111 @@
+package com.otaliastudios.transcoder.internal
+
+import android.media.MediaFormat
+import com.otaliastudios.transcoder.common.TrackStatus
+import com.otaliastudios.transcoder.common.TrackType
+import com.otaliastudios.transcoder.internal.pipeline.Pipeline
+import com.otaliastudios.transcoder.internal.utils.Logger
+import com.otaliastudios.transcoder.internal.utils.TrackMap
+import com.otaliastudios.transcoder.internal.utils.mutableTrackMapOf
+
+internal class Segments(
+    private val sources: DataSources,
+    private val tracks: Tracks,
+    private val factory: (TrackType, Int, Int, TrackStatus, MediaFormat) -> Pipeline
+) {
+
+    private val log = Logger("Segments")
+    private val current = mutableTrackMapOf<Segment>(null, null)
+    val currentIndex = mutableTrackMapOf(-1, -1)
+    private val requestedIndex = mutableTrackMapOf(0, 0)
+
+    fun hasNext(type: TrackType): Boolean {
+        if (!sources.has(type)) return false
+        // log.v("hasNext($type): segment=${current.getOrNull(type)} lastIndex=${sources.getOrNull(type)?.lastIndex} canAdvance=${current.getOrNull(type)?.canAdvance()}")
+        val segment = current.getOrNull(type) ?: return true // not started
+        val lastIndex = sources.getOrNull(type)?.lastIndex ?: return false // no track!
+        return segment.canAdvance() || segment.index < lastIndex
+    }
+
+    fun hasNext() = hasNext(TrackType.VIDEO) || hasNext(TrackType.AUDIO)
+
+    /**
+     * Returns the [Segment] to be consumed. It is null if:
+     * - data sources don't have this type
+     * - transcoding for this track is over
+     */
+    fun next(type: TrackType): Segment? {
+        val currentIndex = currentIndex[type]
+        val requestedIndex = requestedIndex[type]
+        return when {
+            requestedIndex < currentIndex -> {
+                error("Requested index $requestedIndex smaller than $currentIndex.")
+            }
+            // We need to open a new segment, if possible.
+            // createSegment will make requested == current.
+            requestedIndex > currentIndex -> tryCreateSegment(type, requestedIndex)
+            // requested == current
+            current[type].canAdvance() -> current[type]
+            // requested == current, but this segment is finished:
+            // create a new one. destroySegment will increase the requested index,
+            // so if this is the last one, we'll return null.
+            else -> {
+                destroySegment(current[type])
+                next(type)
+            }
+        }
+    }
+
+    fun release() {
+        current.videoOrNull()?.let { destroySegment(it) }
+        current.audioOrNull()?.let { destroySegment(it) }
+    }
+
+    private fun tryCreateSegment(type: TrackType, index: Int): Segment? {
+        // Return null if out of bounds, either because segments are over or because the
+        // source set does not have sources for this track type.
+        val source = sources[type].getOrNull(index) ?: return null
+        log.i("tryCreateSegment($type, $index): created!")
+        if (tracks.active.has(type)) {
+            source.selectTrack(type)
+            // TODO this sucks
+            // By design, all sources must select all tracks before seeking. We can achieve this
+            // here but it still doesn't look right. The other source might be on a different
+            // point in the timeline, or it might be hidden because wrapped in a DataSourceWrapper...
+            // We have no checks for any of these options and they can all break concatenation.
+            val other = when (type) {
+                TrackType.AUDIO -> TrackType.VIDEO
+                TrackType.VIDEO -> TrackType.AUDIO
+            }
+            if (tracks.active.has(other) && sources[other].any { it === source }) {
+                source.selectTrack(other)
+            }
+        }
+        // Update current index before pipeline creation, for other components
+        // who check it during pipeline init.
+        currentIndex[type] = index
+        val pipeline = factory(
+            type,
+            index,
+            sources[type].size,
+            tracks.all[type],
+            tracks.outputFormats[type]
+        )
+        return Segment(
+            type = type,
+            index = index,
+            pipeline = pipeline
+        ).also {
+            current[type] = it
+        }
+    }
+
+    private fun destroySegment(segment: Segment) {
+        segment.release()
+        val source = sources[segment.type][segment.index]
+        if (tracks.active.has(segment.type)) {
+            source.releaseTrack(segment.type)
+        }
+        requestedIndex[segment.type] = segment.index + 1
+    }
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/Timer.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/Timer.kt
new file mode 100644
index 00000000..f3030e7f
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/Timer.kt
@@ -0,0 +1,108 @@
+package com.otaliastudios.transcoder.internal
+
+import com.otaliastudios.transcoder.common.TrackType
+import com.otaliastudios.transcoder.internal.utils.Logger
+import com.otaliastudios.transcoder.internal.utils.TrackMap
+import com.otaliastudios.transcoder.source.DataSource
+import com.otaliastudios.transcoder.time.TimeInterpolator
+
+internal class Timer(
+    private val interpolator: TimeInterpolator,
+    private val sources: DataSources,
+    private val tracks: Tracks,
+    private val current: TrackMap<Int>
+) {
+
+    private val log = Logger("Timer")
+
+    private fun List<DataSource>.durationUs(current: Int) = foldIndexed(0L) { index, acc, source ->
+        // If source has been drained, readUs can be more precise than durationUs
+        acc + if (index < current) source.positionUs else source.durationUs
+    }
+
+    private fun List<DataSource>.positionUs(current: Int) = foldIndexed(0L) { index, acc, source ->
+        if (index <= current) acc + source.positionUs else acc
+    }
+
+    val positionUs = object : TrackMap<Long> {
+        override fun has(type: TrackType) = true
+        override fun get(type: TrackType): Long {
+            return if (!tracks.active.has(type)) 0L
+            else sources[type].positionUs(current = current[type])
+        }
+    }
+
+    val durationUs = object : TrackMap<Long> {
+        override fun has(type: TrackType) = true
+        override fun get(type: TrackType): Long {
+            return if (!tracks.active.has(type)) 0L
+            else sources[type].durationUs(current = current[type])
+        }
+    }
+
+    val totalDurationUs: Long get() {
+        val video = if (tracks.active.hasVideo) durationUs.video else Long.MAX_VALUE
+        val audio = if (tracks.active.hasAudio) durationUs.audio else Long.MAX_VALUE
+        return minOf(video, audio)
+    }
+
+    val progress = object : TrackMap<Double> {
+        override fun has(type: TrackType) = true
+        override fun get(type: TrackType): Double {
+            val read = positionUs[type] // 0L if not active
+            val total = durationUs[type] // 0L if not active
+            return if (total == 0L) 0.0 else read.toDouble() / total
+        }
+    }
+
+    private val interpolators = mutableMapOf<Pair<TrackType, Int>, SegmentInterpolator>()
+
+    fun localize(type: TrackType, index: Int, positionUs: Long): Long? {
+        if (!tracks.active.has(type)) return null
+        val behindUs = sources[type]
+                .filterIndexed { i, _ -> i < index }
+                .durationUs(-1)
+        val localizedUs = positionUs - behindUs
+        if (localizedUs < 0L) return null
+        if (localizedUs > sources[type][index].durationUs) return null
+        return localizedUs
+    }
+
+    fun interpolator(type: TrackType, index: Int): SegmentInterpolator = interpolators.getOrPut(type to index) {
+        SegmentInterpolator(
+            log = Logger("${type.displayName}Interpolator$index/${sources[type].size}"),
+            user = interpolator,
+            previous = if (index == 0) null else interpolator(type, index - 1)
+        )
+    }
+
+    class SegmentInterpolator(
+        private val log: Logger,
+        private val user: TimeInterpolator,
+        previous: SegmentInterpolator?,
+    ) : TimeInterpolator {
+
+        private var inputBase = Long.MIN_VALUE
+        private var interpolatedLast = Long.MIN_VALUE
+        private var outputLast = Long.MIN_VALUE
+        private val outputBase by lazy {
+            when (previous) {
+                null -> 0L
+                // Not interpolated by user, so we give user interpolator a consistent stream.
+                // Add a bit of distance just so they're not identical, won't be noticeable.
+                else -> previous.outputLast + 1L
+            }.also {
+                log.i("Found output base timestamp: $it")
+            }
+        }
+
+        override fun interpolate(type: TrackType, time: Long): Long {
+            if (inputBase == Long.MIN_VALUE) inputBase = time
+            outputLast = outputBase + (time - inputBase)
+            return user.interpolate(type, outputLast).also {
+                check(it > interpolatedLast) { "Timestamps must be monotonically increasing: $it, $interpolatedLast" }
+                interpolatedLast = it
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/TrackTypeMap.java b/lib/src/main/java/com/otaliastudios/transcoder/internal/TrackTypeMap.java
deleted file mode 100644
index 6a8c514e..00000000
--- a/lib/src/main/java/com/otaliastudios/transcoder/internal/TrackTypeMap.java
+++ /dev/null
@@ -1,84 +0,0 @@
-package com.otaliastudios.transcoder.internal;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import com.otaliastudios.transcoder.engine.TrackType;
-
-import java.util.HashMap;
-import java.util.Map;
-
-/**
- * An utility class for storing data relative to a single {@link TrackType}
- * in a map, with handy nullability annotations.
- *
- * @param <T> the map type
- */
-public class TrackTypeMap<T> {
-
-    public TrackTypeMap() {
-    }
-
-    public TrackTypeMap(@NonNull T videoValue, @NonNull T audioValue) {
-        set(TrackType.AUDIO, audioValue);
-        set(TrackType.VIDEO, videoValue);
-    }
-
-    private Map<TrackType, T> map = new HashMap<>();
-
-    public void set(@NonNull TrackType type, @Nullable T value) {
-        //noinspection ConstantConditions
-        map.put(type, value);
-    }
-
-    public void setAudio(@Nullable T value) {
-        set(TrackType.AUDIO, value);
-    }
-
-    public void setVideo(@Nullable T value) {
-        set(TrackType.VIDEO, value);
-    }
-
-    @Nullable
-    public T get(@NonNull TrackType type) {
-        return map.get(type);
-    }
-
-    @Nullable
-    public T getAudio() {
-        return get(TrackType.AUDIO);
-    }
-
-    @Nullable
-    public T getVideo() {
-        return get(TrackType.VIDEO);
-    }
-
-    @NonNull
-    public T require(@NonNull TrackType type) {
-        //noinspection ConstantConditions
-        return map.get(type);
-    }
-
-    @NonNull
-    public T requireAudio() {
-        return require(TrackType.AUDIO);
-    }
-
-    @NonNull
-    public T requireVideo() {
-        return require(TrackType.VIDEO);
-    }
-
-    public boolean has(@NonNull TrackType type) {
-        return map.containsKey(type);
-    }
-
-    public boolean hasAudio() {
-        return has(TrackType.AUDIO);
-    }
-
-    public boolean hasVideo() {
-        return has(TrackType.VIDEO);
-    }
-}
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/Tracks.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/Tracks.kt
new file mode 100644
index 00000000..d091e8c6
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/Tracks.kt
@@ -0,0 +1,83 @@
+package com.otaliastudios.transcoder.internal
+
+import android.media.MediaFormat
+import com.otaliastudios.transcoder.common.TrackStatus
+import com.otaliastudios.transcoder.common.TrackType
+import com.otaliastudios.transcoder.internal.media.MediaFormatProvider
+import com.otaliastudios.transcoder.internal.utils.Logger
+import com.otaliastudios.transcoder.internal.utils.TrackMap
+import com.otaliastudios.transcoder.internal.utils.trackMapOf
+import com.otaliastudios.transcoder.source.DataSource
+import com.otaliastudios.transcoder.strategy.TrackStrategy
+
+internal class Tracks(
+        strategies: TrackMap<TrackStrategy>,
+        sources: DataSources,
+        videoRotation: Int,
+        forceCompression: Boolean
+) {
+
+    private val log = Logger("Tracks")
+
+    val all: TrackMap<TrackStatus>
+
+    val outputFormats: TrackMap<MediaFormat>
+
+    init {
+        val (audioFormat, audioStatus) = resolveTrack(TrackType.AUDIO, strategies.audio, sources.audioOrNull())
+        val (videoFormat, videoStatus) = resolveTrack(TrackType.VIDEO, strategies.video, sources.videoOrNull())
+        all = trackMapOf(
+                video = resolveVideoStatus(videoStatus, forceCompression, videoRotation),
+                audio = resolveAudioStatus(audioStatus, forceCompression)
+        )
+        outputFormats = trackMapOf(video = videoFormat, audio = audioFormat)
+        log.i("init: videoStatus=$videoStatus, resolvedVideoStatus=${all.video}, videoFormat=$videoFormat")
+        log.i("init: audioStatus=$audioStatus, resolvedAudioStatus=${all.audio}, audioFormat=$audioFormat")
+    }
+
+    val active: TrackMap<TrackStatus> = trackMapOf(
+            video = all.video.takeIf { it.isTranscoding },
+            audio = all.audio.takeIf { it.isTranscoding }
+    )
+
+    private fun resolveVideoStatus(status: TrackStatus, forceCompression: Boolean, rotation: Int): TrackStatus {
+        val force = forceCompression || rotation != 0
+        val canForce = status == TrackStatus.PASS_THROUGH
+        return if (canForce && force) TrackStatus.COMPRESSING else status
+    }
+
+    private fun resolveAudioStatus(status: TrackStatus, forceCompression: Boolean): TrackStatus {
+        val force = forceCompression
+        val canForce = status == TrackStatus.PASS_THROUGH
+        return if (canForce && force) TrackStatus.COMPRESSING else status
+    }
+
+    private fun resolveTrack(
+            type: TrackType,
+            strategy: TrackStrategy,
+            sources: List<DataSource>? // null or not-empty
+    ): Pair<MediaFormat, TrackStatus> {
+        log.i("resolveTrack($type), sources=${sources?.size}, strategy=${strategy::class.simpleName}")
+        if (sources == null) {
+            return MediaFormat() to TrackStatus.ABSENT
+        }
+
+        val provider = MediaFormatProvider()
+        val inputs = sources.mapNotNull {
+            val format = it.getTrackFormat(type) ?: return@mapNotNull null
+            provider.provideMediaFormat(it, type, format)
+        }
+
+        // The DataSources class already tries to address this for audio, by inserting
+        // a BlankAudioDataSource. However we still don't have a solution for video.
+        return when (inputs.size) {
+            0 -> MediaFormat() to TrackStatus.ABSENT
+            sources.size -> {
+                val output = MediaFormat()
+                val status = strategy.createOutputFormat(inputs, output)
+                output to status
+            }
+            else -> error("Of all $type sources, some have a $type track, some don't.")
+        }
+    }
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/ValidatorException.java b/lib/src/main/java/com/otaliastudios/transcoder/internal/ValidatorException.java
deleted file mode 100644
index d12813df..00000000
--- a/lib/src/main/java/com/otaliastudios/transcoder/internal/ValidatorException.java
+++ /dev/null
@@ -1,30 +0,0 @@
-/*
- * Copyright (C) 2014 Yuya Tanaka
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.otaliastudios.transcoder.internal;
-
-import com.otaliastudios.transcoder.validator.Validator;
-
-import androidx.annotation.Nullable;
-
-/**
- * An exception thrown internally when a {@link Validator}
- * returns false. Not to be used.
- */
-public class ValidatorException extends RuntimeException {
-    public ValidatorException(@Nullable String detailMessage) {
-        super(detailMessage);
-    }
-}
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/audio/AudioEngine.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/audio/AudioEngine.kt
new file mode 100644
index 00000000..33e03c5d
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/audio/AudioEngine.kt
@@ -0,0 +1,118 @@
+package com.otaliastudios.transcoder.internal.audio
+
+import android.media.MediaFormat
+import android.media.MediaFormat.*
+import android.view.Surface
+import com.otaliastudios.transcoder.internal.audio.remix.AudioRemixer
+import com.otaliastudios.transcoder.internal.codec.*
+import com.otaliastudios.transcoder.internal.pipeline.*
+import com.otaliastudios.transcoder.resample.AudioResampler
+import com.otaliastudios.transcoder.stretch.AudioStretcher
+import kotlin.math.ceil
+import kotlin.math.floor
+
+/**
+ * Performs audio rendering, from decoder output to encoder input, applying sample rate conversion,
+ * remixing, stretching. TODO: With some extra work this could be split in different steps.
+ */
+internal class AudioEngine(
+    private val stretcher: AudioStretcher,
+    private val resampler: AudioResampler,
+    private val targetFormat: MediaFormat
+): QueuedStep<DecoderData, DecoderChannel, EncoderData, EncoderChannel>("AudioEngine"), DecoderChannel {
+
+    override val channel = this
+    private val buffers = ShortBuffers()
+
+    private val MediaFormat.sampleRate get() = getInteger(KEY_SAMPLE_RATE)
+    private val MediaFormat.channels get() = getInteger(KEY_CHANNEL_COUNT)
+
+    private val chunks = ChunkQueue(log)
+    private var readyToDrain = false
+    private lateinit var rawFormat: MediaFormat
+    private lateinit var remixer: AudioRemixer
+
+    override fun handleSourceFormat(sourceFormat: MediaFormat): Surface? = null
+
+    override fun handleRawFormat(rawFormat: MediaFormat) {
+        log.i("handleRawFormat($rawFormat)")
+        this.rawFormat = rawFormat
+        this.remixer = AudioRemixer[rawFormat.channels, targetFormat.channels]
+        this.readyToDrain = true
+    }
+
+    override fun enqueueEos(data: DecoderData) {
+        log.i("enqueueEos (${chunks.size} in queue)")
+        data.release(false)
+        chunks.enqueueEos()
+    }
+
+    override fun enqueue(data: DecoderData) {
+        val stretch = (data as? DecoderTimerData)?.timeStretch ?: 1.0
+        chunks.enqueue(data.buffer.asShortBuffer(), data.timeUs, stretch) { data.release(false) }
+    }
+
+    override fun drain(): State<EncoderData> {
+        if (!readyToDrain) {
+            log.i("drain(): not ready, waiting... (${chunks.size} in queue)")
+            return State.Retry(false)
+        }
+        if (chunks.isEmpty()) {
+            // nothing was enqueued
+            log.i("drain(): no chunks, waiting...")
+            return State.Retry(false)
+        }
+        val (outBytes, outId) = next.buffer() ?: return run {
+            // dequeueInputBuffer failed
+            log.i("drain(): no next buffer, waiting... (${chunks.size} in queue)")
+            State.Retry(true)
+        }
+        val outBuffer = outBytes.asShortBuffer()
+        return chunks.drain(
+            eos = State.Eos(EncoderData(outBytes, outId, 0)),
+            format = rawFormat
+        ) { inBuffer, timeUs, stretch ->
+            val outSize = outBuffer.remaining()
+            val inSize = inBuffer.remaining()
+
+            // Compute the desired output size based on all steps that we'll go through
+            var desiredOutSize = ceil(inSize * stretch) // stretch
+            desiredOutSize = remixer.getRemixedSize(desiredOutSize.toInt()).toDouble() // remix
+            desiredOutSize = ceil(desiredOutSize * targetFormat.sampleRate / rawFormat.sampleRate) // resample
+
+            // See if we have enough room to process the whole input
+            val processableSize = if (desiredOutSize <= outSize) inSize else {
+                val factor = desiredOutSize / inSize
+                floor(outSize / factor).toInt()
+            }
+            inBuffer.limit(inBuffer.position() + processableSize)
+
+            // Stretching
+            val stretchSize = ceil(processableSize * stretch)
+            val stretchBuffer = buffers.acquire("stretch", stretchSize.toInt())
+            stretcher.stretch(inBuffer, stretchBuffer, rawFormat.channels)
+            stretchBuffer.flip()
+
+            // Remix
+            val remixSize = remixer.getRemixedSize(stretchSize.toInt())
+            val remixBuffer = buffers.acquire("remix", remixSize)
+            remixer.remix(stretchBuffer, remixBuffer)
+            remixBuffer.flip()
+
+            // Resample
+            resampler.resample(
+                remixBuffer, rawFormat.sampleRate,
+                outBuffer, targetFormat.sampleRate,
+                targetFormat.channels
+            )
+            outBuffer.flip()
+
+            // Adjust position and dispatch.
+            outBytes.clear()
+            outBytes.limit(outBuffer.limit() * BYTES_PER_SHORT)
+            outBytes.position(outBuffer.position() * BYTES_PER_SHORT)
+            log.v("drain(): passing buffer $outId to encoder... ${chunks.size} in queue")
+            State.Ok(EncoderData(outBytes, outId, timeUs))
+        }
+    }
+}
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/audio/chunks.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/audio/chunks.kt
new file mode 100644
index 00000000..cf8008a3
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/audio/chunks.kt
@@ -0,0 +1,107 @@
+package com.otaliastudios.transcoder.internal.audio
+
+import android.media.MediaFormat
+import android.media.MediaFormat.KEY_CHANNEL_COUNT
+import android.media.MediaFormat.KEY_SAMPLE_RATE
+import com.otaliastudios.transcoder.internal.utils.Logger
+import java.nio.ByteBuffer
+import java.nio.ShortBuffer
+
+private data class Chunk(
+        val buffer: ShortBuffer,
+        val timeUs: Long,
+        val timeStretch: Double,
+        val release: () -> Unit
+) {
+    companion object {
+        val Eos = Chunk(ShortBuffer.allocate(0), 0, 0.0, {})
+    }
+}
+
+/**
+ * FIFO queue for audio processing. Check [isEmpty] before [drain].
+ * Queuing is needed because, primarily, the output buffer that you have available is not
+ * big enough to contain the full processed size, in which case we want to consume only
+ * part of the input buffer and keep it available for the next cycle.
+ */
+internal class ChunkQueue(private val log: Logger) {
+    private val queue = ArrayDeque<Chunk>()
+    private val pool = ShortBufferPool()
+
+    fun isEmpty() = queue.isEmpty()
+    val size get() = queue.size
+
+    fun enqueue(buffer: ShortBuffer, timeUs: Long, timeStretch: Double, release: () -> Unit) {
+        if (buffer.hasRemaining()) {
+            if (queue.size >= 3) {
+                val copy = pool.take(buffer)
+                queue.addLast(Chunk(copy, timeUs, timeStretch, { pool.give(copy) }))
+                release()
+            } else {
+                queue.addLast(Chunk(buffer, timeUs, timeStretch, release))
+            }
+        } else {
+            log.w("enqueued invalid buffer ($timeUs, ${buffer.capacity()})")
+            release()
+        }
+    }
+
+    fun enqueueEos() {
+        queue.addLast(Chunk.Eos)
+    }
+
+    fun <T> drain(format: MediaFormat, eos: T, action: (buffer: ShortBuffer, timeUs: Long, timeStretch: Double) -> T): T {
+        val head = queue.removeFirst()
+        if (head === Chunk.Eos) return eos
+
+        val size = head.buffer.remaining()
+        val limit = head.buffer.limit()
+        val result = action(head.buffer, head.timeUs, head.timeStretch)
+        // Action can reduce the limit for any reason. Restore it before comparing sizes.
+        head.buffer.limit(limit)
+        if (head.buffer.hasRemaining()) {
+            // We could technically hold onto the same chunk, but in practice it's better to
+            // release input buffers back to the decoder otherwise it can get stuck
+            val consumed = size - head.buffer.remaining()
+            val sampleRate = format.getInteger(KEY_SAMPLE_RATE)
+            val channelCount = format.getInteger(KEY_CHANNEL_COUNT)
+            val buffer = pool.take(head.buffer)
+            head.release()
+            queue.addFirst(head.copy(
+                timeUs = shortsToUs(consumed, sampleRate, channelCount),
+                release = { pool.give(buffer) },
+                buffer = buffer
+            ))
+            log.v("drain(): partially handled chunk at ${head.timeUs}us, ${head.buffer.remaining()} bytes left (${queue.size})")
+        } else {
+            // buffer consumed!
+            log.v("drain(): consumed chunk at ${head.timeUs}us (${queue.size + 1} => ${queue.size})")
+            head.release()
+        }
+        return result
+    }
+}
+
+
+class ShortBufferPool {
+    private val pool = mutableListOf<ShortBuffer>()
+
+    fun take(original: ShortBuffer): ShortBuffer {
+        val needed = original.remaining()
+        val index = pool.indexOfFirst { it.capacity() >= needed }
+        val memory = when {
+            index >= 0 -> pool.removeAt(index)
+            else -> ByteBuffer.allocateDirect((needed * Short.SIZE_BYTES).coerceAtLeast(1024))
+                .order(original.order())
+                .asShortBuffer()
+        }
+        memory.put(original)
+        memory.flip()
+        return memory
+    }
+
+    fun give(buffer: ShortBuffer) {
+        buffer.clear()
+        pool.add(buffer)
+    }
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/audio/conversions.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/audio/conversions.kt
new file mode 100644
index 00000000..6ca67e3f
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/audio/conversions.kt
@@ -0,0 +1,40 @@
+package com.otaliastudios.transcoder.internal.audio
+
+import kotlin.math.ceil
+
+private const val BYTES_PER_SAMPLE_PER_CHANNEL = 2 // Assuming 16bit audio, so 2
+private const val MICROSECONDS_PER_SECOND = 1000000L
+
+internal fun bytesToUs(
+        bytes: Int /* bytes */,
+        sampleRate: Int /* samples/sec */,
+        channels: Int /* channel */
+): Long {
+    val byteRatePerChannel = sampleRate * BYTES_PER_SAMPLE_PER_CHANNEL // bytes/sec/channel
+    val byteRate = byteRatePerChannel * channels // bytes/sec
+    return MICROSECONDS_PER_SECOND * bytes / byteRate // usec
+}
+
+internal fun bitRate(sampleRate: Int, channels: Int): Int {
+    val byteRate = channels * sampleRate * BYTES_PER_SAMPLE_PER_CHANNEL
+    return byteRate * 8
+}
+
+internal fun samplesToBytes(samples: Int, channels: Int): Int {
+    val bytesPerSample = BYTES_PER_SAMPLE_PER_CHANNEL * channels
+    return samples * bytesPerSample
+}
+
+internal fun usToBytes(us: Long, sampleRate: Int, channels: Int): Int {
+    val byteRatePerChannel = sampleRate * BYTES_PER_SAMPLE_PER_CHANNEL
+    val byteRate = byteRatePerChannel * channels
+    return ceil(us.toDouble() * byteRate / MICROSECONDS_PER_SECOND).toInt()
+}
+
+internal fun shortsToUs(shorts: Int, sampleRate: Int, channels: Int): Long {
+    return bytesToUs(shorts * BYTES_PER_SHORT, sampleRate, channels)
+}
+
+internal fun usToShorts(us: Long, sampleRate: Int, channels: Int): Int {
+    return usToBytes(us, sampleRate, channels) / BYTES_PER_SHORT
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/audio/remix/AudioRemixer.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/audio/remix/AudioRemixer.kt
new file mode 100644
index 00000000..1810d0a6
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/audio/remix/AudioRemixer.kt
@@ -0,0 +1,33 @@
+package com.otaliastudios.transcoder.internal.audio.remix
+
+import java.nio.ShortBuffer
+
+/**
+ * Remixes audio data. See [DownMixAudioRemixer], [UpMixAudioRemixer] or [PassThroughAudioRemixer]
+ * for concrete implementations.
+ */
+internal interface AudioRemixer {
+
+    /**
+     * Remixes input audio from input buffer into the output buffer.
+     * The output buffer is guaranteed to have a [ShortBuffer.remaining] size that is
+     * consistent with [getRemixedSize].
+     */
+    fun remix(inputBuffer: ShortBuffer, outputBuffer: ShortBuffer)
+
+    /**
+     * Returns the output size (in shorts) needed to process an input buffer of the
+     * given [inputSize] (in shorts).
+     */
+    fun getRemixedSize(inputSize: Int): Int
+
+    companion object {
+        internal operator fun get(inputChannels: Int, outputChannels: Int): AudioRemixer = when {
+            inputChannels == outputChannels -> PassThroughAudioRemixer()
+            inputChannels !in setOf(1, 2) -> error("Input channel count not supported: $inputChannels")
+            outputChannels !in setOf(1, 2) -> error("Output channel count not supported: $outputChannels")
+            inputChannels < outputChannels -> UpMixAudioRemixer()
+            else -> DownMixAudioRemixer()
+        }
+    }
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/remix/DownMixAudioRemixer.java b/lib/src/main/java/com/otaliastudios/transcoder/internal/audio/remix/DownMixAudioRemixer.java
similarity index 93%
rename from lib/src/main/java/com/otaliastudios/transcoder/remix/DownMixAudioRemixer.java
rename to lib/src/main/java/com/otaliastudios/transcoder/internal/audio/remix/DownMixAudioRemixer.java
index 6385fdd8..ac75e1da 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/remix/DownMixAudioRemixer.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/audio/remix/DownMixAudioRemixer.java
@@ -1,7 +1,9 @@
-package com.otaliastudios.transcoder.remix;
+package com.otaliastudios.transcoder.internal.audio.remix;
 
 import androidx.annotation.NonNull;
 
+import com.otaliastudios.transcoder.internal.audio.remix.AudioRemixer;
+
 import java.nio.ShortBuffer;
 
 /**
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/remix/PassThroughAudioRemixer.java b/lib/src/main/java/com/otaliastudios/transcoder/internal/audio/remix/PassThroughAudioRemixer.java
similarity index 77%
rename from lib/src/main/java/com/otaliastudios/transcoder/remix/PassThroughAudioRemixer.java
rename to lib/src/main/java/com/otaliastudios/transcoder/internal/audio/remix/PassThroughAudioRemixer.java
index 717d7390..00c4deb3 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/remix/PassThroughAudioRemixer.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/audio/remix/PassThroughAudioRemixer.java
@@ -1,7 +1,9 @@
-package com.otaliastudios.transcoder.remix;
+package com.otaliastudios.transcoder.internal.audio.remix;
 
 import androidx.annotation.NonNull;
 
+import com.otaliastudios.transcoder.internal.audio.remix.AudioRemixer;
+
 import java.nio.ShortBuffer;
 
 /**
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/remix/UpMixAudioRemixer.java b/lib/src/main/java/com/otaliastudios/transcoder/internal/audio/remix/UpMixAudioRemixer.java
similarity index 86%
rename from lib/src/main/java/com/otaliastudios/transcoder/remix/UpMixAudioRemixer.java
rename to lib/src/main/java/com/otaliastudios/transcoder/internal/audio/remix/UpMixAudioRemixer.java
index a0e27ff0..b331c033 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/remix/UpMixAudioRemixer.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/audio/remix/UpMixAudioRemixer.java
@@ -1,7 +1,9 @@
-package com.otaliastudios.transcoder.remix;
+package com.otaliastudios.transcoder.internal.audio.remix;
 
 import androidx.annotation.NonNull;
 
+import com.otaliastudios.transcoder.internal.audio.remix.AudioRemixer;
+
 import java.nio.ShortBuffer;
 
 /**
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/audio/shorts.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/audio/shorts.kt
new file mode 100644
index 00000000..80959159
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/audio/shorts.kt
@@ -0,0 +1,25 @@
+package com.otaliastudios.transcoder.internal.audio
+
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import java.nio.ShortBuffer
+
+internal const val BYTES_PER_SHORT = 2
+
+internal class ShortBuffers {
+    private val map = mutableMapOf<String, ShortBuffer>()
+
+    fun acquire(name: String, size: Int): ShortBuffer {
+        var current = map[name]
+        if (current == null || current.capacity() < size) {
+            current = ByteBuffer.allocateDirect(size * BYTES_PER_SHORT)
+                    .order(ByteOrder.nativeOrder())
+                    .asShortBuffer()
+        }
+        current!!.clear()
+        current.limit(size)
+        return current.also {
+            map[name] = current
+        }
+    }
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/codec/Decoder.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/codec/Decoder.kt
new file mode 100644
index 00000000..635536a6
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/codec/Decoder.kt
@@ -0,0 +1,124 @@
+package com.otaliastudios.transcoder.internal.codec
+
+import android.media.MediaCodec.*
+import android.media.MediaFormat
+import android.view.Surface
+import com.otaliastudios.transcoder.common.TrackType
+import com.otaliastudios.transcoder.common.trackType
+import com.otaliastudios.transcoder.internal.Codecs
+import com.otaliastudios.transcoder.internal.data.ReaderChannel
+import com.otaliastudios.transcoder.internal.data.ReaderData
+import com.otaliastudios.transcoder.internal.pipeline.Channel
+import com.otaliastudios.transcoder.internal.pipeline.QueuedStep
+import com.otaliastudios.transcoder.internal.pipeline.State
+import java.nio.ByteBuffer
+
+
+internal open class DecoderData(
+    val buffer: ByteBuffer,
+    val timeUs: Long,
+    val release: (render: Boolean) -> Unit
+)
+
+internal interface DecoderChannel : Channel {
+    fun handleSourceFormat(sourceFormat: MediaFormat): Surface?
+    fun handleRawFormat(rawFormat: MediaFormat)
+}
+
+internal class Decoder(
+    private val format: MediaFormat, // source.getTrackFormat(track)
+    continuous: Boolean, // relevant if the source sends no-render chunks. should we compensate or not?
+) : QueuedStep<ReaderData, ReaderChannel, DecoderData, DecoderChannel>(
+    when (format.trackType) {
+        TrackType.VIDEO -> "VideoDecoder"
+        TrackType.AUDIO -> "AudioDecoder"
+    }
+), ReaderChannel {
+
+    override val channel = this
+
+    init {
+        log.i("init: instantiating codec...")
+    }
+    private val decoder = Codecs.Codec(createDecoderByType(format.getString(MediaFormat.KEY_MIME)!!), null, log)
+    private var info = BufferInfo()
+    private val dropper = DecoderDropper(continuous)
+
+    private var surfaceRendering = false
+    private val surfaceRenderingDummyBuffer = ByteBuffer.allocateDirect(0)
+
+    override fun initialize(next: DecoderChannel) {
+        super.initialize(next)
+        log.i("initialize()")
+        val surface = next.handleSourceFormat(format)
+        surfaceRendering = surface != null
+        decoder.codec.configure(format, surface, null, 0)
+        decoder.codec.start()
+    }
+
+    override fun buffer(): Pair<ByteBuffer, Int>? = decoder.getInputBuffer()
+
+    override fun enqueueEos(data: ReaderData) {
+        log.i("enqueueEos()!")
+        decoder.dequeuedInputs--
+        decoder.codec.queueInputBuffer(data.id, 0, 0, 0, BUFFER_FLAG_END_OF_STREAM)
+    }
+
+    override fun enqueue(data: ReaderData) {
+        decoder.dequeuedInputs--
+        val (chunk, id) = data
+        val flag = if (chunk.keyframe) BUFFER_FLAG_SYNC_FRAME else 0
+        log.v("enqueued ${chunk.buffer.remaining()} bytes (${chunk.timeUs}us)")
+        decoder.codec.queueInputBuffer(id, chunk.buffer.position(), chunk.buffer.remaining(), chunk.timeUs, flag)
+        dropper.input(chunk.timeUs, chunk.render)
+    }
+
+    override fun drain(): State<DecoderData> {
+        val result = decoder.codec.dequeueOutputBuffer(info, 100)
+        return when (result) {
+            INFO_TRY_AGAIN_LATER -> {
+                log.i("drain(): got INFO_TRY_AGAIN_LATER, waiting.")
+                State.Retry(true)
+            }
+            INFO_OUTPUT_FORMAT_CHANGED -> {
+                log.i("drain(): got INFO_OUTPUT_FORMAT_CHANGED, handling format and retrying. format=${decoder.codec.outputFormat}")
+                next.handleRawFormat(decoder.codec.outputFormat)
+                drain()
+            }
+            INFO_OUTPUT_BUFFERS_CHANGED -> {
+                log.i("drain(): got INFO_OUTPUT_BUFFERS_CHANGED, retrying.")
+                drain()
+            }
+            else -> {
+                val isEos = info.flags and BUFFER_FLAG_END_OF_STREAM != 0
+                val timeUs = if (isEos) 0 else dropper.output(info.presentationTimeUs)
+                if (timeUs != null /* && (isEos || info.size > 0) */) {
+                    val codecBuffer = decoder.codec.getOutputBuffer(result)
+                    val buffer = when {
+                        codecBuffer != null -> codecBuffer
+                        surfaceRendering -> surfaceRenderingDummyBuffer // happens, at least on API28 emulator
+                        else -> error("outputBuffer($result, ${info.size}, ${info.offset}, ${info.flags}) should not be null.")
+                    }
+                    decoder.dequeuedOutputs++
+                    val data = DecoderData(buffer, timeUs) {
+                        decoder.codec.releaseOutputBuffer(result, it)
+                        decoder.dequeuedOutputs--
+                    }
+                    if (isEos) State.Eos(data) else State.Ok(data)
+                } else {
+                    // frame was dropped, no need to sleep
+                    decoder.codec.releaseOutputBuffer(result, false)
+                    State.Retry(false)
+                }.also {
+                    log.v("drain(): returning $it")
+                }
+            }
+        }
+    }
+
+    override fun release() {
+        log.i("release: releasing codec. ${decoder.state}")
+        decoder.codec.stop()
+        decoder.codec.release()
+    }
+}
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/codec/DecoderDropper.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/codec/DecoderDropper.kt
new file mode 100644
index 00000000..9809b2a4
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/codec/DecoderDropper.kt
@@ -0,0 +1,92 @@
+package com.otaliastudios.transcoder.internal.codec
+
+import com.otaliastudios.transcoder.internal.utils.Logger
+import com.otaliastudios.transcoder.source.DataSource
+
+/**
+ * Hard to read, late-night class that takes decoder input frames along with their render
+ * boolean coming from [DataSource.Chunk.render] and understands which periods of time should be
+ * rendered and which shouldn't.
+ *
+ * These ranges are then matched in the decoder raw output frames. Those that do not belong to
+ * a render period are dropped, the others are rendered but their timestamp is shifted according
+ * to the no-render periods behind. So we do two things:
+ *
+ * 1. don't render [output] frames belonging to [input] frames that had render = false
+ * 2. adjust [output] timestamps to account for the no-render periods
+ *
+ * The second feature can be disabled by setting the [continuous] boolean to false.
+ *
+ * NOTE: we assumes that the [input] timestamps are monotonic. If they are not, everything
+ * is screwed. Also, if the source jumps forward using seek, we won't catch the jump. This class
+ * catches discontinuities only through changes in the render boolean passed to [input].
+ */
+internal class DecoderDropper(private val continuous: Boolean) {
+
+    private val log = Logger("DecoderDropper")
+    private val closedDeltas = mutableMapOf<LongRange, Long>()
+    private val closedRanges = mutableListOf<LongRange>()
+    private var pendingRange: LongRange? = null
+
+    private var firstInputUs: Long? = null
+    private var firstOutputUs: Long? = null
+
+    private fun debug(message: String, important: Boolean = false) {
+        /* val full = "$message firstInputUs=$firstInputUs " +
+                "validInputUs=[${closedRanges.joinToString {
+                    "$it(deltaUs=${closedDeltas[it]})"
+                }}] pendingRangeUs=${pendingRange}"
+        if (important) log.w(full) else log.v(full) */
+    }
+
+    fun input(timeUs: Long, render: Boolean) {
+        if (firstInputUs == null) {
+            firstInputUs = timeUs
+        }
+        if (render) {
+            debug("INPUT: inputUs=$timeUs")
+            // log.v("TDBG inputUs=$timeUs")
+            if (pendingRange == null) pendingRange = timeUs..Long.MAX_VALUE
+            else pendingRange = pendingRange!!.first..timeUs
+        } else {
+            debug("INPUT: Got SKIPPING input! inputUs=$timeUs")
+            if (pendingRange != null && pendingRange!!.last != Long.MAX_VALUE) {
+                closedRanges.add(pendingRange!!)
+                closedDeltas[pendingRange!!] = if (closedRanges.size >= 2) {
+                    pendingRange!!.first - closedRanges[closedRanges.lastIndex - 1].last
+                } else 0L
+            }
+            pendingRange = null
+        }
+    }
+
+    fun output(timeUs: Long): Long? {
+        if (firstOutputUs == null) {
+            firstOutputUs = timeUs
+        }
+        val timeInInputScaleUs = firstInputUs!! + (timeUs - firstOutputUs!!)
+        var deltaUs = 0L
+        closedRanges.forEach {
+            deltaUs += closedDeltas[it]!!
+            if (it.contains(timeInInputScaleUs)) {
+                debug("OUTPUT: Rendering! outputTimeUs=$timeUs newOutputTimeUs=${timeUs - deltaUs} deltaUs=$deltaUs")
+                // log.v("TDBG outputUs=$timeUs")
+                return if (continuous) timeUs - deltaUs
+                else timeUs
+            }
+        }
+        if (pendingRange != null) {
+            if (pendingRange!!.contains(timeInInputScaleUs)) {
+                if (closedRanges.isNotEmpty()) {
+                    deltaUs += pendingRange!!.first - closedRanges.last().last
+                }
+                debug("OUTPUT: Rendering! outputTimeUs=$timeUs newOutputTimeUs=${timeUs - deltaUs} deltaUs=$deltaUs")
+                // log.v("TDBG outputUs=$timeUs")
+                return if (continuous) timeUs - deltaUs
+                else timeUs
+            }
+        }
+        debug("OUTPUT: SKIPPING! outputTimeUs=$timeUs", important = true)
+        return null
+    }
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/codec/DecoderTimer.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/codec/DecoderTimer.kt
new file mode 100644
index 00000000..6b5adf9c
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/codec/DecoderTimer.kt
@@ -0,0 +1,52 @@
+package com.otaliastudios.transcoder.internal.codec
+
+import com.otaliastudios.transcoder.common.TrackType
+import com.otaliastudios.transcoder.internal.pipeline.TransformStep
+import com.otaliastudios.transcoder.internal.pipeline.State
+import com.otaliastudios.transcoder.time.TimeInterpolator
+import java.nio.ByteBuffer
+
+internal class DecoderTimerData(
+        buffer: ByteBuffer,
+        val rawTimeUs: Long,
+        timeUs: Long,
+        val timeStretch: Double,
+        release: (render: Boolean) -> Unit
+) : DecoderData(buffer, timeUs, release)
+
+internal class DecoderTimer(
+    private val track: TrackType,
+    private val interpolator: TimeInterpolator,
+) : TransformStep<DecoderData, DecoderChannel>("DecoderTimer") {
+
+    private var lastTimeUs: Long = Long.MIN_VALUE
+    private var lastRawTimeUs: Long = Long.MIN_VALUE
+
+    override fun advance(state: State.Ok<DecoderData>): State<DecoderData> {
+        if (state is State.Eos) return state
+        require(state.value !is DecoderTimerData) {
+            "Can't apply DecoderTimer twice."
+        }
+        val rawTimeUs = state.value.timeUs
+        val timeUs = interpolator.interpolate(track, rawTimeUs)
+        val timeStretch = if (lastTimeUs == Long.MIN_VALUE) {
+            1.0
+        } else {
+            // TODO to be exact, timeStretch should be computed by comparing the NEXT timestamps
+            //  with this, instead of comparing this with the PREVIOUS
+            val durationUs = timeUs - lastTimeUs
+            val rawDurationUs = rawTimeUs - lastRawTimeUs
+            durationUs.toDouble() / rawDurationUs
+        }
+        lastTimeUs = timeUs
+        lastRawTimeUs = rawTimeUs
+
+        return State.Ok(DecoderTimerData(
+            buffer = state.value.buffer,
+            rawTimeUs = rawTimeUs,
+            timeUs = timeUs,
+            timeStretch = timeStretch,
+            release = state.value.release
+        ))
+    }
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/codec/Encoder.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/codec/Encoder.kt
new file mode 100644
index 00000000..38fcfdfc
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/codec/Encoder.kt
@@ -0,0 +1,137 @@
+package com.otaliastudios.transcoder.internal.codec
+
+import android.media.MediaCodec.*
+import com.otaliastudios.transcoder.common.TrackType
+import com.otaliastudios.transcoder.internal.Codecs
+import com.otaliastudios.transcoder.internal.data.WriterChannel
+import com.otaliastudios.transcoder.internal.data.WriterData
+import com.otaliastudios.transcoder.internal.pipeline.Channel
+import com.otaliastudios.transcoder.internal.pipeline.QueuedStep
+import com.otaliastudios.transcoder.internal.pipeline.State
+import java.nio.ByteBuffer
+
+internal data class EncoderData(
+        val buffer: ByteBuffer?, // If present, it must have correct position/remaining!
+        val id: Int,
+        val timeUs: Long
+) {
+    companion object { val Empty = EncoderData(null, 0, 0L) }
+}
+
+internal interface EncoderChannel : Channel {
+    val surface: Codecs.Surface?
+    fun buffer(): Pair<ByteBuffer, Int>?
+}
+
+internal class Encoder(
+    private val encoder: Codecs.Codec,
+    ownsCodecStart: Boolean,
+    private val ownsCodecStop: Boolean,
+) : QueuedStep<EncoderData, EncoderChannel, WriterData, WriterChannel>(
+    when (encoder.surface) {
+        null -> "AudioEncoder"
+        else -> "VideoEncoder"
+    }
+), EncoderChannel {
+
+    constructor(codecs: Codecs, type: TrackType) : this(
+        codecs.encoders[type],
+        codecs.ownsEncoderStart[type],
+        codecs.ownsEncoderStop[type]
+    )
+
+    override val surface: Codecs.Surface? get() = encoder.surface
+    override val channel = this
+
+    private var info = BufferInfo()
+
+    init {
+        encoder.log = log
+        log.i("ownsStart=$ownsCodecStart ownsStop=$ownsCodecStop ${encoder.state}")
+        if (ownsCodecStart) {
+            encoder.codec.start()
+        }
+    }
+
+    override fun buffer(): Pair<ByteBuffer, Int>? = encoder.getInputBuffer()
+
+    private var eosReceivedButNotEnqueued = false
+
+    override fun enqueueEos(data: EncoderData) {
+        if (surface == null) {
+            if (ownsCodecStop) {
+                encoder.codec.queueInputBuffer(data.id, 0, 0, 0, BUFFER_FLAG_END_OF_STREAM)
+                encoder.dequeuedInputs--
+            } else {
+                eosReceivedButNotEnqueued = true
+                encoder.holdInputBuffer(data.buffer!!, data.id)
+            }
+        } else {
+            if (!ownsCodecStop) eosReceivedButNotEnqueued = true
+            else encoder.codec.signalEndOfInputStream()
+        }
+    }
+
+    override fun enqueue(data: EncoderData) {
+        if (surface != null) return
+        else {
+            val buffer = requireNotNull(data.buffer) { "Audio should always pass a buffer to Encoder." }
+            encoder.codec.queueInputBuffer(data.id, buffer.position(), buffer.remaining(), data.timeUs, 0)
+            encoder.dequeuedInputs--
+        }
+    }
+
+    override fun drain(): State<WriterData> {
+        val timeoutUs = if (eosReceivedButNotEnqueued) 5000L else 100L
+        return when (val result = encoder.codec.dequeueOutputBuffer(info, timeoutUs)) {
+            INFO_TRY_AGAIN_LATER -> {
+                if (eosReceivedButNotEnqueued) {
+                    // Horrible hack. When we don't own the MediaCodec, we can't enqueue EOS so we
+                    // can't dequeue them. INFO_TRY_AGAIN_LATER is returned. We assume this means EOS.
+                    log.i("Sending fake Eos. ${encoder.state}")
+                    val buffer = ByteBuffer.allocateDirect(0)
+                    State.Eos(WriterData(buffer, 0L, 0) {})
+                } else {
+                    log.i("Can't dequeue output buffer: INFO_TRY_AGAIN_LATER")
+                    State.Retry(true)
+                }
+            }
+            INFO_OUTPUT_FORMAT_CHANGED -> {
+                log.i("INFO_OUTPUT_FORMAT_CHANGED! format=${encoder.codec.outputFormat}")
+                next.handleFormat(encoder.codec.outputFormat)
+                drain()
+            }
+            INFO_OUTPUT_BUFFERS_CHANGED -> {
+                drain()
+            }
+            else -> {
+                val isConfig = info.flags and BUFFER_FLAG_CODEC_CONFIG != 0
+                if (isConfig) {
+                    encoder.codec.releaseOutputBuffer(result, false)
+                    drain()
+                } else {
+                    encoder.dequeuedOutputs++
+                    val isEos = info.flags and BUFFER_FLAG_END_OF_STREAM != 0
+                    val flags = info.flags and BUFFER_FLAG_END_OF_STREAM.inv()
+                    val buffer = checkNotNull(encoder.codec.getOutputBuffer(result)) { "outputBuffer($result) should not be null." }
+                    val timeUs = info.presentationTimeUs
+                    buffer.clear()
+                    buffer.limit(info.offset + info.size)
+                    buffer.position(info.offset)
+                    val data = WriterData(buffer, timeUs, flags) {
+                        encoder.codec.releaseOutputBuffer(result, false)
+                        encoder.dequeuedOutputs--
+                    }
+                    if (isEos) State.Eos(data) else State.Ok(data)
+                }
+            }
+        }
+    }
+
+    override fun release() {
+        log.i("release(): ownsStop=$ownsCodecStop ${encoder.state}")
+        if (ownsCodecStop) {
+            encoder.codec.stop()
+        }
+    }
+}
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/data/Bridge.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/data/Bridge.kt
new file mode 100644
index 00000000..8bd3529b
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/data/Bridge.kt
@@ -0,0 +1,36 @@
+package com.otaliastudios.transcoder.internal.data
+
+import android.media.MediaCodec
+import android.media.MediaFormat
+import com.otaliastudios.transcoder.internal.pipeline.BaseStep
+import com.otaliastudios.transcoder.internal.pipeline.State
+import com.otaliastudios.transcoder.internal.pipeline.Step
+import com.otaliastudios.transcoder.internal.utils.Logger
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+
+internal class Bridge(private val format: MediaFormat)
+    : BaseStep<ReaderData, ReaderChannel, WriterData, WriterChannel>("Bridge"), ReaderChannel {
+
+    private val bufferSize = format.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE)
+    private val buffer = ByteBuffer.allocateDirect(bufferSize).order(ByteOrder.nativeOrder())
+    override val channel = this
+
+    override fun buffer(): Pair<ByteBuffer, Int> {
+        buffer.clear()
+        return buffer to 0
+    }
+
+    override fun initialize(next: WriterChannel) {
+        log.i("initialize(): format=$format")
+        next.handleFormat(format)
+    }
+
+    // Can't do much about chunk.render, since we don't even decode.
+    override fun advance(state: State.Ok<ReaderData>): State<WriterData> {
+        val (chunk, _) = state.value
+        val flags = if (chunk.keyframe) MediaCodec.BUFFER_FLAG_SYNC_FRAME else 0
+        val result = WriterData(chunk.buffer, chunk.timeUs, flags) {}
+        return if (state is State.Eos) State.Eos(result) else State.Ok(result)
+    }
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/data/Reader.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/data/Reader.kt
new file mode 100644
index 00000000..c663f6b7
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/data/Reader.kt
@@ -0,0 +1,58 @@
+package com.otaliastudios.transcoder.internal.data
+
+import com.otaliastudios.transcoder.common.TrackType
+import com.otaliastudios.transcoder.internal.pipeline.BaseStep
+import com.otaliastudios.transcoder.internal.pipeline.Channel
+import com.otaliastudios.transcoder.internal.pipeline.State
+import com.otaliastudios.transcoder.source.DataSource
+import java.nio.ByteBuffer
+
+
+internal data class ReaderData(val chunk: DataSource.Chunk, val id: Int)
+
+internal interface ReaderChannel : Channel {
+    fun buffer(): Pair<ByteBuffer, Int>?
+}
+
+internal class Reader(
+    private val source: DataSource,
+    private val track: TrackType
+) : BaseStep<Unit, Channel, ReaderData, ReaderChannel>("Reader") {
+
+    override val channel = Channel
+    private val chunk = DataSource.Chunk()
+
+    private inline fun nextBufferOrWait(action: (ByteBuffer, Int) -> State<ReaderData>): State<ReaderData> {
+        val buffer = next.buffer()
+        if (buffer == null) {
+            // dequeueInputBuffer failed
+            log.v("Returning State.Wait because buffer is null.")
+            return State.Retry(true)
+        } else {
+            return action(buffer.first, buffer.second)
+        }
+    }
+
+    override fun advance(state: State.Ok<Unit>): State<ReaderData> {
+        return if (source.isDrained) {
+            log.i("Source is drained! Returning Eos as soon as possible.")
+            nextBufferOrWait { byteBuffer, id ->
+                byteBuffer.limit(0)
+                chunk.buffer = byteBuffer
+                chunk.keyframe = false
+                chunk.render = true
+                State.Eos(ReaderData(chunk, id))
+            }
+        } else if (!source.canReadTrack(track)) {
+            log.i("Returning State.Wait because source can't read $track right now.")
+            State.Retry(false)
+        } else {
+            nextBufferOrWait { byteBuffer, id ->
+                chunk.buffer = byteBuffer
+                source.readTrack(chunk)
+                // log.v("Returning ${chunk.buffer?.remaining() ?: -1} bytes from source")
+                State.Ok(ReaderData(chunk, id))
+            }
+        }
+    }
+}
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/data/ReaderTimer.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/data/ReaderTimer.kt
new file mode 100644
index 00000000..b0996afe
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/data/ReaderTimer.kt
@@ -0,0 +1,17 @@
+package com.otaliastudios.transcoder.internal.data
+
+import com.otaliastudios.transcoder.common.TrackType
+import com.otaliastudios.transcoder.internal.pipeline.TransformStep
+import com.otaliastudios.transcoder.internal.pipeline.State
+import com.otaliastudios.transcoder.time.TimeInterpolator
+
+internal class ReaderTimer(
+    private val track: TrackType,
+    private val interpolator: TimeInterpolator
+) : TransformStep<ReaderData, ReaderChannel>("ReaderTimer") {
+    override fun advance(state: State.Ok<ReaderData>): State<ReaderData> {
+        if (state is State.Eos) return state
+        state.value.chunk.timeUs = interpolator.interpolate(track, state.value.chunk.timeUs)
+        return state
+    }
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/data/Seeker.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/data/Seeker.kt
new file mode 100644
index 00000000..4e2ade8d
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/data/Seeker.kt
@@ -0,0 +1,32 @@
+package com.otaliastudios.transcoder.internal.data
+
+import com.otaliastudios.transcoder.common.TrackType
+import com.otaliastudios.transcoder.internal.pipeline.BaseStep
+import com.otaliastudios.transcoder.internal.pipeline.Channel
+import com.otaliastudios.transcoder.internal.pipeline.State
+import com.otaliastudios.transcoder.internal.utils.Logger
+import com.otaliastudios.transcoder.source.DataSource
+import java.nio.ByteBuffer
+
+internal class Seeker(
+    private val source: DataSource,
+    positions: List<Long>,
+    private val seek: (Long) -> Boolean
+) : BaseStep<Unit, Channel, Unit, Channel>("Seeker") {
+
+    override val channel = Channel
+    private val positions = positions.toMutableList()
+
+    override fun advance(state: State.Ok<Unit>): State<Unit> {
+        if (positions.isNotEmpty()) {
+            if (seek(positions.first())) {
+                log.i("Seeking to next position ${positions.first()}")
+                val next = positions.removeFirst()
+                source.seekTo(next)
+            } else {
+                // log.v("Not seeking to next Request. head=${positions.first()}")
+            }
+        }
+        return state
+    }
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/data/Writer.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/data/Writer.kt
new file mode 100644
index 00000000..323edbf0
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/data/Writer.kt
@@ -0,0 +1,61 @@
+package com.otaliastudios.transcoder.internal.data
+
+import android.media.MediaCodec
+import android.media.MediaFormat
+import com.otaliastudios.transcoder.common.TrackType
+import com.otaliastudios.transcoder.internal.pipeline.BaseStep
+import com.otaliastudios.transcoder.internal.pipeline.Channel
+import com.otaliastudios.transcoder.internal.pipeline.State
+import com.otaliastudios.transcoder.internal.pipeline.Step
+import com.otaliastudios.transcoder.internal.utils.Logger
+import com.otaliastudios.transcoder.sink.DataSink
+import java.nio.ByteBuffer
+
+internal data class WriterData(
+        val buffer: ByteBuffer,
+        val timeUs: Long,
+        val flags: Int,
+        val release: () -> Unit
+)
+
+internal interface WriterChannel : Channel {
+    fun handleFormat(format: MediaFormat)
+}
+
+internal class Writer(
+    private val sink: DataSink,
+    private val track: TrackType
+) : BaseStep<WriterData, WriterChannel, Unit, Channel>("Writer"), WriterChannel {
+
+    override val channel = this
+
+    private val info = MediaCodec.BufferInfo()
+
+    override fun handleFormat(format: MediaFormat) {
+        log.i("handleFormat($format)")
+        sink.setTrackFormat(track, format)
+    }
+
+    override fun advance(state: State.Ok<WriterData>): State<Unit> {
+        val (buffer, timestamp, flags) = state.value
+        // Note: flags does NOT include BUFFER_FLAG_END_OF_STREAM. That's passed via State.Eos.
+        val eos = state is State.Eos
+        if (eos) {
+            // Note: it may happen that at this point, buffer has some data. but creating an extra writeTrack() call
+            // can cause some crashes that were not properly debugged, probably related to wrong timestamp.
+            // I think if we could ensure that timestamp is valid (> previous, > 0) and buffer.hasRemaining(), there should
+            // be an extra call here. See #159. Reluctant to do so without a repro test.
+            info.set(0, 0, 0, flags or MediaCodec.BUFFER_FLAG_END_OF_STREAM)
+        } else {
+            info.set(
+                buffer.position(),
+                buffer.remaining(),
+                timestamp,
+                flags
+            )
+        }
+        sink.writeTrack(track, buffer, info)
+        state.value.release()
+        return if (eos) State.Eos(Unit) else State.Ok(Unit)
+    }
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/MediaFormatConstants.java b/lib/src/main/java/com/otaliastudios/transcoder/internal/media/MediaFormatConstants.java
similarity index 95%
rename from lib/src/main/java/com/otaliastudios/transcoder/internal/MediaFormatConstants.java
rename to lib/src/main/java/com/otaliastudios/transcoder/internal/media/MediaFormatConstants.java
index a9160dfd..085d8bfd 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/internal/MediaFormatConstants.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/media/MediaFormatConstants.java
@@ -13,9 +13,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.otaliastudios.transcoder.internal;
+package com.otaliastudios.transcoder.internal.media;
 
-@SuppressWarnings("unused")
 public class MediaFormatConstants {
 
     // from MediaFormat of API level >= 21, but might be usable in older APIs as native code implementation exists.
@@ -53,6 +52,7 @@ public class MediaFormatConstants {
     // Audio formats
     // from MediaFormat of API level >= 21
     public static final String MIMETYPE_AUDIO_AAC = "audio/mp4a-latm";
+    public static final String MIMETYPE_AUDIO_RAW = "audio/raw";
 
     private MediaFormatConstants() { }
 }
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/engine/MediaFormatProvider.java b/lib/src/main/java/com/otaliastudios/transcoder/internal/media/MediaFormatProvider.java
similarity index 85%
rename from lib/src/main/java/com/otaliastudios/transcoder/engine/MediaFormatProvider.java
rename to lib/src/main/java/com/otaliastudios/transcoder/internal/media/MediaFormatProvider.java
index 736b930f..d642ec81 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/engine/MediaFormatProvider.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/media/MediaFormatProvider.java
@@ -1,4 +1,4 @@
-package com.otaliastudios.transcoder.engine;
+package com.otaliastudios.transcoder.internal.media;
 
 import android.media.MediaCodec;
 import android.media.MediaFormat;
@@ -6,7 +6,7 @@
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 
-import com.otaliastudios.transcoder.internal.MediaCodecBuffers;
+import com.otaliastudios.transcoder.common.TrackType;
 import com.otaliastudios.transcoder.source.DataSource;
 
 import java.io.IOException;
@@ -20,7 +20,7 @@
  * This class will check the completeness of the input format and if needed, provide a more
  * complete format by decoding the input file until MediaCodec computes all values.
  */
-class MediaFormatProvider {
+public class MediaFormatProvider {
 
     /**
      * Inspects the given format - coming from {@link DataSource#getTrackFormat(TrackType)},
@@ -32,7 +32,7 @@ class MediaFormatProvider {
      * @return a complete format
      */
     @NonNull
-    MediaFormat provideMediaFormat(@NonNull DataSource source,
+    public MediaFormat provideMediaFormat(@NonNull DataSource source,
                                    @NonNull TrackType type,
                                    @NonNull MediaFormat format) {
         // If this format is already complete, there's nothing we should do.
@@ -99,14 +99,14 @@ private MediaFormat decodeMediaFormat(@NonNull DataSource source,
             throw new RuntimeException("Can't decode this track", e);
         }
         decoder.start();
-        MediaCodecBuffers buffers = new MediaCodecBuffers(decoder);
         MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
         DataSource.Chunk chunk = new DataSource.Chunk();
         MediaFormat result = null;
         while (result == null) {
-            result = decodeOnce(type, source, chunk, decoder, buffers, info);
+            result = decodeOnce(type, source, chunk, decoder, info);
         }
-        source.rewind();
+        source.deinitialize();
+        source.initialize();
         return result;
     }
 
@@ -115,18 +115,16 @@ private MediaFormat decodeOnce(@NonNull TrackType type,
                                    @NonNull DataSource source,
                                    @NonNull DataSource.Chunk chunk,
                                    @NonNull MediaCodec decoder,
-                                   @NonNull MediaCodecBuffers buffers,
                                    @NonNull MediaCodec.BufferInfo info) {
         // First drain then feed.
-        MediaFormat format = drainOnce(decoder, buffers, info);
+        MediaFormat format = drainOnce(decoder, info);
         if (format != null) return format;
-        feedOnce(type, source, chunk, decoder, buffers);
+        feedOnce(type, source, chunk, decoder);
         return null;
     }
 
     @Nullable
     private MediaFormat drainOnce(@NonNull MediaCodec decoder,
-                                  @NonNull MediaCodecBuffers buffers,
                                   @NonNull MediaCodec.BufferInfo info) {
         int result = decoder.dequeueOutputBuffer(info, 0);
         switch (result) {
@@ -135,8 +133,7 @@ private MediaFormat drainOnce(@NonNull MediaCodec decoder,
             case MediaCodec.INFO_OUTPUT_FORMAT_CHANGED:
                 return decoder.getOutputFormat();
             case MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED:
-                buffers.onOutputBuffersChanged();
-                return drainOnce(decoder, buffers, info);
+                return drainOnce(decoder, info);
             default: // Drop this data immediately.
                 decoder.releaseOutputBuffer(result, false);
                 return null;
@@ -146,19 +143,18 @@ private MediaFormat drainOnce(@NonNull MediaCodec decoder,
     private void feedOnce(@NonNull TrackType type,
                           @NonNull DataSource source,
                           @NonNull DataSource.Chunk chunk,
-                          @NonNull MediaCodec decoder,
-                          @NonNull MediaCodecBuffers buffers) {
+                          @NonNull MediaCodec decoder) {
         if (!source.canReadTrack(type)) {
             throw new RuntimeException("This should never happen!");
         }
         final int result = decoder.dequeueInputBuffer(0);
         if (result < 0) return;
-        chunk.buffer = buffers.getInputBuffer(result);
+        chunk.buffer = decoder.getInputBuffer(result);
         source.readTrack(chunk);
         decoder.queueInputBuffer(result,
-                0,
-                chunk.bytes,
-                chunk.timestampUs,
-                chunk.isKeyFrame ? MediaCodec.BUFFER_FLAG_SYNC_FRAME : 0);
+                chunk.buffer.position(),
+                chunk.buffer.remaining(),
+                chunk.timeUs,
+                chunk.keyframe ? MediaCodec.BUFFER_FLAG_SYNC_FRAME : 0);
     }
 }
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/pipeline/Pipeline.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/pipeline/Pipeline.kt
new file mode 100644
index 00000000..85fd5ab0
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/pipeline/Pipeline.kt
@@ -0,0 +1,197 @@
+package com.otaliastudios.transcoder.internal.pipeline
+
+import com.otaliastudios.transcoder.internal.utils.Logger
+
+
+
+private class PipelineItem(
+    val step: Step<Any, Channel, Any, Channel>,
+    val name: String,
+) {
+    // var success: State.Ok<Any>? = null
+    // var failure: State.Retry? = null
+    val unhandled = ArrayDeque<State.Ok<Any>>()
+    var done = false
+    var advanced = false
+    var packets = 0
+    private var nextUnhandled: ArrayDeque<State.Ok<Any>>? = null
+
+    fun attachToNext(next: PipelineItem) {
+        nextUnhandled = next.unhandled
+        step.initialize(next = next.step.channel)
+    }
+
+    fun canHandle(first: Boolean): Boolean {
+        if (done) return false
+        if (first) {
+            unhandled.clear()
+            unhandled.addLast(State.Ok(Unit))
+        }
+        return unhandled.isNotEmpty() || step is QueuedStep
+    }
+
+    fun handle(): State.Failure? {
+        advanced = false
+        while (unhandled.isNotEmpty() && !done) {
+            val input = unhandled.removeFirst()
+            when (val result = step.advance(input)) {
+                is State.Ok -> {
+                    packets++
+                    advanced = true
+                    done = result is State.Eos
+                    nextUnhandled?.addLast(result)
+                }
+                is State.Retry -> {
+                    unhandled.addFirst(input)
+                    return result
+                }
+                is State.Consume -> return result
+            }
+        }
+        if (!advanced && !done && step is QueuedStep) {
+            when (val result = step.tryAdvance()) {
+                is State.Ok -> {
+                    packets++
+                    advanced = true
+                    done = result is State.Eos
+                    nextUnhandled?.addLast(result)
+                }
+                is State.Failure -> return result
+            }
+        }
+        return null
+    }
+}
+
+internal class Pipeline private constructor(name: String, private val items: List<PipelineItem>) {
+
+    private val log = Logger(name)
+
+    init {
+        items.zipWithNext().reversed().forEach { (first, next) -> first.attachToNext(next) }
+    }
+
+    fun execute(): State<Unit> {
+        log.v("LOOP")
+        var advanced = false
+        var sleeps = false
+
+        for (i in items.indices) {
+            val item = items[i]
+
+            if (item.canHandle(i == 0)) {
+                log.v("${item.name} START #${item.packets} (${item.unhandled.size} pending)")
+                val failure = item.handle()
+                if (failure != null) {
+                    sleeps = sleeps || failure.sleep
+                    log.v("${item.name} FAILED #${item.packets}")
+                } else {
+                    log.v("${item.name} SUCCESS #${item.packets} ${if (item.done) "(eos)" else ""}")
+                }
+                advanced = advanced || item.advanced
+            } else {
+                log.v("${item.name} SKIP #${item.packets} ${if (item.done) "(eos)" else ""}")
+            }
+        }
+        return when {
+            items.isEmpty() -> State.Eos(Unit)
+            items.last().done -> State.Eos(Unit)
+            advanced -> State.Ok(Unit)
+            else -> State.Retry(sleeps)
+        }
+    }
+
+    /*fun execute_OLD(): State<Unit> {
+        var headState: State.Ok<Any> = State.Ok(Unit)
+        var headFresh = true
+        // In case of failure in the previous run, we should re-run all items before the failed one
+        // This is important for decoders/encoders that need more input before they can output
+        val previouslyFailedIndex = items.indexOfLast { it.failure != null }.takeIf { it >= 0 }
+        log.v("LOOP (previouslyFailed: ${previouslyFailedIndex})")
+        for (i in items.indices) {
+            val item = items[i]
+            val cached = item.success
+            val skip = cached is State.Eos || (!headFresh && cached != null)
+            if (skip) {
+                // Note: we could consider a retry() on queued steps here but it's risky
+                // because the current 'cached' value may have never been handled by the next item
+                log.v("${i+1}/${items.size} '${item.step.name}' SKIP ${if (cached is State.Eos) "(eos)" else "(handled)"}")
+                headState = cached!!
+                headFresh = false
+                continue
+            }
+            // This item did not succeed at the last loop, or we have fresh input data.
+            log.v("${i+1}/${items.size} '${item.step.name}' START (${if (headFresh) "fresh" else "stale"})")
+            val result = when {
+                !headFresh && item.step is QueuedStep -> item.step.retry() // queued steps should never get stale data
+                else -> item.step.advance(headState)
+            }
+            item.set(result)
+            if (result is State.Ok) {
+                log.v("${i+1}/${items.size} '${item.step.name}' SUCCESS ${if (result is State.Eos) "(eos)" else ""}")
+                headState = result
+                headFresh = true
+                if (i == items.lastIndex) items.forEach {
+                    if (it.success !is State.Eos) it.set(null)
+                }
+                continue
+            }
+            // Item failed. Check if we had a later failure in the previous run. In that case
+            // we should retry that too. Note: `cached` should always be not null in this branch
+            // but let's avoid throwing
+            if (previouslyFailedIndex != null && i < previouslyFailedIndex) {
+                if (cached != null) {
+                    log.v("${i+1}/${items.size} '${item.step.name}' FAILED (skip)")
+                    item.set(cached) // keep 'cached' for next run
+                    headState = cached
+                    headFresh = false
+                    continue
+                }
+            }
+
+            // Item failed: don't proceed. Return early.
+            log.v("${i+1}/${items.size} '${item.step.name}' FAILED")
+            return State.Wait(item.failure!!.sleep)
+        }
+        return when {
+            items.isEmpty() -> State.Eos(Unit)
+            headState is State.Eos -> State.Eos(Unit)
+            else -> State.Ok(Unit)
+        }
+    } */
+
+    fun release() {
+        items.forEach { it.step.release() }
+    }
+
+    companion object {
+        internal fun build(name: String, debug: String? = null, builder: () -> Builder<*, Channel> = { Builder<Unit, Channel>() }): Pipeline {
+            val steps = builder().steps
+            val items = steps.mapIndexed { index, step ->
+                @Suppress("UNCHECKED_CAST")
+                PipelineItem(
+                    step = step as Step<Any, Channel, Any, Channel>,
+                    name = "${index+1}/${steps.size} '${step.name}'"
+                )
+            }
+            return Pipeline("${name}Pipeline${debug ?: ""}", items)
+        }
+    }
+
+    class Builder<D: Any, C: Channel> internal constructor(
+            internal val steps: List<Step<*, *, *, *>> = listOf()
+    ) {
+        operator fun <NewData: Any, NewChannel: Channel> plus(
+                step: Step<D, C, NewData, NewChannel>
+        ): Builder<NewData, NewChannel>  = Builder(steps + step)
+    }
+}
+
+internal operator fun <
+        CurrData: Any, CurrChannel: Channel,
+        NewData: Any, NewChannel: Channel
+> Step<Unit, Channel, CurrData, CurrChannel>.plus(
+        other: Step<CurrData, CurrChannel, NewData, NewChannel>
+): Pipeline.Builder<NewData, NewChannel> {
+    return Pipeline.Builder<CurrData, CurrChannel>(listOf(this)) + other
+}
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/pipeline/State.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/pipeline/State.kt
new file mode 100644
index 00000000..2ffc6c51
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/pipeline/State.kt
@@ -0,0 +1,27 @@
+package com.otaliastudios.transcoder.internal.pipeline
+
+internal sealed interface State<out T> {
+
+    // Running
+    open class Ok<T>(val value: T) : State<T> {
+        override fun toString() = "State.Ok($value)"
+    }
+
+    // Run for the last time
+    class Eos<T>(value: T) : Ok<T>(value) {
+        override fun toString() = "State.Eos($value)"
+    }
+
+    // Failed to produce output, try again later
+    sealed interface Failure : State<Nothing> {
+        val sleep: Boolean
+    }
+
+    class Retry(override val sleep: Boolean) : Failure {
+        override fun toString() = "State.Retry($sleep)"
+    }
+
+    class Consume(override val sleep: Boolean = false) : Failure {
+        override fun toString() = "State.Consume($sleep)"
+    }
+}
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/pipeline/Step.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/pipeline/Step.kt
new file mode 100644
index 00000000..cc234575
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/pipeline/Step.kt
@@ -0,0 +1,22 @@
+package com.otaliastudios.transcoder.internal.pipeline
+
+// TODO this could be Any
+internal interface Channel {
+    companion object : Channel
+}
+
+internal interface Step<
+    Input: Any,
+    InputChannel: Channel,
+    Output: Any,
+    OutputChannel: Channel
+> {
+    val name: String
+    val channel: InputChannel
+
+    fun initialize(next: OutputChannel) = Unit
+
+    fun advance(state: State.Ok<Input>): State<Output>
+
+    fun release() = Unit
+}
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/pipeline/pipelines.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/pipeline/pipelines.kt
new file mode 100644
index 00000000..6f46892d
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/pipeline/pipelines.kt
@@ -0,0 +1,86 @@
+package com.otaliastudios.transcoder.internal.pipeline
+
+import android.media.MediaFormat
+import com.otaliastudios.transcoder.common.TrackType
+import com.otaliastudios.transcoder.internal.Codecs
+import com.otaliastudios.transcoder.internal.audio.AudioEngine
+import com.otaliastudios.transcoder.internal.data.*
+import com.otaliastudios.transcoder.internal.data.Reader
+import com.otaliastudios.transcoder.internal.data.ReaderTimer
+import com.otaliastudios.transcoder.internal.data.Writer
+import com.otaliastudios.transcoder.internal.codec.Decoder
+import com.otaliastudios.transcoder.internal.codec.DecoderTimer
+import com.otaliastudios.transcoder.internal.codec.Encoder
+import com.otaliastudios.transcoder.internal.video.VideoPublisher
+import com.otaliastudios.transcoder.internal.video.VideoRenderer
+import com.otaliastudios.transcoder.resample.AudioResampler
+import com.otaliastudios.transcoder.sink.DataSink
+import com.otaliastudios.transcoder.source.DataSource
+import com.otaliastudios.transcoder.stretch.AudioStretcher
+import com.otaliastudios.transcoder.time.TimeInterpolator
+
+internal fun EmptyPipeline() = Pipeline.build("Empty")
+
+internal fun PassThroughPipeline(
+    track: TrackType,
+    source: DataSource,
+    sink: DataSink,
+    interpolator: TimeInterpolator
+) = Pipeline.build("PassThrough$track") {
+    Reader(source, track) +
+            ReaderTimer(track, interpolator) +
+            Bridge(source.getTrackFormat(track)!!) +
+            Writer(sink, track)
+}
+
+internal fun RegularPipeline(
+    track: TrackType,
+    debug: String?,
+    source: DataSource,
+    sink: DataSink,
+    interpolator: TimeInterpolator,
+    format: MediaFormat,
+    codecs: Codecs,
+    videoRotation: Int,
+    audioStretcher: AudioStretcher,
+    audioResampler: AudioResampler
+) = when (track) {
+    TrackType.VIDEO -> VideoPipeline(debug, source, sink, interpolator, format, codecs, videoRotation)
+    TrackType.AUDIO -> AudioPipeline(debug, source, sink, interpolator, format, codecs, audioStretcher, audioResampler)
+}
+
+private fun VideoPipeline(
+    debug: String?,
+    source: DataSource,
+    sink: DataSink,
+    interpolator: TimeInterpolator,
+    format: MediaFormat,
+    codecs: Codecs,
+    videoRotation: Int
+) = Pipeline.build("Video", debug) {
+    Reader(source, TrackType.VIDEO) +
+            Decoder(source.getTrackFormat(TrackType.VIDEO)!!, true) +
+            DecoderTimer(TrackType.VIDEO, interpolator) +
+            VideoRenderer(source.orientation, videoRotation, format) +
+            VideoPublisher() +
+            Encoder(codecs, TrackType.VIDEO) +
+            Writer(sink, TrackType.VIDEO)
+}
+
+private fun AudioPipeline(
+    debug: String?,
+    source: DataSource,
+    sink: DataSink,
+    interpolator: TimeInterpolator,
+    format: MediaFormat,
+    codecs: Codecs,
+    audioStretcher: AudioStretcher,
+    audioResampler: AudioResampler
+) = Pipeline.build("Audio", debug) {
+    Reader(source, TrackType.AUDIO) +
+            Decoder(source.getTrackFormat(TrackType.AUDIO)!!, true) +
+            DecoderTimer(TrackType.AUDIO, interpolator) +
+            AudioEngine(audioStretcher, audioResampler, format) +
+            Encoder(codecs, TrackType.AUDIO) +
+            Writer(sink, TrackType.AUDIO)
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/pipeline/steps.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/pipeline/steps.kt
new file mode 100644
index 00000000..8b39aa7f
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/pipeline/steps.kt
@@ -0,0 +1,57 @@
+package com.otaliastudios.transcoder.internal.pipeline
+
+import com.otaliastudios.transcoder.internal.utils.Logger
+
+internal abstract class BaseStep<
+    Input: Any,
+    InputChannel: Channel,
+    Output: Any,
+    OutputChannel: Channel
+>(final override val name: String) : Step<Input, InputChannel, Output, OutputChannel> {
+
+    protected val log = Logger(name)
+
+    protected lateinit var next: OutputChannel
+    private set
+
+    override fun initialize(next: OutputChannel) {
+        this.next = next
+    }
+}
+
+internal abstract class TransformStep<D: Any, C: Channel>(name: String) : BaseStep<D, C, D, C>(name) {
+    override lateinit var channel: C
+    override fun initialize(next: C) {
+        super.initialize(next)
+        channel = next
+    }
+}
+
+internal abstract class QueuedStep<
+    Input: Any,
+    InputChannel: Channel,
+    Output: Any,
+    OutputChannel: Channel
+>(name: String) : BaseStep<Input, InputChannel, Output, OutputChannel>(name) {
+
+    protected abstract fun enqueue(data: Input)
+
+    protected abstract fun enqueueEos(data: Input)
+
+    protected abstract fun drain(): State<Output>
+
+    final override fun advance(state: State.Ok<Input>): State<Output> {
+        if (state is State.Eos) enqueueEos(state.value)
+        else enqueue(state.value)
+        // Disallow State.Retry because the input was already handled.
+        return when (val result = drain()) {
+            is State.Retry -> State.Consume(result.sleep)
+            else -> result
+        }
+    }
+
+    fun tryAdvance(): State<Output> {
+        return drain()
+    }
+
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/thumbnails/DefaultThumbnailsEngine.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/thumbnails/DefaultThumbnailsEngine.kt
new file mode 100644
index 00000000..13a07bb6
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/thumbnails/DefaultThumbnailsEngine.kt
@@ -0,0 +1,141 @@
+package com.otaliastudios.transcoder.internal.thumbnails
+
+import android.graphics.Bitmap
+import android.media.MediaFormat
+import android.media.MediaFormat.KEY_HEIGHT
+import android.media.MediaFormat.KEY_WIDTH
+import android.opengl.GLES20
+import com.otaliastudios.opengl.core.Egloo
+import com.otaliastudios.transcoder.common.TrackStatus
+import com.otaliastudios.transcoder.common.TrackType
+import com.otaliastudios.transcoder.internal.DataSources
+import com.otaliastudios.transcoder.internal.Segments
+import com.otaliastudios.transcoder.internal.Timer
+import com.otaliastudios.transcoder.internal.Tracks
+import com.otaliastudios.transcoder.internal.codec.Decoder
+import com.otaliastudios.transcoder.internal.codec.EncoderChannel
+import com.otaliastudios.transcoder.internal.codec.EncoderData
+import com.otaliastudios.transcoder.internal.data.Reader
+import com.otaliastudios.transcoder.internal.data.Seeker
+import com.otaliastudios.transcoder.internal.pipeline.*
+import com.otaliastudios.transcoder.internal.pipeline.Channel
+import com.otaliastudios.transcoder.internal.pipeline.EmptyPipeline
+import com.otaliastudios.transcoder.internal.pipeline.Pipeline
+import com.otaliastudios.transcoder.internal.utils.*
+import com.otaliastudios.transcoder.internal.utils.forcingEos
+import com.otaliastudios.transcoder.internal.video.VideoPublisher
+import com.otaliastudios.transcoder.internal.video.VideoRenderer
+import com.otaliastudios.transcoder.internal.video.VideoSnapshots
+import com.otaliastudios.transcoder.resize.Resizer
+import com.otaliastudios.transcoder.strategy.DefaultVideoStrategy
+import com.otaliastudios.transcoder.strategy.RemoveTrackStrategy
+import com.otaliastudios.transcoder.thumbnail.Thumbnail
+import com.otaliastudios.transcoder.thumbnail.ThumbnailRequest
+import com.otaliastudios.transcoder.time.DefaultTimeInterpolator
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import kotlin.math.abs
+
+internal class DefaultThumbnailsEngine(
+        private val dataSources: DataSources,
+        private val rotation: Int,
+        resizer: Resizer,
+        requests: List<ThumbnailRequest>
+) : ThumbnailsEngine() {
+
+    private val log = Logger("ThumbnailsEngine")
+
+    // Huge framerate triks the VideoRenderer into not dropping frames, which is important
+    // for thumbnail requests that want to catch the very last frame.
+    private val tracks = Tracks(trackMapOf(
+            video = DefaultVideoStrategy.Builder()
+                    .frameRate(120)
+                    .addResizer(resizer)
+                    .build(),
+            audio = RemoveTrackStrategy()
+    ), dataSources, rotation, true)
+
+    private val segments = Segments(dataSources, tracks, ::createPipeline)
+
+    private val timer = Timer(DefaultTimeInterpolator(), dataSources, tracks, segments.currentIndex)
+
+    init {
+        log.i("Created Tracks, Segments, Timer...")
+    }
+
+    private val positions = requests.flatMap { request ->
+        val duration = timer.totalDurationUs
+        request.locate(duration).map { it to request }
+    }.sortedBy { it.first }
+
+    private class Stub(
+            val request: ThumbnailRequest,
+            val positionUs: Long,
+            val localizedUs: Long) {
+        var actualLocalizedUs: Long = localizedUs
+    }
+
+    private fun createPipeline(
+            type: TrackType,
+            index: Int,
+            count: Int,
+            status: TrackStatus,
+            outputFormat: MediaFormat
+    ): Pipeline {
+        log.i("Creating pipeline #$index. absoluteUs=${positions.joinToString { it.first.toString() }}")
+        val stubs = positions.mapNotNull { (positionUs, request) ->
+            val localizedUs = timer.localize(type, index, positionUs)
+            localizedUs?.let { Stub(request, positionUs, localizedUs) }
+        }.toMutableList()
+
+        if (stubs.isEmpty()) return EmptyPipeline()
+        val source = dataSources[type][index].forcingEos {
+            stubs.isEmpty()
+        }
+        val positions = stubs.map { it.localizedUs }
+        log.i("Requests for step #$index: ${positions.joinToString()} [duration=${source.durationUs}]")
+        return Pipeline.build("Thumbnails") {
+            Seeker(source, positions) { it == stubs.firstOrNull()?.localizedUs } +
+                    Reader(source, type) +
+                    Decoder(source.getTrackFormat(type)!!, continuous = false) +
+                    VideoRenderer(source.orientation, rotation, outputFormat, flipY = true) +
+                    VideoSnapshots(outputFormat, positions, 50 * 1000) { pos, bitmap ->
+                        val stub = stubs.removeFirst()
+                        stub.actualLocalizedUs = pos
+                        log.i("Got snapshot. positionUs=${stub.positionUs} " +
+                                "localizedUs=${stub.localizedUs} " +
+                                "actualLocalizedUs=${stub.actualLocalizedUs} " +
+                                "deltaUs=${stub.localizedUs - stub.actualLocalizedUs}")
+                        val thumbnail = Thumbnail(stub.request, stub.positionUs, bitmap)
+                        progress(thumbnail)
+                    }
+        }
+    }
+
+    private lateinit var progress: (Thumbnail) -> Unit
+
+    override fun thumbnails(progress: (Thumbnail) -> Unit) {
+        this.progress = progress
+        while (true) {
+            val advanced = segments.next(TrackType.VIDEO)?.advance() ?: false
+            val completed = !advanced && !segments.hasNext() // avoid calling hasNext if we advanced.
+            if (Thread.interrupted()) {
+                throw InterruptedException()
+            } else if (completed) {
+                break
+            } else if (!advanced) {
+                Thread.sleep(WAIT_MS)
+            }
+        }
+    }
+
+    override fun cleanup() {
+        runCatching { segments.release() }
+        runCatching { dataSources.release() }
+    }
+
+    companion object {
+        private val WAIT_MS = 2L
+        private val PROGRESS_LOOPS = 10L
+    }
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/thumbnails/ThumbnailsDispatcher.java b/lib/src/main/java/com/otaliastudios/transcoder/internal/thumbnails/ThumbnailsDispatcher.java
new file mode 100644
index 00000000..f3ad2fdd
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/thumbnails/ThumbnailsDispatcher.java
@@ -0,0 +1,64 @@
+package com.otaliastudios.transcoder.internal.thumbnails;
+
+import android.os.Handler;
+
+import androidx.annotation.NonNull;
+
+import com.otaliastudios.transcoder.ThumbnailerListener;
+import com.otaliastudios.transcoder.ThumbnailerOptions;
+import com.otaliastudios.transcoder.thumbnail.Thumbnail;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Wraps a ThumbnailerListener and posts events on the given handler.
+ */
+class ThumbnailsDispatcher {
+
+    private final Handler mHandler;
+    private final ThumbnailerListener mListener;
+    private final List<Thumbnail> mResults = new ArrayList<>();
+
+    ThumbnailsDispatcher(@NonNull ThumbnailerOptions options) {
+        mHandler = options.getListenerHandler();
+        mListener = options.getListener();
+    }
+
+    void dispatchCancel() {
+        mHandler.post(new Runnable() {
+            @Override
+            public void run() {
+                mListener.onThumbnailsCanceled();
+            }
+        });
+    }
+
+    void dispatchCompletion() {
+        mHandler.post(new Runnable() {
+            @Override
+            public void run() {
+                mListener.onThumbnailsCompleted(mResults);
+            }
+        });
+    }
+
+    void dispatchFailure(@NonNull final Throwable exception) {
+        mHandler.post(new Runnable() {
+            @Override
+            public void run() {
+                mListener.onThumbnailsFailed(exception);
+            }
+        });
+    }
+
+    void dispatchThumbnail(@NonNull final Thumbnail thumbnail) {
+        mResults.add(thumbnail);
+        mHandler.post(new Runnable() {
+            @Override
+            public void run() {
+                mListener.onThumbnail(thumbnail);
+            }
+        });
+    }
+}
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/thumbnails/ThumbnailsEngine.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/thumbnails/ThumbnailsEngine.kt
new file mode 100644
index 00000000..26eb75a7
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/thumbnails/ThumbnailsEngine.kt
@@ -0,0 +1,56 @@
+package com.otaliastudios.transcoder.internal.thumbnails
+
+import com.otaliastudios.transcoder.ThumbnailerOptions
+import com.otaliastudios.transcoder.Transcoder
+import com.otaliastudios.transcoder.TranscoderOptions
+import com.otaliastudios.transcoder.internal.DataSources
+import com.otaliastudios.transcoder.internal.utils.Logger
+import com.otaliastudios.transcoder.internal.utils.trackMapOf
+import com.otaliastudios.transcoder.thumbnail.Thumbnail
+
+internal abstract class ThumbnailsEngine {
+
+    abstract fun thumbnails(progress: (Thumbnail) -> Unit)
+
+    abstract fun cleanup()
+
+    companion object {
+        private val log = Logger("ThumbnailsEngine")
+
+        private fun Throwable.isInterrupted(): Boolean {
+            if (this is InterruptedException) return true
+            if (this == this.cause) return false
+            return this.cause?.isInterrupted() ?: false
+        }
+
+        @JvmStatic
+        fun thumbnails(options: ThumbnailerOptions) {
+            log.i("thumbnails(): called...")
+            var engine: ThumbnailsEngine? = null
+            val dispatcher = ThumbnailsDispatcher(options)
+            try {
+                engine = DefaultThumbnailsEngine(
+                        dataSources = DataSources(options),
+                        rotation = options.rotation,
+                        resizer = options.resizer,
+                        requests = options.thumbnailRequests
+                )
+                engine.thumbnails {
+                    dispatcher.dispatchThumbnail(it)
+                }
+                dispatcher.dispatchCompletion()
+            } catch (e: Exception) {
+                if (e.isInterrupted()) {
+                    log.i("Transcode canceled.", e)
+                    dispatcher.dispatchCancel()
+                } else {
+                    log.e("Unexpected error while transcoding.", e)
+                    dispatcher.dispatchFailure(e)
+                    throw e
+                }
+            } finally {
+                engine?.cleanup()
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/transcode/DefaultTranscodeEngine.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/transcode/DefaultTranscodeEngine.kt
new file mode 100644
index 00000000..260894e2
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/transcode/DefaultTranscodeEngine.kt
@@ -0,0 +1,153 @@
+package com.otaliastudios.transcoder.internal.transcode
+
+import android.media.MediaFormat
+import com.otaliastudios.transcoder.common.TrackStatus
+import com.otaliastudios.transcoder.common.TrackType
+import com.otaliastudios.transcoder.internal.*
+import com.otaliastudios.transcoder.internal.Codecs
+import com.otaliastudios.transcoder.internal.DataSources
+import com.otaliastudios.transcoder.internal.Segments
+import com.otaliastudios.transcoder.internal.Timer
+import com.otaliastudios.transcoder.internal.Tracks
+import com.otaliastudios.transcoder.internal.pipeline.EmptyPipeline
+import com.otaliastudios.transcoder.internal.pipeline.PassThroughPipeline
+import com.otaliastudios.transcoder.internal.pipeline.Pipeline
+import com.otaliastudios.transcoder.internal.pipeline.RegularPipeline
+import com.otaliastudios.transcoder.internal.utils.Logger
+import com.otaliastudios.transcoder.internal.utils.TrackMap
+import com.otaliastudios.transcoder.internal.utils.forcingEos
+import com.otaliastudios.transcoder.internal.utils.ignoringEos
+import com.otaliastudios.transcoder.resample.AudioResampler
+import com.otaliastudios.transcoder.sink.DataSink
+import com.otaliastudios.transcoder.strategy.TrackStrategy
+import com.otaliastudios.transcoder.stretch.AudioStretcher
+import com.otaliastudios.transcoder.time.TimeInterpolator
+import com.otaliastudios.transcoder.validator.Validator
+
+internal class DefaultTranscodeEngine(
+        private val dataSources: DataSources,
+        private val dataSink: DataSink,
+        strategies: TrackMap<TrackStrategy>,
+        private val validator: Validator,
+        private val videoRotation: Int,
+        private val audioStretcher: AudioStretcher,
+        private val audioResampler: AudioResampler,
+        interpolator: TimeInterpolator
+) : TranscodeEngine() {
+
+    private val log = Logger("TranscodeEngine")
+
+    private val tracks = Tracks(strategies, dataSources, videoRotation, false)
+
+    private val segments = Segments(dataSources, tracks, ::createPipeline)
+
+    private val timer = Timer(interpolator, dataSources, tracks, segments.currentIndex)
+
+    private val codecs = Codecs(dataSources, tracks, segments.currentIndex)
+
+    init {
+        log.i("Created Tracks, Segments, Timer...")
+    }
+
+    init {
+        dataSink.setOrientation(0) // Explicitly set 0 to output - we rotate the textures.
+        val location = dataSources.all().asSequence().mapNotNull { it.location }.firstOrNull()
+        if (location != null) {
+            dataSink.setLocation(location[0], location[1])
+        }
+        dataSink.setTrackStatus(TrackType.VIDEO, tracks.all.video)
+        dataSink.setTrackStatus(TrackType.AUDIO, tracks.all.audio)
+        log.i("Set up the DataSink...")
+    }
+
+    private fun createPipeline(
+        type: TrackType,
+        index: Int,
+        count: Int,
+        status: TrackStatus,
+        outputFormat: MediaFormat
+    ): Pipeline {
+        log.w("createPipeline($type, $index, $status), format=$outputFormat")
+        val interpolator = timer.interpolator(type, index)
+        val sources = dataSources[type]
+        val source = sources[index].forcingEos {
+            // Enforce EOS if we exceed duration of other tracks,
+            // with a little tolerance.
+            timer.positionUs[type] > timer.totalDurationUs + 100L
+        }
+        val sink = dataSink.ignoringEos { index < sources.lastIndex }
+        return when (status) {
+            TrackStatus.ABSENT -> EmptyPipeline()
+            TrackStatus.REMOVING -> EmptyPipeline()
+            TrackStatus.PASS_THROUGH -> PassThroughPipeline(type, source, sink, interpolator)
+            TrackStatus.COMPRESSING -> RegularPipeline(type, if (count > 1) "${index+1}/$count" else null,
+                    source, sink, interpolator, outputFormat, codecs,
+                    videoRotation, audioStretcher, audioResampler)
+        }
+    }
+
+    override fun validate(): Boolean {
+        if (!validator.validate(tracks.all.video, tracks.all.audio)) {
+            log.i("Validator has decided that the input is fine and transcoding is not necessary.")
+            return false
+        }
+        return true
+    }
+
+    /**
+     * Retrieve next segment from [Segments] and call [Segment.advance] for each track.
+     * We don't have to worry about which tracks are available and how. The [Segments] class
+     * will simply return null if there's nothing to be done.
+     */
+    override fun transcode(progress: (Double) -> Unit) {
+        var loop = 0L
+        log.i("transcode(): about to start, " +
+                "durationUs=${timer.totalDurationUs}, " +
+                "audioUs=${timer.durationUs.audioOrNull()}, " +
+                "videoUs=${timer.durationUs.videoOrNull()}"
+        )
+        while (true) {
+            // Create both segments before reading. Creating the segment calls source.selectTrack,
+            // and if source is the same, it's important that both tracks are selected before
+            // reading (or even worse, seeking. DataSource.seek is broken if you add a track later on).
+            val audio = segments.next(TrackType.AUDIO)
+            val video = segments.next(TrackType.VIDEO)
+            val advanced = (audio?.advance() ?: false) or (video?.advance() ?: false)
+            val completed = !advanced && !segments.hasNext() // avoid calling hasNext if we advanced.
+
+            log.v("iteration #$loop audio=${segments.currentIndex.audio+1}/${dataSources.audio.size} video=${segments.currentIndex.video+1}/${dataSources.video.size} advanced=$advanced completed=$completed")
+            if (Thread.interrupted()) {
+                throw InterruptedException()
+            }
+            if (completed) {
+                progress(1.0)
+                break
+            }
+
+            if (!advanced && audio?.needsSleep() != false && video?.needsSleep() != false) {
+                Thread.sleep(WAIT_MS)
+            }
+
+            if (advanced && ++loop % PROGRESS_LOOPS == 0L) {
+                val audioProgress = timer.progress.audio
+                val videoProgress = timer.progress.video
+                log.v("transcode(): got progress, video=$videoProgress audio=$audioProgress")
+                progress((videoProgress + audioProgress) / tracks.active.size)
+            }
+        }
+        dataSink.stop()
+    }
+
+    override fun cleanup() {
+        runCatching { segments.release() }
+        runCatching { dataSink.release() }
+        runCatching { dataSources.release() }
+        runCatching { codecs.release() }
+    }
+
+
+    companion object {
+        private val WAIT_MS = 2L
+        private val PROGRESS_LOOPS = 10L
+    }
+}
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/transcode/TranscodeDispatcher.java b/lib/src/main/java/com/otaliastudios/transcoder/internal/transcode/TranscodeDispatcher.java
new file mode 100644
index 00000000..2db927ed
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/transcode/TranscodeDispatcher.java
@@ -0,0 +1,58 @@
+package com.otaliastudios.transcoder.internal.transcode;
+
+import android.os.Handler;
+
+import androidx.annotation.NonNull;
+
+import com.otaliastudios.transcoder.TranscoderListener;
+import com.otaliastudios.transcoder.TranscoderOptions;
+
+/**
+ * Wraps a TranscoderListener and posts events on the given handler.
+ */
+class TranscodeDispatcher {
+
+    private final Handler mHandler;
+    private final TranscoderListener mListener;
+
+    TranscodeDispatcher(@NonNull TranscoderOptions options) {
+        mHandler = options.getListenerHandler();
+        mListener = options.getListener();
+    }
+
+    void dispatchCancel() {
+        mHandler.post(new Runnable() {
+            @Override
+            public void run() {
+                mListener.onTranscodeCanceled();
+            }
+        });
+    }
+
+    void dispatchSuccess(final int successCode) {
+        mHandler.post(new Runnable() {
+            @Override
+            public void run() {
+                mListener.onTranscodeCompleted(successCode);
+            }
+        });
+    }
+
+    void dispatchFailure(@NonNull final Throwable exception) {
+        mHandler.post(new Runnable() {
+            @Override
+            public void run() {
+                mListener.onTranscodeFailed(exception);
+            }
+        });
+    }
+
+    void dispatchProgress(final double progress) {
+        mHandler.post(new Runnable() {
+            @Override
+            public void run() {
+                mListener.onTranscodeProgress(progress);
+            }
+        });
+    }
+}
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/transcode/TranscodeEngine.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/transcode/TranscodeEngine.kt
new file mode 100644
index 00000000..34a6610b
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/transcode/TranscodeEngine.kt
@@ -0,0 +1,67 @@
+package com.otaliastudios.transcoder.internal.transcode
+
+import com.otaliastudios.transcoder.Transcoder
+import com.otaliastudios.transcoder.TranscoderOptions
+import com.otaliastudios.transcoder.internal.DataSources
+import com.otaliastudios.transcoder.internal.utils.Logger
+import com.otaliastudios.transcoder.internal.utils.trackMapOf
+
+internal abstract class TranscodeEngine {
+
+    abstract fun validate(): Boolean
+
+    abstract fun transcode(progress: (Double) -> Unit)
+
+    abstract fun cleanup()
+
+    companion object {
+        private val log = Logger("TranscodeEngine")
+
+        private fun Throwable.isInterrupted(): Boolean {
+            if (this is InterruptedException) return true
+            if (this == this.cause) return false
+            return this.cause?.isInterrupted() ?: false
+        }
+
+        @JvmStatic
+        fun transcode(options: TranscoderOptions) {
+            log.i("transcode(): called...")
+            var engine: TranscodeEngine? = null
+            val dispatcher = TranscodeDispatcher(options)
+            try {
+                engine = DefaultTranscodeEngine(
+                        dataSources = DataSources(options),
+                        dataSink = options.dataSink,
+                        strategies = trackMapOf(
+                                video = options.videoTrackStrategy,
+                                audio = options.audioTrackStrategy
+                        ),
+                        validator = options.validator,
+                        videoRotation = options.videoRotation,
+                        interpolator = options.timeInterpolator,
+                        audioStretcher = options.audioStretcher,
+                        audioResampler = options.audioResampler
+                )
+                if (!engine.validate()) {
+                    dispatcher.dispatchSuccess(Transcoder.SUCCESS_NOT_NEEDED)
+                } else {
+                    engine.transcode {
+                        dispatcher.dispatchProgress(it)
+                    }
+                    dispatcher.dispatchSuccess(Transcoder.SUCCESS_TRANSCODED)
+                }
+            } catch (e: Exception) {
+                if (e.isInterrupted()) {
+                    log.i("Transcode canceled.", e)
+                    dispatcher.dispatchCancel()
+                } else {
+                    log.e("Unexpected error while transcoding.", e)
+                    dispatcher.dispatchFailure(e)
+                    throw e
+                }
+            } finally {
+                engine?.cleanup()
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/AvcCsdUtils.java b/lib/src/main/java/com/otaliastudios/transcoder/internal/utils/AvcCsdUtils.java
similarity index 95%
rename from lib/src/main/java/com/otaliastudios/transcoder/internal/AvcCsdUtils.java
rename to lib/src/main/java/com/otaliastudios/transcoder/internal/utils/AvcCsdUtils.java
index 616fccba..80083f09 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/internal/AvcCsdUtils.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/utils/AvcCsdUtils.java
@@ -13,13 +13,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.otaliastudios.transcoder.internal;
+package com.otaliastudios.transcoder.internal.utils;
 
 import android.media.MediaFormat;
 
 import androidx.annotation.NonNull;
 
-import com.otaliastudios.transcoder.internal.MediaFormatConstants;
+import com.otaliastudios.transcoder.internal.media.MediaFormatConstants;
 
 import java.nio.ByteBuffer;
 import java.util.Arrays;
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/AvcSpsUtils.java b/lib/src/main/java/com/otaliastudios/transcoder/internal/utils/AvcSpsUtils.java
similarity index 97%
rename from lib/src/main/java/com/otaliastudios/transcoder/internal/AvcSpsUtils.java
rename to lib/src/main/java/com/otaliastudios/transcoder/internal/utils/AvcSpsUtils.java
index 171f0a1f..7f7e54ac 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/internal/AvcSpsUtils.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/utils/AvcSpsUtils.java
@@ -13,7 +13,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.otaliastudios.transcoder.internal;
+package com.otaliastudios.transcoder.internal.utils;
 
 import androidx.annotation.NonNull;
 
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/BitRates.java b/lib/src/main/java/com/otaliastudios/transcoder/internal/utils/BitRates.java
similarity index 88%
rename from lib/src/main/java/com/otaliastudios/transcoder/internal/BitRates.java
rename to lib/src/main/java/com/otaliastudios/transcoder/internal/utils/BitRates.java
index c5a0b8c7..fd752720 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/internal/BitRates.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/utils/BitRates.java
@@ -1,4 +1,4 @@
-package com.otaliastudios.transcoder.internal;
+package com.otaliastudios.transcoder.internal.utils;
 
 import android.media.MediaFormat;
 
@@ -14,6 +14,7 @@ public static long estimateVideoBitRate(int width, int height, int frameRate) {
     }
 
     // Wildly assuming a 0.75 compression rate for AAC.
+    @SuppressWarnings("UnnecessaryLocalVariable")
     public static long estimateAudioBitRate(int channels, int sampleRate) {
         int bitsPerSample = 16;
         long samplesPerSecondPerChannel = (long) sampleRate;
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/ISO6709LocationParser.java b/lib/src/main/java/com/otaliastudios/transcoder/internal/utils/ISO6709LocationParser.java
similarity index 96%
rename from lib/src/main/java/com/otaliastudios/transcoder/internal/ISO6709LocationParser.java
rename to lib/src/main/java/com/otaliastudios/transcoder/internal/utils/ISO6709LocationParser.java
index 91ded25f..c3415e67 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/internal/ISO6709LocationParser.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/utils/ISO6709LocationParser.java
@@ -1,4 +1,4 @@
-package com.otaliastudios.transcoder.internal;
+package com.otaliastudios.transcoder.internal.utils;
 
 import androidx.annotation.Nullable;
 
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/Logger.java b/lib/src/main/java/com/otaliastudios/transcoder/internal/utils/Logger.java
similarity index 88%
rename from lib/src/main/java/com/otaliastudios/transcoder/internal/Logger.java
rename to lib/src/main/java/com/otaliastudios/transcoder/internal/utils/Logger.java
index d6a973c7..43c0c291 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/internal/Logger.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/utils/Logger.java
@@ -1,4 +1,4 @@
-package com.otaliastudios.transcoder.internal;
+package com.otaliastudios.transcoder.internal.utils;
 
 import androidx.annotation.IntDef;
 import androidx.annotation.NonNull;
@@ -21,7 +21,7 @@ public class Logger {
     @SuppressWarnings("WeakerAccess")
     public final static int LEVEL_ERROR = 3;
 
-    private static int sLevel;
+    private static int sLevel = LEVEL_INFO;
 
     /**
      * Interface of integers representing log levels.
@@ -35,10 +35,17 @@ public class Logger {
     @Retention(RetentionPolicy.SOURCE)
     public @interface LogLevel {}
 
-    private String mTag;
+    private final String mTag;
+    private final int mLevel;
 
     public Logger(@NonNull String tag) {
         mTag = tag;
+        mLevel = sLevel;
+    }
+
+    public Logger(@NonNull String tag, int level) {
+        mTag = tag;
+        mLevel = level;
     }
 
     /**
@@ -55,7 +62,7 @@ public static void setLogLevel(@LogLevel int logLevel) {
     }
 
     private boolean should(int messageLevel) {
-        return sLevel <= messageLevel;
+        return mLevel <= messageLevel;
     }
 
     public void v(String message) { v(message, null); }
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/utils/ThreadPool.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/utils/ThreadPool.kt
new file mode 100644
index 00000000..879920d6
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/utils/ThreadPool.kt
@@ -0,0 +1,29 @@
+package com.otaliastudios.transcoder.internal.utils
+
+import java.util.concurrent.LinkedBlockingQueue
+import java.util.concurrent.ThreadFactory
+import java.util.concurrent.ThreadPoolExecutor
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicInteger
+
+internal object ThreadPool {
+
+    /**
+     * NOTE: A better maximum pool size (instead of CPU+1) would be the number of MediaCodec
+     * instances that the device can handle at the same time. Hard to tell though as that
+     * also depends on the codec type / on input data.
+     */
+    @JvmStatic
+    val executor = ThreadPoolExecutor(
+            Runtime.getRuntime().availableProcessors() + 1,
+            Runtime.getRuntime().availableProcessors() + 1,
+            60,
+            TimeUnit.SECONDS,
+            LinkedBlockingQueue(),
+            object : ThreadFactory {
+                private val count = AtomicInteger(1)
+                override fun newThread(r: Runnable): Thread {
+                    return Thread(r, "TranscoderThread #" + count.getAndIncrement())
+                }
+            })
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/utils/TrackMap.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/utils/TrackMap.kt
new file mode 100644
index 00000000..46e64309
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/utils/TrackMap.kt
@@ -0,0 +1,57 @@
+package com.otaliastudios.transcoder.internal.utils
+
+import com.otaliastudios.transcoder.common.TrackType
+
+interface TrackMap<T> : Iterable<T> {
+
+    operator fun get(type: TrackType): T
+    val video get() = get(TrackType.VIDEO)
+    val audio get() = get(TrackType.AUDIO)
+
+    fun has(type: TrackType): Boolean
+    val hasVideo get() = has(TrackType.VIDEO)
+    val hasAudio get() = has(TrackType.AUDIO)
+
+    fun getOrNull(type: TrackType) = if (has(type)) get(type) else null
+    fun videoOrNull() = getOrNull(TrackType.VIDEO)
+    fun audioOrNull() = getOrNull(TrackType.AUDIO)
+
+    val size get() = listOfNotNull(videoOrNull(), audioOrNull()).size
+
+    override fun iterator() = listOfNotNull(videoOrNull(), audioOrNull()).iterator()
+}
+
+interface MutableTrackMap<T> : TrackMap<T> {
+    operator fun set(type: TrackType, value: T?)
+
+    fun reset(video: T?, audio: T?) {
+        set(TrackType.VIDEO, video)
+        set(TrackType.AUDIO, audio)
+    }
+
+    override var audio: T
+        get() = super.audio
+        set(value) = set(TrackType.AUDIO, value)
+
+    override var video: T
+        get() = super.video
+        set(value) = set(TrackType.VIDEO, value)
+}
+
+fun <T> trackMapOf(default: T?) = trackMapOf(default, default)
+
+fun <T> trackMapOf(video: T?, audio: T?): TrackMap<T> = DefaultTrackMap(video, audio)
+
+fun <T> mutableTrackMapOf(default: T?) = mutableTrackMapOf(default, default)
+
+fun <T> mutableTrackMapOf(video: T? = null, audio: T? = null): MutableTrackMap<T> = DefaultTrackMap(video, audio)
+
+private class DefaultTrackMap<T>(video: T?, audio: T?) : MutableTrackMap<T> {
+    private val map = mutableMapOf(TrackType.VIDEO to video, TrackType.AUDIO to audio)
+    override fun get(type: TrackType): T = requireNotNull(map[type])
+    override fun has(type: TrackType) = map[type] != null
+    override fun set(type: TrackType, value: T?) {
+        map[type] = value
+    }
+}
+
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/utils/debug.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/utils/debug.kt
new file mode 100644
index 00000000..d8dd1a47
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/utils/debug.kt
@@ -0,0 +1,3 @@
+package com.otaliastudios.transcoder.internal.utils
+
+internal fun stackTrace() = Thread.currentThread().stackTrace.drop(2).take(10).joinToString("\n")
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/utils/eos.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/utils/eos.kt
new file mode 100644
index 00000000..c093facf
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/utils/eos.kt
@@ -0,0 +1,48 @@
+package com.otaliastudios.transcoder.internal.utils
+
+import android.media.MediaCodec
+import com.otaliastudios.transcoder.common.TrackType
+import com.otaliastudios.transcoder.internal.Segment
+import com.otaliastudios.transcoder.internal.Segments
+import com.otaliastudios.transcoder.sink.DataSink
+import com.otaliastudios.transcoder.source.DataSource
+import java.nio.ByteBuffer
+
+// See https://github.com/natario1/Transcoder/issues/107
+internal fun DataSink.ignoringEos(ignore: () -> Boolean): DataSink
+        = EosIgnoringDataSink(this, ignore)
+
+private class EosIgnoringDataSink(
+        private val sink: DataSink,
+        private val ignore: () -> Boolean,
+) : DataSink by sink {
+    private val info = MediaCodec.BufferInfo()
+    override fun writeTrack(type: TrackType, byteBuffer: ByteBuffer, bufferInfo: MediaCodec.BufferInfo) {
+        if (ignore()) {
+            val flags = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM.inv()
+            if (bufferInfo.size > 0 || flags != 0) {
+                info.set(bufferInfo.offset, bufferInfo.size, bufferInfo.presentationTimeUs, flags)
+                sink.writeTrack(type, byteBuffer, info)
+            }
+        } else {
+            sink.writeTrack(type, byteBuffer, bufferInfo)
+        }
+    }
+}
+
+/**
+ * When transcoding has more tracks, we need to check if we need to force EOS on some of them.
+ * This can happen if the user adds e.g. 1 minute of audio with 20 seconds of video.
+ * In this case the video track must be stopped once the audio stops.
+ */
+internal fun DataSource.forcingEos(force: () -> Boolean): DataSource
+        = EosForcingDataSource(this, force)
+
+private class EosForcingDataSource(
+        private val source: DataSource,
+        private val force: () -> Boolean,
+) : DataSource by source {
+    override fun isDrained(): Boolean {
+        return force() || source.isDrained
+    }
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/transcode/internal/VideoDecoderOutput.java b/lib/src/main/java/com/otaliastudios/transcoder/internal/video/FrameDrawer.java
similarity index 92%
rename from lib/src/main/java/com/otaliastudios/transcoder/transcode/internal/VideoDecoderOutput.java
rename to lib/src/main/java/com/otaliastudios/transcoder/internal/video/FrameDrawer.java
index 7f052561..800a0187 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/transcode/internal/VideoDecoderOutput.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/video/FrameDrawer.java
@@ -1,4 +1,4 @@
-package com.otaliastudios.transcoder.transcode.internal;
+package com.otaliastudios.transcoder.internal.video;
 
 
 import android.graphics.SurfaceTexture;
@@ -11,7 +11,7 @@
 import com.otaliastudios.opengl.draw.GlRect;
 import com.otaliastudios.opengl.program.GlTextureProgram;
 import com.otaliastudios.opengl.texture.GlTexture;
-import com.otaliastudios.transcoder.internal.Logger;
+import com.otaliastudios.transcoder.internal.utils.Logger;
 
 /**
  * The purpose of this class is to create a {@link Surface} associated to a certain GL texture.
@@ -26,9 +26,8 @@
  * NOTE: By default, the Surface will be using a BufferQueue in asynchronous mode, so we
  * can potentially drop frames.
  */
-public class VideoDecoderOutput {
-    private static final String TAG = VideoDecoderOutput.class.getSimpleName();
-    private static final Logger LOG = new Logger(TAG);
+class FrameDrawer {
+    private static final Logger LOG = new Logger("FrameDrawer");
 
     private static final long NEW_IMAGE_TIMEOUT_MILLIS = 10000;
 
@@ -41,6 +40,7 @@ public class VideoDecoderOutput {
     private float mScaleX = 1F;
     private float mScaleY = 1F;
     private int mRotation = 0;
+    private boolean mFlipY = false;
 
     @GuardedBy("mFrameAvailableLock")
     private boolean mFrameAvailable;
@@ -50,7 +50,7 @@ public class VideoDecoderOutput {
      * Creates an VideoDecoderOutput using the current EGL context (rather than establishing a
      * new one). Creates a Surface that can be passed to MediaCodec.configure().
      */
-    public VideoDecoderOutput() {
+    public FrameDrawer() {
         GlTexture texture = new GlTexture();
         mProgram = new GlTextureProgram();
         mProgram.setTexture(texture);
@@ -96,6 +96,10 @@ public void setRotation(int rotation) {
         mRotation = rotation;
     }
 
+    public void setFlipY(boolean flipY) {
+        mFlipY = flipY;
+    }
+
     /**
      * Returns a Surface to draw onto.
      * @return the output surface
@@ -170,10 +174,14 @@ private void drawNewFrame() {
         Matrix.translateM(mProgram.getTextureTransform(), 0, glTranslX, glTranslY, 0);
         // Scale.
         Matrix.scaleM(mProgram.getTextureTransform(), 0, glScaleX, glScaleY, 1);
-        // Apply rotation.
+        // Apply rotation and flip.
         Matrix.translateM(mProgram.getTextureTransform(), 0, 0.5F, 0.5F, 0);
         Matrix.rotateM(mProgram.getTextureTransform(), 0, mRotation, 0, 0, 1);
+        if (mFlipY) {
+            Matrix.scaleM(mProgram.getTextureTransform(), 0, 1F, -1F, 1F);
+        }
         Matrix.translateM(mProgram.getTextureTransform(), 0, -0.5F, -0.5F, 0);
+
         // Draw.
         mProgram.draw(mDrawable);
     }
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/video/FrameDropper.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/video/FrameDropper.kt
new file mode 100644
index 00000000..53087887
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/video/FrameDropper.kt
@@ -0,0 +1,35 @@
+package com.otaliastudios.transcoder.internal.video
+
+import com.otaliastudios.transcoder.internal.utils.Logger
+
+internal interface FrameDropper {
+    fun shouldRender(timeUs: Long): Boolean
+}
+
+/**
+ * A very simple dropper, from
+ * https://stackoverflow.com/questions/4223766/dropping-video-frames
+ */
+internal fun FrameDropper(inputFps: Int, outputFps: Int) = object : FrameDropper {
+
+    private val log = Logger("FrameDropper")
+    private val inputSpf = 1.0 / inputFps
+    private val outputSpf = 1.0 / outputFps
+    private var currentSpf = 0.0
+    private var frameCount = 0
+
+    override fun shouldRender(timeUs: Long): Boolean {
+        currentSpf += inputSpf
+        if (frameCount++ == 0) {
+            log.v("RENDERING (first frame) - currentSpf=$currentSpf inputSpf=$inputSpf outputSpf=$outputSpf")
+            return true
+        } else if (currentSpf > outputSpf) {
+            currentSpf -= outputSpf
+            log.v("RENDERING - currentSpf=$currentSpf inputSpf=$inputSpf outputSpf=$outputSpf")
+            return true
+        } else {
+            log.v("DROPPING - currentSpf=$currentSpf inputSpf=$inputSpf outputSpf=$outputSpf")
+            return false
+        }
+    }
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/video/VideoPublisher.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/video/VideoPublisher.kt
new file mode 100644
index 00000000..a7773a22
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/video/VideoPublisher.kt
@@ -0,0 +1,33 @@
+package com.otaliastudios.transcoder.internal.video
+
+import android.opengl.EGL14
+import com.otaliastudios.opengl.core.EglCore
+import com.otaliastudios.opengl.surface.EglWindowSurface
+import com.otaliastudios.transcoder.internal.codec.EncoderChannel
+import com.otaliastudios.transcoder.internal.codec.EncoderData
+import com.otaliastudios.transcoder.internal.pipeline.BaseStep
+import com.otaliastudios.transcoder.internal.pipeline.Channel
+import com.otaliastudios.transcoder.internal.pipeline.State
+import com.otaliastudios.transcoder.internal.pipeline.Step
+
+
+internal class VideoPublisher: BaseStep<Long, Channel, EncoderData, EncoderChannel>("VideoPublisher") {
+
+    override val channel = Channel
+
+    override fun advance(state: State.Ok<Long>): State<EncoderData> {
+        if (state is State.Eos) {
+            return State.Eos(EncoderData.Empty)
+        } else {
+            val surface = next.surface!!
+            surface.window.setPresentationTime(state.value * 1000)
+            surface.window.swapBuffers()
+            /* val s = EGL14.eglGetCurrentSurface(EGL14.EGL_DRAW)
+            val ss = IntArray(2)
+            EGL14.eglQuerySurface(EGL14.eglGetCurrentDisplay(), s, EGL14.EGL_WIDTH, ss, 0)
+            EGL14.eglQuerySurface(EGL14.eglGetCurrentDisplay(), s, EGL14.EGL_HEIGHT, ss, 1)
+            log.e("XXX VideoPublisher.surfaceSize: ${ss[0]}x${ss[1]}") */
+            return State.Ok(EncoderData.Empty)
+        }
+    }
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/video/VideoRenderer.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/video/VideoRenderer.kt
new file mode 100644
index 00000000..237eb4f6
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/video/VideoRenderer.kt
@@ -0,0 +1,115 @@
+package com.otaliastudios.transcoder.internal.video
+
+import android.media.MediaFormat
+import android.media.MediaFormat.*
+import android.view.Surface
+import com.otaliastudios.transcoder.internal.codec.DecoderChannel
+import com.otaliastudios.transcoder.internal.codec.DecoderData
+import com.otaliastudios.transcoder.internal.media.MediaFormatConstants.KEY_ROTATION_DEGREES
+import com.otaliastudios.transcoder.internal.pipeline.BaseStep
+import com.otaliastudios.transcoder.internal.pipeline.Channel
+import com.otaliastudios.transcoder.internal.pipeline.State
+
+
+internal class VideoRenderer(
+    private val sourceRotation: Int, // intrinsic source rotation
+    private val extraRotation: Int, // any extra rotation in TranscoderOptions
+    private val targetFormat: MediaFormat,
+    flipY: Boolean = false
+): BaseStep<DecoderData, DecoderChannel, Long, Channel>("VideoRenderer"), DecoderChannel {
+
+    override val channel = this
+
+    // frame drawer needs EGL context which is not created by us, so let's use by lazy.
+    private val frameDrawer by lazy {
+        val drawer = FrameDrawer()
+        drawer.setFlipY(flipY)
+        drawer
+    }
+
+    private lateinit var frameDropper: FrameDropper
+
+    init {
+        // Modify the target format in place. We apply some extra rotation when drawing,
+        // so target format size should be flipped accordingly for correct Encoder configuration.
+        // Note that it is possible that format has its own KEY_ROTATION but we don't care, that
+        // will be applied at playback time by the player.
+        val width = targetFormat.getInteger(KEY_WIDTH)
+        val height = targetFormat.getInteger(KEY_HEIGHT)
+        val flip = extraRotation % 180 != 0
+        val flippedWidth = if (flip) height else width
+        val flippedHeight = if (flip) width else height
+        targetFormat.setInteger(KEY_WIDTH, flippedWidth)
+        targetFormat.setInteger(KEY_HEIGHT, flippedHeight)
+        log.i("encoded output format: $targetFormat")
+        log.i("output size=${flippedWidth}x${flippedHeight}, flipped=$flip")
+    }
+
+    // VideoTrackTranscoder.onConfigureDecoder
+    override fun handleSourceFormat(sourceFormat: MediaFormat): Surface {
+        log.i("encoded input format: $sourceFormat")
+
+        // Just a sanity check that the rotation coming from DataSource is not different from
+        // the one found in the DataSource's MediaFormat for video.
+        val sourceRotation = runCatching { sourceFormat.getInteger(KEY_ROTATION_DEGREES) }.getOrElse { 0 }
+        if (sourceRotation != this.sourceRotation) {
+            error("Unexpected difference in rotation. DataSource=${this.sourceRotation}, MediaFormat=$sourceRotation")
+        }
+
+        // Decoded video is rotated automatically starting from Android 5.0. Turn it off here because we
+        // don't want to work on the rotated one, we apply rotation at rendering time.
+        // https://android.googlesource.com/platform/frameworks/av/+blame/lollipop-release/media/libstagefright/Utils.cpp
+        sourceFormat.setInteger(KEY_ROTATION_DEGREES, 0)
+
+        // Instead, apply the source rotation, plus the extra user rotation, to the renderer.
+        val rotation = (sourceRotation + extraRotation) % 360
+        frameDrawer.setRotation(rotation)
+
+        // Depending on the rotation, we must also pass scale to the drawer due to how GL works.
+        val flip = rotation % 180 != 0
+        val sourceWidth = sourceFormat.getInteger(KEY_WIDTH).toFloat()
+        val sourceHeight = sourceFormat.getInteger(KEY_HEIGHT).toFloat()
+        val sourceRatio = sourceWidth / sourceHeight
+        val targetWidth = (if (flip) targetFormat.getInteger(KEY_HEIGHT) else targetFormat.getInteger(KEY_WIDTH)).toFloat()
+        val targetHeight = (if (flip) targetFormat.getInteger(KEY_WIDTH) else targetFormat.getInteger(KEY_HEIGHT)).toFloat()
+        val targetRatio = targetWidth / targetHeight
+        var scaleX = 1f
+        var scaleY = 1f
+        if (sourceRatio > targetRatio) { // Input wider. We have a scaleX.
+            scaleX = sourceRatio / targetRatio
+        } else if (sourceRatio < targetRatio) { // Input taller. We have a scaleY.
+            scaleY = targetRatio / sourceRatio
+        }
+        frameDrawer.setScale(scaleX, scaleY)
+
+        // Create the frame dropper, now that we know the source FPS and the target FPS.
+        frameDropper = FrameDropper(
+                sourceFormat.getInteger(KEY_FRAME_RATE),
+                targetFormat.getInteger(KEY_FRAME_RATE))
+        return frameDrawer.surface
+    }
+
+    override fun handleRawFormat(rawFormat: MediaFormat) {
+        log.i("decoded input format: $rawFormat")
+    }
+
+    override fun advance(state: State.Ok<DecoderData>): State<Long> {
+        return if (state is State.Eos) {
+            state.value.release(false)
+            State.Eos(0L)
+        } else {
+            if (frameDropper.shouldRender(state.value.timeUs)) {
+                state.value.release(true)
+                frameDrawer.drawFrame()
+                State.Ok(state.value.timeUs)
+            } else {
+                state.value.release(false)
+                State.Consume()
+            }
+        }
+    }
+
+    override fun release() {
+        frameDrawer.release()
+    }
+}
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/internal/video/VideoSnapshots.kt b/lib/src/main/java/com/otaliastudios/transcoder/internal/video/VideoSnapshots.kt
new file mode 100644
index 00000000..09fd5c28
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/internal/video/VideoSnapshots.kt
@@ -0,0 +1,64 @@
+package com.otaliastudios.transcoder.internal.video
+
+import android.graphics.Bitmap
+import android.media.MediaFormat
+import android.media.MediaFormat.KEY_HEIGHT
+import android.media.MediaFormat.KEY_WIDTH
+import android.opengl.EGL14
+import android.opengl.GLES20
+import com.otaliastudios.opengl.core.EglCore
+import com.otaliastudios.opengl.core.Egloo
+import com.otaliastudios.opengl.surface.EglOffscreenSurface
+import com.otaliastudios.opengl.surface.EglSurface
+import com.otaliastudios.transcoder.internal.pipeline.BaseStep
+import com.otaliastudios.transcoder.internal.pipeline.Channel
+import com.otaliastudios.transcoder.internal.pipeline.State
+import com.otaliastudios.transcoder.internal.utils.Logger
+import com.otaliastudios.transcoder.thumbnail.Thumbnail
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import kotlin.math.abs
+
+internal class VideoSnapshots(
+    format: MediaFormat,
+    requests: List<Long>,
+    private val accuracyUs: Long,
+    private val onSnapshot: (Long, Bitmap) -> Unit
+) : BaseStep<Long, Channel, Long, Channel>("VideoSnapshots") {
+
+    override val channel = Channel
+    private val requests = requests.toMutableList()
+    private val width = format.getInteger(KEY_WIDTH)
+    private val height = format.getInteger(KEY_HEIGHT)
+    private val core = EglCore(EGL14.EGL_NO_CONTEXT, EglCore.FLAG_RECORDABLE)
+    private val surface = EglOffscreenSurface(core, width, height).also {
+        it.makeCurrent()
+    }
+
+    override fun advance(state: State.Ok<Long>): State<Long> {
+        if (requests.isEmpty()) return state
+
+        val expectedUs = requests.first()
+        val deltaUs = abs(expectedUs - state.value)
+        if (deltaUs < accuracyUs || (state is State.Eos && expectedUs > state.value)) {
+            log.i("Request MATCHED! expectedUs=$expectedUs actualUs=${state.value} deltaUs=$deltaUs")
+            requests.removeFirst()
+            val buffer = ByteBuffer.allocateDirect(width * height * 4)
+            buffer.order(ByteOrder.LITTLE_ENDIAN)
+            GLES20.glReadPixels(0, 0, width, height, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, buffer)
+            Egloo.checkGlError("glReadPixels")
+            buffer.rewind()
+            val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+            bitmap.copyPixelsFromBuffer(buffer)
+            onSnapshot(state.value, bitmap)
+        } else {
+            log.v("Request has high delta. expectedUs=$expectedUs actualUs=${state.value} deltaUs=$deltaUs")
+        }
+        return state
+    }
+
+    override fun release() {
+        surface.release()
+        core.release()
+    }
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/remix/AudioRemixer.java b/lib/src/main/java/com/otaliastudios/transcoder/remix/AudioRemixer.java
deleted file mode 100644
index ce80f45e..00000000
--- a/lib/src/main/java/com/otaliastudios/transcoder/remix/AudioRemixer.java
+++ /dev/null
@@ -1,38 +0,0 @@
-package com.otaliastudios.transcoder.remix;
-
-import androidx.annotation.NonNull;
-
-import java.nio.Buffer;
-import java.nio.ShortBuffer;
-
-/**
- * Remixes audio data. See {@link DownMixAudioRemixer},
- * {@link UpMixAudioRemixer} or {@link PassThroughAudioRemixer}
- * for concrete implementations.
- */
-public interface AudioRemixer {
-
-    /**
-     * Remixes input audio from input buffer into the output buffer.
-     * The output buffer is guaranteed to have a {@link Buffer#remaining()} size that is
-     * consistent with {@link #getRemixedSize(int)}.
-     *
-     * @param inputBuffer the input buffer
-     * @param outputBuffer the output buffer
-     */
-    void remix(@NonNull final ShortBuffer inputBuffer, @NonNull final ShortBuffer outputBuffer);
-
-    /**
-     * Returns the output size (in shorts) needed to process an input buffer of the
-     * given size (in shorts).
-     * @param inputSize input size in shorts
-     * @return output size in shorts
-     */
-    int getRemixedSize(int inputSize);
-
-    AudioRemixer DOWNMIX = new DownMixAudioRemixer();
-
-    AudioRemixer UPMIX = new UpMixAudioRemixer();
-
-    AudioRemixer PASSTHROUGH = new PassThroughAudioRemixer();
-}
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/strategy/size/AspectRatioResizer.java b/lib/src/main/java/com/otaliastudios/transcoder/resize/AspectRatioResizer.java
similarity index 89%
rename from lib/src/main/java/com/otaliastudios/transcoder/strategy/size/AspectRatioResizer.java
rename to lib/src/main/java/com/otaliastudios/transcoder/resize/AspectRatioResizer.java
index cda21e45..ee0f3250 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/strategy/size/AspectRatioResizer.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/resize/AspectRatioResizer.java
@@ -1,7 +1,10 @@
-package com.otaliastudios.transcoder.strategy.size;
+package com.otaliastudios.transcoder.resize;
 
 import androidx.annotation.NonNull;
 
+import com.otaliastudios.transcoder.common.Size;
+import com.otaliastudios.transcoder.resize.Resizer;
+
 /**
  * A {@link Resizer} that crops the input size to match the given
  * aspect ratio, respecting the source portrait or landscape-ness.
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/strategy/size/AtMostResizer.java b/lib/src/main/java/com/otaliastudios/transcoder/resize/AtMostResizer.java
similarity index 92%
rename from lib/src/main/java/com/otaliastudios/transcoder/strategy/size/AtMostResizer.java
rename to lib/src/main/java/com/otaliastudios/transcoder/resize/AtMostResizer.java
index 8fb4ce58..41883c6d 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/strategy/size/AtMostResizer.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/resize/AtMostResizer.java
@@ -1,7 +1,10 @@
-package com.otaliastudios.transcoder.strategy.size;
+package com.otaliastudios.transcoder.resize;
 
 import androidx.annotation.NonNull;
 
+import com.otaliastudios.transcoder.common.Size;
+import com.otaliastudios.transcoder.resize.Resizer;
+
 /**
  * A {@link Resizer} that scales down the input size so that its dimension
  * is smaller or equal to a certain value.
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/strategy/size/ExactResizer.java b/lib/src/main/java/com/otaliastudios/transcoder/resize/ExactResizer.java
similarity index 85%
rename from lib/src/main/java/com/otaliastudios/transcoder/strategy/size/ExactResizer.java
rename to lib/src/main/java/com/otaliastudios/transcoder/resize/ExactResizer.java
index 7f9e2fd8..a3a9f5e7 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/strategy/size/ExactResizer.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/resize/ExactResizer.java
@@ -1,7 +1,10 @@
-package com.otaliastudios.transcoder.strategy.size;
+package com.otaliastudios.transcoder.resize;
 
 import androidx.annotation.NonNull;
 
+import com.otaliastudios.transcoder.common.Size;
+import com.otaliastudios.transcoder.resize.Resizer;
+
 /**
  * A {@link Resizer} that returns the exact dimensions that were passed to the constructor.
  */
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/strategy/size/FractionResizer.java b/lib/src/main/java/com/otaliastudios/transcoder/resize/FractionResizer.java
similarity index 85%
rename from lib/src/main/java/com/otaliastudios/transcoder/strategy/size/FractionResizer.java
rename to lib/src/main/java/com/otaliastudios/transcoder/resize/FractionResizer.java
index 342433a5..2ef08b2e 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/strategy/size/FractionResizer.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/resize/FractionResizer.java
@@ -1,7 +1,10 @@
-package com.otaliastudios.transcoder.strategy.size;
+package com.otaliastudios.transcoder.resize;
 
 import androidx.annotation.NonNull;
 
+import com.otaliastudios.transcoder.common.Size;
+import com.otaliastudios.transcoder.resize.Resizer;
+
 /**
  * A {@link Resizer} that reduces the input size by the given fraction.
  * This ensures that output dimensions are not an odd number (refused by a few codecs).
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/strategy/size/MultiResizer.java b/lib/src/main/java/com/otaliastudios/transcoder/resize/MultiResizer.java
similarity index 86%
rename from lib/src/main/java/com/otaliastudios/transcoder/strategy/size/MultiResizer.java
rename to lib/src/main/java/com/otaliastudios/transcoder/resize/MultiResizer.java
index bdb908da..11bfcd4c 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/strategy/size/MultiResizer.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/resize/MultiResizer.java
@@ -1,10 +1,13 @@
-package com.otaliastudios.transcoder.strategy.size;
+package com.otaliastudios.transcoder.resize;
 
 import java.util.ArrayList;
 import java.util.List;
 
 import androidx.annotation.NonNull;
 
+import com.otaliastudios.transcoder.common.Size;
+import com.otaliastudios.transcoder.resize.Resizer;
+
 /**
  * A {@link Resizer} that applies a chain of multiple resizers.
  * Of course order matters: the output of a resizer is the input of the next one.
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/strategy/size/PassThroughResizer.java b/lib/src/main/java/com/otaliastudios/transcoder/resize/PassThroughResizer.java
similarity index 68%
rename from lib/src/main/java/com/otaliastudios/transcoder/strategy/size/PassThroughResizer.java
rename to lib/src/main/java/com/otaliastudios/transcoder/resize/PassThroughResizer.java
index 5a91c728..f2019736 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/strategy/size/PassThroughResizer.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/resize/PassThroughResizer.java
@@ -1,14 +1,15 @@
-package com.otaliastudios.transcoder.strategy.size;
+package com.otaliastudios.transcoder.resize;
 
 import androidx.annotation.NonNull;
 
+import com.otaliastudios.transcoder.common.Size;
+import com.otaliastudios.transcoder.resize.Resizer;
+
 /**
  * A {@link Resizer} that returns the input size unchanged.
  */
-@SuppressWarnings("unused")
 public class PassThroughResizer implements Resizer {
 
-    @SuppressWarnings("unused")
     public PassThroughResizer() { }
 
     @NonNull
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/strategy/size/Resizer.java b/lib/src/main/java/com/otaliastudios/transcoder/resize/Resizer.java
similarity index 89%
rename from lib/src/main/java/com/otaliastudios/transcoder/strategy/size/Resizer.java
rename to lib/src/main/java/com/otaliastudios/transcoder/resize/Resizer.java
index 0f0173c1..5dc44cf7 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/strategy/size/Resizer.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/resize/Resizer.java
@@ -1,7 +1,10 @@
-package com.otaliastudios.transcoder.strategy.size;
+package com.otaliastudios.transcoder.resize;
 
 import androidx.annotation.NonNull;
 
+import com.otaliastudios.transcoder.common.ExactSize;
+import com.otaliastudios.transcoder.common.Size;
+
 /**
  * A general purpose interface that can be used (accepted as a parameter)
  * by video strategies such as {@link com.otaliastudios.transcoder.strategy.DefaultVideoStrategy}
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/sink/DataSink.java b/lib/src/main/java/com/otaliastudios/transcoder/sink/DataSink.java
index cc6e604c..2f3ba804 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/sink/DataSink.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/sink/DataSink.java
@@ -5,9 +5,8 @@
 
 import androidx.annotation.NonNull;
 
-import com.otaliastudios.transcoder.engine.TrackStatus;
-import com.otaliastudios.transcoder.engine.TrackType;
-import com.otaliastudios.transcoder.transcode.TrackTranscoder;
+import com.otaliastudios.transcoder.common.TrackStatus;
+import com.otaliastudios.transcoder.common.TrackType;
 
 import java.nio.ByteBuffer;
 
@@ -46,7 +45,7 @@ void setTrackStatus(@NonNull TrackType type,
                         @NonNull TrackStatus status);
 
     /**
-     * Called by {@link TrackTranscoder}s when they have an output format.
+     * Called by the transcoding pipeline when we have an output format.
      * This is not the output format chosen by the library user but rather the
      * output format determined by {@link MediaCodec}, which contains more information,
      * and should be inspected to know what kind of data we're collecting.
@@ -57,7 +56,7 @@ void setTrackFormat(@NonNull TrackType type,
                         @NonNull MediaFormat format);
 
     /**
-     * Called by {@link TrackTranscoder}s to write data into this sink.
+     * Called by the transcoding pipeline to write data into this sink.
      *  @param type the track type
      * @param byteBuffer the data
      * @param bufferInfo the metadata
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/sink/DefaultDataSink.java b/lib/src/main/java/com/otaliastudios/transcoder/sink/DefaultDataSink.java
index a2c86305..5d8d2ed1 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/sink/DefaultDataSink.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/sink/DefaultDataSink.java
@@ -6,18 +6,24 @@
 import android.os.Build;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
 
-import com.otaliastudios.transcoder.engine.TrackStatus;
-import com.otaliastudios.transcoder.engine.TrackType;
-import com.otaliastudios.transcoder.internal.TrackTypeMap;
-import com.otaliastudios.transcoder.internal.Logger;
+import com.otaliastudios.transcoder.common.TrackStatus;
+import com.otaliastudios.transcoder.common.TrackType;
+import com.otaliastudios.transcoder.internal.utils.MutableTrackMap;
+import com.otaliastudios.transcoder.internal.utils.Logger;
+import com.otaliastudios.transcoder.time.MonotonicTimeInterpolator;
+import com.otaliastudios.transcoder.time.TimeInterpolator;
 
+import java.io.FileDescriptor;
 import java.io.IOException;
 import java.nio.ByteBuffer;
 import java.nio.ByteOrder;
 import java.util.ArrayList;
 import java.util.List;
 
+import static com.otaliastudios.transcoder.internal.utils.TrackMapKt.mutableTrackMapOf;
+
 
 /**
  * A {@link DataSink} implementation that:
@@ -33,33 +39,33 @@ public class DefaultDataSink implements DataSink {
      */
     private static class QueuedSample {
         private final TrackType mType;
+        private ByteBuffer mByteBuffer;
+
         private final int mSize;
         private final long mTimeUs;
         private final int mFlags;
 
         private QueuedSample(@NonNull TrackType type,
+                             @NonNull ByteBuffer byteBuffer,
                              @NonNull MediaCodec.BufferInfo bufferInfo) {
             mType = type;
+            mByteBuffer = byteBuffer;
             mSize = bufferInfo.size;
             mTimeUs = bufferInfo.presentationTimeUs;
             mFlags = bufferInfo.flags;
         }
     }
 
-    private final static String TAG = DefaultDataSink.class.getSimpleName();
-    private final static Logger LOG = new Logger(TAG);
-
-    // I have no idea whether this value is appropriate or not...
-    private final static int BUFFER_SIZE = 64 * 1024;
+    private final static Logger LOG = new Logger("DefaultDataSink");
 
     private boolean mMuxerStarted = false;
     private final MediaMuxer mMuxer;
     private final List<QueuedSample> mQueue = new ArrayList<>();
-    private ByteBuffer mQueueBuffer;
-    private TrackTypeMap<TrackStatus> mStatus = new TrackTypeMap<>();
-    private TrackTypeMap<MediaFormat> mLastFormat = new TrackTypeMap<>();
-    private TrackTypeMap<Integer> mMuxerIndex = new TrackTypeMap<>();
+    private final MutableTrackMap<TrackStatus> mStatus = mutableTrackMapOf(null);
+    private final MutableTrackMap<MediaFormat> mLastFormat = mutableTrackMapOf(null);
+    private final MutableTrackMap<Integer> mMuxerIndex = mutableTrackMapOf(null);
     private final DefaultDataSinkChecks mMuxerChecks = new DefaultDataSinkChecks();
+    private final TimeInterpolator mInterpolator = new MonotonicTimeInterpolator();
 
     public DefaultDataSink(@NonNull String outputFilePath) {
         this(outputFilePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
@@ -74,6 +80,21 @@ public DefaultDataSink(@NonNull String outputFilePath, int format) {
         }
     }
 
+    @RequiresApi(api = Build.VERSION_CODES.O)
+    public DefaultDataSink(@NonNull FileDescriptor fileDescriptor) {
+        this(fileDescriptor, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
+    }
+
+    @RequiresApi(api = Build.VERSION_CODES.O)
+    @SuppressWarnings("WeakerAccess")
+    public DefaultDataSink(@NonNull FileDescriptor fileDescriptor, int format) {
+        try {
+            mMuxer = new MediaMuxer(fileDescriptor, format);
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
     @Override
     public void setOrientation(int rotation) {
         mMuxer.setOrientationHint(rotation);
@@ -81,9 +102,7 @@ public void setOrientation(int rotation) {
 
     @Override
     public void setLocation(double latitude, double longitude) {
-        if (Build.VERSION.SDK_INT >= 19) {
-            mMuxer.setLocation((float) latitude, (float) longitude);
-        }
+        mMuxer.setLocation((float) latitude, (float) longitude);
     }
 
     @Override
@@ -93,20 +112,21 @@ public void setTrackStatus(@NonNull TrackType type, @NonNull TrackStatus status)
 
     @Override
     public void setTrackFormat(@NonNull TrackType type, @NonNull MediaFormat format) {
-        boolean shouldValidate = mStatus.require(type) == TrackStatus.COMPRESSING;
+        LOG.i("setTrackFormat(" + type + ") format=" + format);
+        boolean shouldValidate = mStatus.get(type) == TrackStatus.COMPRESSING;
         if (shouldValidate) {
             mMuxerChecks.checkOutputFormat(type, format);
         }
         mLastFormat.set(type, format);
-        startIfNeeded();
+        maybeStart();
     }
 
-    private void startIfNeeded() {
+    private void maybeStart() {
         if (mMuxerStarted) return;
-        boolean isTranscodingVideo = mStatus.require(TrackType.VIDEO).isTranscoding();
-        boolean isTranscodingAudio = mStatus.require(TrackType.AUDIO).isTranscoding();
-        MediaFormat videoOutputFormat = mLastFormat.get(TrackType.VIDEO);
-        MediaFormat audioOutputFormat = mLastFormat.get(TrackType.AUDIO);
+        boolean isTranscodingVideo = mStatus.get(TrackType.VIDEO).isTranscoding();
+        boolean isTranscodingAudio = mStatus.get(TrackType.AUDIO).isTranscoding();
+        MediaFormat videoOutputFormat = mLastFormat.getOrNull(TrackType.VIDEO);
+        MediaFormat audioOutputFormat = mLastFormat.getOrNull(TrackType.AUDIO);
         boolean isVideoReady = videoOutputFormat != null || !isTranscodingVideo;
         boolean isAudioReady = audioOutputFormat != null || !isTranscodingAudio;
         if (!isVideoReady || !isAudioReady) return;
@@ -115,12 +135,12 @@ private void startIfNeeded() {
         // We will stop buffering data and we will start actually muxing it.
         if (isTranscodingVideo) {
             int videoIndex = mMuxer.addTrack(videoOutputFormat);
-            mMuxerIndex.set(TrackType.VIDEO, videoIndex);
+            mMuxerIndex.setVideo(videoIndex);
             LOG.v("Added track #" + videoIndex + " with " + videoOutputFormat.getString(MediaFormat.KEY_MIME) + " to muxer");
         }
         if (isTranscodingAudio) {
             int audioIndex = mMuxer.addTrack(audioOutputFormat);
-            mMuxerIndex.set(TrackType.AUDIO, audioIndex);
+            mMuxerIndex.setAudio(audioIndex);
             LOG.v("Added track #" + audioIndex + " with " + audioOutputFormat.getString(MediaFormat.KEY_MIME) + " to muxer");
         }
         mMuxer.start();
@@ -131,7 +151,19 @@ private void startIfNeeded() {
     @Override
     public void writeTrack(@NonNull TrackType type, @NonNull ByteBuffer byteBuffer, @NonNull MediaCodec.BufferInfo bufferInfo) {
         if (mMuxerStarted) {
-            mMuxer.writeSampleData(mMuxerIndex.require(type), byteBuffer, bufferInfo);
+            if (bufferInfo.presentationTimeUs != 0) {
+                bufferInfo.presentationTimeUs = mInterpolator.interpolate(type, bufferInfo.presentationTimeUs);
+            }
+            /* LOG.v("writeTrack(" + type + "): offset=" + bufferInfo.offset
+                    + "\trealOffset=" + byteBuffer.position()
+                    + "\tsize=" + bufferInfo.size
+                    + "\trealSize=" + byteBuffer.remaining()
+                    + "\ttime=" + bufferInfo.presentationTimeUs
+                    + "\tflags=" + bufferInfo.flags
+                    + "\teos=" + ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) == MediaCodec.BUFFER_FLAG_END_OF_STREAM)
+            );
+             */
+            mMuxer.writeSampleData(mMuxerIndex.get(type), byteBuffer, bufferInfo);
         } else {
             enqueue(type, byteBuffer, bufferInfo);
         }
@@ -148,13 +180,14 @@ public void writeTrack(@NonNull TrackType type, @NonNull ByteBuffer byteBuffer,
     private void enqueue(@NonNull TrackType type,
                          @NonNull ByteBuffer buffer,
                          @NonNull MediaCodec.BufferInfo bufferInfo) {
-        if (mQueueBuffer == null) {
-            mQueueBuffer = ByteBuffer.allocateDirect(BUFFER_SIZE).order(ByteOrder.nativeOrder());
-        }
-        buffer.limit(bufferInfo.offset + bufferInfo.size);
-        buffer.position(bufferInfo.offset);
-        mQueueBuffer.put(buffer);
-        mQueue.add(new QueuedSample(type, bufferInfo));
+        LOG.v("enqueue(" + type + "): offset=" + bufferInfo.offset
+                + "\trealOffset=" + buffer.position()
+                + "\tsize=" + bufferInfo.size
+                + "\trealSize=" + buffer.remaining());
+
+        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(bufferInfo.size).order(ByteOrder.nativeOrder());
+        byteBuffer.put(buffer);
+        mQueue.add(new QueuedSample(type, byteBuffer, bufferInfo));
     }
 
     /**
@@ -163,19 +196,16 @@ private void enqueue(@NonNull TrackType type,
      */
     private void drainQueue() {
         if (mQueue.isEmpty()) return;
-        mQueueBuffer.flip();
         LOG.i("Output format determined, writing pending data into the muxer. "
-                + "samples:" + mQueue.size() + " "
-                + "bytes:" + mQueueBuffer.limit());
+                + "samples:" + mQueue.size());
         MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
-        int offset = 0;
         for (QueuedSample sample : mQueue) {
-            bufferInfo.set(offset, sample.mSize, sample.mTimeUs, sample.mFlags);
-            writeTrack(sample.mType, mQueueBuffer, bufferInfo);
-            offset += sample.mSize;
+            bufferInfo.set(0, sample.mSize, sample.mTimeUs, sample.mFlags);
+            sample.mByteBuffer.position(0);
+            writeTrack(sample.mType, sample.mByteBuffer, bufferInfo);
+            sample.mByteBuffer = null;
         }
         mQueue.clear();
-        mQueueBuffer = null;
     }
 
     @Override
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/sink/DefaultDataSinkChecks.java b/lib/src/main/java/com/otaliastudios/transcoder/sink/DefaultDataSinkChecks.java
index 7066515a..6dd7402b 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/sink/DefaultDataSinkChecks.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/sink/DefaultDataSinkChecks.java
@@ -17,19 +17,18 @@
 
 import android.media.MediaFormat;
 
-import com.otaliastudios.transcoder.engine.TrackType;
-import com.otaliastudios.transcoder.internal.Logger;
-import com.otaliastudios.transcoder.internal.MediaFormatConstants;
-import com.otaliastudios.transcoder.internal.AvcCsdUtils;
-import com.otaliastudios.transcoder.internal.AvcSpsUtils;
+import com.otaliastudios.transcoder.common.TrackType;
+import com.otaliastudios.transcoder.internal.utils.Logger;
+import com.otaliastudios.transcoder.internal.media.MediaFormatConstants;
+import com.otaliastudios.transcoder.internal.utils.AvcCsdUtils;
+import com.otaliastudios.transcoder.internal.utils.AvcSpsUtils;
 
 import java.nio.ByteBuffer;
 
 import androidx.annotation.NonNull;
 
 class DefaultDataSinkChecks {
-    private static final String TAG = DefaultDataSinkChecks.class.getSimpleName();
-    private static final Logger LOG = new Logger(TAG);
+    private static final Logger LOG = new Logger("DefaultDataSinkChecks");
 
     void checkOutputFormat(@NonNull TrackType type, @NonNull MediaFormat format) {
         if (type == TrackType.VIDEO) {
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/sink/MultiDataSink.java b/lib/src/main/java/com/otaliastudios/transcoder/sink/MultiDataSink.java
index 6f552cf1..ef034352 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/sink/MultiDataSink.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/sink/MultiDataSink.java
@@ -5,8 +5,8 @@
 
 import androidx.annotation.NonNull;
 
-import com.otaliastudios.transcoder.engine.TrackStatus;
-import com.otaliastudios.transcoder.engine.TrackType;
+import com.otaliastudios.transcoder.common.TrackStatus;
+import com.otaliastudios.transcoder.common.TrackType;
 
 import java.nio.ByteBuffer;
 import java.util.Arrays;
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/source/AssetFileDescriptorDataSource.java b/lib/src/main/java/com/otaliastudios/transcoder/source/AssetFileDescriptorDataSource.java
new file mode 100644
index 00000000..c0eb3480
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/source/AssetFileDescriptorDataSource.java
@@ -0,0 +1,22 @@
+package com.otaliastudios.transcoder.source;
+
+import android.content.res.AssetFileDescriptor;
+import android.media.MediaExtractor;
+
+import androidx.annotation.NonNull;
+
+import java.io.FileInputStream;
+import java.io.IOException;
+
+/**
+ * It is the caller responsibility to close the file descriptor.
+ */
+public class AssetFileDescriptorDataSource extends DataSourceWrapper {
+    public AssetFileDescriptorDataSource(@NonNull AssetFileDescriptor assetFileDescriptor) {
+        super(new FileDescriptorDataSource(
+                assetFileDescriptor.getFileDescriptor(),
+                assetFileDescriptor.getStartOffset(),
+                assetFileDescriptor.getDeclaredLength()
+        ));
+    }
+}
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/source/BlankAudioDataSource.java b/lib/src/main/java/com/otaliastudios/transcoder/source/BlankAudioDataSource.java
new file mode 100644
index 00000000..56dc4f47
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/source/BlankAudioDataSource.java
@@ -0,0 +1,136 @@
+package com.otaliastudios.transcoder.source;
+
+import android.media.MediaFormat;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.otaliastudios.transcoder.common.TrackType;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+import static com.otaliastudios.transcoder.internal.audio.ConversionsKt.bitRate;
+import static com.otaliastudios.transcoder.internal.audio.ConversionsKt.bytesToUs;
+import static com.otaliastudios.transcoder.internal.audio.ConversionsKt.samplesToBytes;
+import static com.otaliastudios.transcoder.internal.media.MediaFormatConstants.MIMETYPE_AUDIO_RAW;
+
+/**
+ * A {@link DataSource} that provides a silent audio track of the a specific duration.
+ * This class can be used to concatenate a DataSources that has a video track only with another
+ * that has both video and audio track.
+ */
+public class BlankAudioDataSource implements DataSource {
+
+    private static final int CHANNEL_COUNT = 2;
+    private static final int SAMPLE_RATE = 44100;
+    // "period" = our MediaFormat.KEY_MAX_INPUT_SIZE
+    private static final int PERIOD_SIZE = samplesToBytes(2048, CHANNEL_COUNT);
+
+    private final long durationUs;
+
+    private ByteBuffer byteBuffer;
+    private MediaFormat audioFormat;
+    private long positionUs = 0L;
+    private boolean initialized = false;
+
+    public BlankAudioDataSource(long durationUs) {
+        this.durationUs = durationUs;
+    }
+
+    @Override
+    public void initialize() {
+        byteBuffer = ByteBuffer.allocateDirect(PERIOD_SIZE).order(ByteOrder.nativeOrder());
+        audioFormat = new MediaFormat();
+        audioFormat.setString(MediaFormat.KEY_MIME, MIMETYPE_AUDIO_RAW);
+        audioFormat.setInteger(MediaFormat.KEY_BIT_RATE, bitRate(SAMPLE_RATE, CHANNEL_COUNT));
+        audioFormat.setInteger(MediaFormat.KEY_CHANNEL_COUNT, CHANNEL_COUNT);
+        audioFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, PERIOD_SIZE);
+        audioFormat.setInteger(MediaFormat.KEY_SAMPLE_RATE, SAMPLE_RATE);
+        initialized = true;
+    }
+
+    @Override
+    public void selectTrack(@NonNull TrackType type) {
+        // Nothing to do
+    }
+
+    @Override
+    public void releaseTrack(@NonNull TrackType type) {
+        // Nothing to do
+    }
+
+    @Override
+    public void deinitialize() {
+        positionUs = 0;
+        initialized = false;
+    }
+
+    @Override
+    public boolean isInitialized() {
+        return initialized;
+    }
+
+    @Override
+    public int getOrientation() {
+        return 0;
+    }
+
+    @Nullable
+    @Override
+    public double[] getLocation() {
+        return null;
+    }
+
+    @Override
+    public long getDurationUs() {
+        return durationUs;
+    }
+
+
+    @Override
+    public long seekTo(long desiredPositionUs) {
+        positionUs = desiredPositionUs;
+        return desiredPositionUs;
+    }
+
+    @Nullable
+    @Override
+    public MediaFormat getTrackFormat(@NonNull TrackType type) {
+        return (type == TrackType.AUDIO) ? audioFormat : null;
+    }
+
+    @Override
+    public boolean canReadTrack(@NonNull TrackType type) {
+        return type == TrackType.AUDIO;
+    }
+
+    @Override
+    public void readTrack(@NonNull Chunk chunk) {
+        // Could fill the chunk with a while loop in case it's bigger than our buffer,
+        // but let's respect our MediaFormat.KEY_MAX_INPUT_SIZE
+        int position = chunk.buffer.position();
+        int bytes = Math.min(chunk.buffer.remaining(), PERIOD_SIZE);
+
+        byteBuffer.clear();
+        byteBuffer.limit(bytes);
+        chunk.buffer.put(byteBuffer);
+        chunk.buffer.position(position);
+        chunk.buffer.limit(position + bytes);
+
+        chunk.keyframe = true;
+        chunk.timeUs = positionUs;
+        chunk.render = true;
+        positionUs += bytesToUs(bytes, SAMPLE_RATE, CHANNEL_COUNT);
+    }
+
+    @Override
+    public long getPositionUs() {
+        return positionUs;
+    }
+
+    @Override
+    public boolean isDrained() {
+        return positionUs >= getDurationUs();
+    }
+}
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/source/ClipDataSource.java b/lib/src/main/java/com/otaliastudios/transcoder/source/ClipDataSource.java
index 7460fc85..48a05bbd 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/source/ClipDataSource.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/source/ClipDataSource.java
@@ -15,6 +15,11 @@ public ClipDataSource(@NonNull DataSource source, long clipStartUs) {
     public ClipDataSource(@NonNull DataSource source, long clipStartUs, long clipEndUs) {
         super(new TrimDataSource(source,
                 clipStartUs,
-                source.getDurationUs() - clipEndUs));
+                getSourceDurationUs(source) - clipEndUs));
+    }
+
+    private static long getSourceDurationUs(@NonNull DataSource source) {
+        if (!source.isInitialized()) source.initialize();
+        return source.getDurationUs();
     }
 }
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/source/DataSource.java b/lib/src/main/java/com/otaliastudios/transcoder/source/DataSource.java
index 201b2880..963e76d5 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/source/DataSource.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/source/DataSource.java
@@ -5,7 +5,7 @@
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 
-import com.otaliastudios.transcoder.engine.TrackType;
+import com.otaliastudios.transcoder.common.TrackType;
 
 import java.nio.ByteBuffer;
 
@@ -14,6 +14,12 @@
  */
 public interface DataSource {
 
+    void initialize();
+
+    void deinitialize();
+
+    boolean isInitialized();
+
     /**
      * Metadata information. Returns the video orientation, or 0.
      *
@@ -58,12 +64,14 @@ public interface DataSource {
      * Moves all selected tracks to the specified presentation time.
      * The timestamp should be between 0 and {@link #getDurationUs()}.
      * The actual timestamp might differ from the desired one because of
-     * seeking constraints (e.g. seek to sync frames).
+     * seeking constraints (e.g. seek to sync frames). It will typically be smaller
+     * because we use {@link android.media.MediaExtractor#SEEK_TO_PREVIOUS_SYNC} in
+     * the default source.
      *
-     * @param desiredTimestampUs requested timestamp
-     * @return actual timestamp
+     * @param desiredPositionUs requested timestamp
+     * @return actual timestamp, likely smaller or equal
      */
-    long seekTo(long desiredTimestampUs);
+    long seekTo(long desiredPositionUs);
 
     /**
      * Returns true if we can read the given track at this point.
@@ -84,11 +92,10 @@ public interface DataSource {
     void readTrack(@NonNull DataSource.Chunk chunk);
 
     /**
-     * Returns the total number of microseconds that have been read until now.
-     *
-     * @return total read us
+     * Returns the current read position, between 0 and duration.
+     * @return position in us
      */
-    long getReadUs();
+    long getPositionUs();
 
     /**
      * When this source has been totally read, it can return true here to
@@ -108,11 +115,11 @@ public interface DataSource {
      * Rewinds this source, moving it to its default state.
      * To be used again, tracks will be selected again.
      * After this call, for instance,
-     * - {@link #getReadUs()} should be 0
+     * - {@link #getPositionUs()} should be 0
      * - {@link #isDrained()} should be false
      * - {@link #readTrack(Chunk)} should return the very first bytes
      */
-    void rewind();
+    // void rewind();
 
     /**
      * Represents a chunk of data.
@@ -120,8 +127,8 @@ public interface DataSource {
      */
     class Chunk {
         public ByteBuffer buffer;
-        public boolean isKeyFrame;
-        public long timestampUs;
-        public int bytes;
+        public boolean keyframe;
+        public long timeUs;
+        public boolean render;
     }
 }
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/source/DataSourceWrapper.java b/lib/src/main/java/com/otaliastudios/transcoder/source/DataSourceWrapper.java
index 9f26cc18..368a340c 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/source/DataSourceWrapper.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/source/DataSourceWrapper.java
@@ -6,26 +6,37 @@
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 
-import com.otaliastudios.transcoder.engine.TrackType;
+import com.otaliastudios.transcoder.common.TrackType;
 
 /**
  * A {@link DataSource} wrapper that simply delegates all methods to the
- * wrapped source.
+ * wrapped source. It is the implementor responsibility to care about the case where
+ * the wrapped source is already initialized, in case they are overriding initialize.
  */
 public class DataSourceWrapper implements DataSource {
 
-    private final DataSource mSource;
+    private DataSource mSource;
 
     @SuppressWarnings("WeakerAccess")
     protected DataSourceWrapper(@NonNull DataSource source) {
         mSource = source;
     }
 
+    // Only use if you know what you are doing
+    protected DataSourceWrapper() {
+        mSource = null;
+    }
+
     @NonNull
     protected DataSource getSource() {
         return mSource;
     }
 
+    // Only use if you know what you are doing
+    protected void setSource(@NonNull DataSource source) {
+        mSource = source;
+    }
+
     @Override
     public int getOrientation() {
         return mSource.getOrientation();
@@ -54,8 +65,8 @@ public void selectTrack(@NonNull TrackType type) {
     }
 
     @Override
-    public long seekTo(long desiredTimestampUs) {
-        return mSource.seekTo(desiredTimestampUs);
+    public long seekTo(long desiredPositionUs) {
+        return mSource.seekTo(desiredPositionUs);
     }
 
     @Override
@@ -69,8 +80,8 @@ public void readTrack(@NonNull Chunk chunk) {
     }
 
     @Override
-    public long getReadUs() {
-        return mSource.getReadUs();
+    public long getPositionUs() {
+        return mSource.getPositionUs();
     }
 
     @Override
@@ -84,7 +95,23 @@ public void releaseTrack(@NonNull TrackType type) {
     }
 
     @Override
-    public void rewind() {
-        mSource.rewind();
+    public boolean isInitialized() {
+        return mSource != null && mSource.isInitialized();
+    }
+
+    @Override
+    public void initialize() {
+        // Make it easier for subclasses to put their logic in initialize safely.
+        if (!isInitialized()) {
+            if (mSource == null) {
+                throw new NullPointerException("DataSourceWrapper's source is not set!");
+            }
+            mSource.initialize();
+        }
+    }
+
+    @Override
+    public void deinitialize() {
+        mSource.deinitialize();
     }
 }
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/source/DefaultDataSource.java b/lib/src/main/java/com/otaliastudios/transcoder/source/DefaultDataSource.java
index a519d23d..5558b6bf 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/source/DefaultDataSource.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/source/DefaultDataSource.java
@@ -3,140 +3,264 @@
 import android.media.MediaExtractor;
 import android.media.MediaFormat;
 import android.media.MediaMetadataRetriever;
-import android.util.Log;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 
-import com.otaliastudios.transcoder.engine.TrackType;
-import com.otaliastudios.transcoder.internal.ISO6709LocationParser;
-import com.otaliastudios.transcoder.internal.Logger;
-import com.otaliastudios.transcoder.internal.TrackTypeMap;
+import com.otaliastudios.transcoder.common.TrackType;
+import com.otaliastudios.transcoder.common.TrackTypeKt;
+import com.otaliastudios.transcoder.internal.utils.ISO6709LocationParser;
+import com.otaliastudios.transcoder.internal.utils.Logger;
+import com.otaliastudios.transcoder.internal.utils.MutableTrackMap;
 
 import java.io.IOException;
 import java.util.HashSet;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static android.media.MediaMetadataRetriever.METADATA_KEY_DURATION;
+import static android.media.MediaMetadataRetriever.METADATA_KEY_LOCATION;
+import static android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION;
+import static com.otaliastudios.transcoder.internal.utils.TrackMapKt.mutableTrackMapOf;
 
 /**
  * A DataSource implementation that uses Android's Media APIs.
  */
 public abstract class DefaultDataSource implements DataSource {
 
-    private final static String TAG = DefaultDataSource.class.getSimpleName();
-    private final static Logger LOG = new Logger(TAG);
+    private final static AtomicInteger ID = new AtomicInteger(0);
+    private final Logger LOG = new Logger("DefaultDataSource(" + ID.getAndIncrement() + ")");
 
-    private MediaMetadataRetriever mMetadata = new MediaMetadataRetriever();
-    private MediaExtractor mExtractor = new MediaExtractor();
-    private boolean mMetadataApplied;
-    private boolean mExtractorApplied;
-    private final TrackTypeMap<MediaFormat> mFormats = new TrackTypeMap<>();
-    private final TrackTypeMap<Integer> mIndex = new TrackTypeMap<>();
+    private final MutableTrackMap<MediaFormat> mFormat = mutableTrackMapOf(null);
+    private final MutableTrackMap<Integer> mIndex = mutableTrackMapOf(null);
     private final HashSet<TrackType> mSelectedTracks = new HashSet<>();
-    private final TrackTypeMap<Long> mLastTimestampUs
-            = new TrackTypeMap<>(0L, 0L);
-    private long mFirstTimestampUs = Long.MIN_VALUE;
+    private final MutableTrackMap<Long> mLastTimestampUs = mutableTrackMapOf(0L, 0L);
+
+    private MediaMetadataRetriever mMetadata = null;
+    private MediaExtractor mExtractor = null;
+    private long mOriginUs = Long.MIN_VALUE;
+    private boolean mInitialized = false;
+
+    private long mDontRenderRangeStart = -1L;
+    private long mDontRenderRangeEnd = -1L;
 
-    private void ensureMetadata() {
-        if (!mMetadataApplied) {
-            mMetadataApplied = true;
-            applyRetriever(mMetadata);
+    @Override
+    public void initialize() {
+        LOG.i("initialize(): initializing...");
+        mExtractor = new MediaExtractor();
+        try {
+            initializeExtractor(mExtractor);
+        } catch (IOException e) {
+            LOG.e("Got IOException while trying to open MediaExtractor.", e);
+            throw new RuntimeException(e);
         }
-    }
+        mMetadata = new MediaMetadataRetriever();
+        initializeRetriever(mMetadata);
 
-    private void ensureExtractor() {
-        if (!mExtractorApplied) {
-            mExtractorApplied = true;
-            try {
-                applyExtractor(mExtractor);
-            } catch (IOException e) {
-                LOG.e("Got IOException while trying to open MediaExtractor.", e);
-                throw new RuntimeException(e);
+        int trackCount = mExtractor.getTrackCount();
+        for (int i = 0; i < trackCount; i++) {
+            MediaFormat format = mExtractor.getTrackFormat(i);
+            TrackType type = TrackTypeKt.getTrackTypeOrNull(format);
+            if (type != null && !mIndex.has(type)) {
+                mIndex.set(type, i);
+                mFormat.set(type, format);
             }
         }
+
+        mInitialized = true;
+
+        // Debugging mOriginUs issues.
+        /* LOG.v("initialize(): origin after unselect is" + mExtractor.getSampleTime());
+        if (getTrackFormat(TrackType.VIDEO) != null) {
+            mExtractor.selectTrack(mIndex.getVideo());
+            LOG.v("initialize(): video only origin is" + mExtractor.getSampleTime());
+            mExtractor.unselectTrack(mIndex.getVideo());
+        }
+        if (getTrackFormat(TrackType.AUDIO) != null) {
+            mExtractor.selectTrack(mIndex.getAudio());
+            LOG.v("initialize(): audio only origin is" + mExtractor.getSampleTime());
+            mExtractor.unselectTrack(mIndex.getAudio());
+        } */
+    }
+
+    /**
+     * Some properties are better initialized lazily instead of during initialize().
+     * For example, the origin - very important to have a timebase before reads and seeks -
+     * requires the extractor tracks to be selected, which is not true in initialize.
+     * Selecting and unselecting all tracks is not correct either.
+     */
+    private void initializeLazyProperties() {
+        if (mOriginUs == Long.MIN_VALUE) {
+            mOriginUs = mExtractor.getSampleTime();
+        }
     }
 
-    protected abstract void applyExtractor(@NonNull MediaExtractor extractor) throws IOException;
+    @Override
+    public void deinitialize() {
+        LOG.i("deinitialize(): deinitializing...");
+        try {
+            mExtractor.release();
+        } catch (Exception e) {
+            LOG.w("Could not release extractor:", e);
+        }
+        try {
+            mMetadata.release();
+        } catch (Exception e) {
+            LOG.w("Could not release metadata:", e);
+        }
+
+        mSelectedTracks.clear();
+        mOriginUs = Long.MIN_VALUE;
+        mLastTimestampUs.reset(0L, 0L);
+        mFormat.reset(null, null);
+        mIndex.reset(null, null);
+        mDontRenderRangeStart = -1;
+        mDontRenderRangeEnd = -1;
+        mInitialized = false;
+    }
 
-    protected abstract void applyRetriever(@NonNull MediaMetadataRetriever retriever);
+    @Override
+    public boolean isInitialized() {
+        return mInitialized;
+    }
 
     @Override
     public void selectTrack(@NonNull TrackType type) {
-        mSelectedTracks.add(type);
-        mExtractor.selectTrack(mIndex.require(type));
+        LOG.i("selectTrack(" + type + ")");
+        if (!mSelectedTracks.contains(type)) {
+            mSelectedTracks.add(type);
+            mExtractor.selectTrack(mIndex.get(type));
+        }
     }
 
     @Override
-    public long seekTo(long desiredTimestampUs) {
-        ensureExtractor();
-        long base = mFirstTimestampUs > 0 ? mFirstTimestampUs : mExtractor.getSampleTime();
+    public void releaseTrack(@NonNull TrackType type) {
+        LOG.i("releaseTrack(" + type + ")");
+        if (mSelectedTracks.contains(type)) {
+            mSelectedTracks.remove(type);
+            mExtractor.unselectTrack(mIndex.get(type));
+        }
+    }
+
+    protected abstract void initializeExtractor(@NonNull MediaExtractor extractor) throws IOException;
+
+    protected abstract void initializeRetriever(@NonNull MediaMetadataRetriever retriever);
+
+    @Override
+    public long seekTo(long desiredPositionUs) {
+        initializeLazyProperties();
+
         boolean hasVideo = mSelectedTracks.contains(TrackType.VIDEO);
         boolean hasAudio = mSelectedTracks.contains(TrackType.AUDIO);
-        LOG.i("Seeking to: " + ((base + desiredTimestampUs) / 1000) + " first: " + (base / 1000)
-                + " hasVideo: " + hasVideo
-                + " hasAudio: " + hasAudio);
-        mExtractor.seekTo(base + desiredTimestampUs, MediaExtractor.SEEK_TO_CLOSEST_SYNC);
+        LOG.i("seekTo(): seeking to " + (mOriginUs + desiredPositionUs)
+                + " originUs=" + mOriginUs
+                + " extractorUs=" + mExtractor.getSampleTime()
+                + " externalUs=" + desiredPositionUs
+                + " hasVideo=" + hasVideo
+                + " hasAudio=" + hasAudio);
         if (hasVideo && hasAudio) {
             // Special case: audio can be moved to any timestamp, but video will only stop in
             // sync frames. MediaExtractor is not smart enough to sync the two tracks at the
-            // video sync frame, so we must do it by seeking AGAIN at the next video position.
-            while (mExtractor.getSampleTrackIndex() != mIndex.requireVideo()) {
-                mExtractor.advance();
-            }
-            LOG.i("Second seek to " + (mExtractor.getSampleTime() / 1000));
+            // video sync frame, so we must take care of this with the following trick.
+            mExtractor.unselectTrack(mIndex.getAudio());
+            LOG.v("seekTo(): unselected AUDIO, seeking to " + (mOriginUs + desiredPositionUs) + " (extractorUs=" + mExtractor.getSampleTime() + ")");
+            mExtractor.seekTo(mOriginUs + desiredPositionUs, MediaExtractor.SEEK_TO_PREVIOUS_SYNC);
+            LOG.v("seekTo(): unselected AUDIO and sought (extractorUs=" + mExtractor.getSampleTime() + ")");
+            mExtractor.selectTrack(mIndex.getAudio()); // second seek might not be needed, but should not hurt.
+            LOG.v("seekTo(): reselected AUDIO, seeking to extractorUs (extractorUs=" + mExtractor.getSampleTime() + ")");
             mExtractor.seekTo(mExtractor.getSampleTime(), MediaExtractor.SEEK_TO_CLOSEST_SYNC);
+            LOG.v("seekTo(): seek workaround completed. (extractorUs=" + mExtractor.getSampleTime() + ")");
+        } else {
+            mExtractor.seekTo(mOriginUs + desiredPositionUs, MediaExtractor.SEEK_TO_PREVIOUS_SYNC);
         }
-        return mExtractor.getSampleTime() - base;
+        mDontRenderRangeStart = mExtractor.getSampleTime();
+        mDontRenderRangeEnd = mOriginUs + desiredPositionUs;
+        if (mDontRenderRangeStart > mDontRenderRangeEnd) {
+            // Extractor jumped beyond the requested point!
+            // This can happen in edge cases because we compute mOriginUs with both tracks selected,
+            // while source can later be used with a single track. E.g. audio track starts at 0,
+            // video track starts at 20000, mOriginUs is 0. A seekTo(0) will give range=20000..0.
+            // In this case, range should just be empty.
+            mDontRenderRangeStart = mDontRenderRangeEnd; // 0..0
+        }
+        LOG.i("seekTo(): dontRenderRange=" +
+                mDontRenderRangeStart + ".." +
+                mDontRenderRangeEnd + " (" +
+                (mDontRenderRangeEnd - mDontRenderRangeStart) + "us)");
+        return mExtractor.getSampleTime() - mOriginUs;
     }
 
     @Override
     public boolean isDrained() {
-        ensureExtractor();
         return mExtractor.getSampleTrackIndex() < 0;
     }
 
     @Override
     public boolean canReadTrack(@NonNull TrackType type) {
-        ensureExtractor();
-        return mExtractor.getSampleTrackIndex() == mIndex.require(type);
+        return mExtractor.getSampleTrackIndex() == mIndex.get(type);
     }
 
     @Override
     public void readTrack(@NonNull Chunk chunk) {
-        ensureExtractor();
+        initializeLazyProperties();
+
         int index = mExtractor.getSampleTrackIndex();
-        chunk.bytes = mExtractor.readSampleData(chunk.buffer, 0);
-        chunk.isKeyFrame = (mExtractor.getSampleFlags() & MediaExtractor.SAMPLE_FLAG_SYNC) != 0;
-        chunk.timestampUs = mExtractor.getSampleTime();
-        if (mFirstTimestampUs == Long.MIN_VALUE) {
-            mFirstTimestampUs = chunk.timestampUs;
-        }
-        TrackType type = (mIndex.hasAudio() && mIndex.requireAudio() == index) ? TrackType.AUDIO
-                : (mIndex.hasVideo() && mIndex.requireVideo() == index) ? TrackType.VIDEO
+
+        int position = chunk.buffer.position();
+        int limit = chunk.buffer.limit();
+        int read = mExtractor.readSampleData(chunk.buffer, position);
+        if (read < 0) {
+            throw new IllegalStateException("No samples available! Forgot to call " +
+                    "canReadTrack / isDrained?");
+        } else if (position + read > limit) {
+            throw new IllegalStateException("MediaExtractor is not respecting the buffer limit. " +
+                    "This might cause other issues down the pipeline.");
+        }
+        chunk.buffer.limit(position + read);
+        chunk.buffer.position(position);
+
+        chunk.keyframe = (mExtractor.getSampleFlags() & MediaExtractor.SAMPLE_FLAG_SYNC) != 0;
+        chunk.timeUs = mExtractor.getSampleTime();
+        chunk.render = chunk.timeUs < mDontRenderRangeStart || chunk.timeUs >= mDontRenderRangeEnd;
+        LOG.v("readTrack(): time=" + chunk.timeUs + ", render=" + chunk.render + ", end=" + mDontRenderRangeEnd);
+
+        // We now fetch this on initialize, can't wait until here, seek might be called before.
+        // if (mStartTimestampUs == Long.MIN_VALUE) mStartTimestampUs = chunk.timeUs;
+
+        TrackType type = (mIndex.getHasAudio() && mIndex.getAudio() == index) ? TrackType.AUDIO
+                : (mIndex.getHasVideo() && mIndex.getVideo() == index) ? TrackType.VIDEO
                 : null;
         if (type == null) {
             throw new RuntimeException("Unknown type: " + index);
         }
-        mLastTimestampUs.set(type, chunk.timestampUs);
+        mLastTimestampUs.set(type, chunk.timeUs);
         mExtractor.advance();
+
+        // This means that mDontRenderRangeEnd is so high, that it covers the whole video and we
+        // don't render anything. Likely due to little timestamp mismatches or using seekTo(durationUs).
+        // User likely wants at least the last frame. Force send it.
+        if (!chunk.render && isDrained()) {
+            LOG.w("Force rendering the last frame. timeUs=" + chunk.timeUs);
+            chunk.render = true;
+        }
     }
 
     @Override
-    public long getReadUs() {
-        if (mFirstTimestampUs == Long.MIN_VALUE) {
-            return 0;
-        }
+    public long getPositionUs() {
+        if (mOriginUs == Long.MIN_VALUE) return 0;
+
         // Return the fastest track.
         // This ensures linear behavior over time: if a track is behind the other,
         // this will not push down the readUs value, which might break some components
         // down the pipeline which expect a monotonically growing timestamp.
-        long last = Math.max(mLastTimestampUs.requireAudio(), mLastTimestampUs.requireVideo());
-        return last - mFirstTimestampUs;
+        long last = Math.max(mLastTimestampUs.getAudio(), mLastTimestampUs.getVideo());
+        return last - mOriginUs;
     }
 
     @Nullable
     @Override
     public double[] getLocation() {
-        ensureMetadata();
-        String string = mMetadata.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION);
+        LOG.i("getLocation()");
+        String string = mMetadata.extractMetadata(METADATA_KEY_LOCATION);
         if (string != null) {
             float[] location = new ISO6709LocationParser().parse(string);
             if (location != null) {
@@ -151,10 +275,9 @@ public double[] getLocation() {
 
     @Override
     public int getOrientation() {
-        ensureMetadata();
-        String string = mMetadata.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION);
+        LOG.i("getOrientation()");
         try {
-            return Integer.parseInt(string);
+            return Integer.parseInt(mMetadata.extractMetadata(METADATA_KEY_VIDEO_ROTATION));
         } catch (NumberFormatException ignore) {
             return 0;
         }
@@ -162,10 +285,9 @@ public int getOrientation() {
 
     @Override
     public long getDurationUs() {
-        ensureMetadata();
+        // LOG.v("getDurationUs()");
         try {
-            return Long.parseLong(mMetadata
-                    .extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)) * 1000;
+            return Long.parseLong(mMetadata.extractMetadata(METADATA_KEY_DURATION)) * 1000;
         } catch (NumberFormatException e) {
             return -1;
         }
@@ -174,68 +296,7 @@ public long getDurationUs() {
     @Nullable
     @Override
     public MediaFormat getTrackFormat(@NonNull TrackType type) {
-        if (mFormats.has(type)) return mFormats.get(type);
-        ensureExtractor();
-        int trackCount = mExtractor.getTrackCount();
-        MediaFormat format;
-        for (int i = 0; i < trackCount; i++) {
-            format = mExtractor.getTrackFormat(i);
-            String mime = format.getString(MediaFormat.KEY_MIME);
-            if (type == TrackType.VIDEO && mime.startsWith("video/")) {
-                mIndex.set(TrackType.VIDEO, i);
-                mFormats.set(TrackType.VIDEO, format);
-                return format;
-            }
-            if (type == TrackType.AUDIO && mime.startsWith("audio/")) {
-                mIndex.set(TrackType.AUDIO, i);
-                mFormats.set(TrackType.AUDIO, format);
-                return format;
-            }
-        }
-        return null;
-    }
-
-    @Override
-    public void releaseTrack(@NonNull TrackType type) {
-        mSelectedTracks.remove(type);
-        if (mSelectedTracks.isEmpty()) {
-            release();
-        }
-    }
-
-    protected void release() {
-        try {
-            mExtractor.release();
-        } catch (Exception e) {
-            LOG.w("Could not release extractor:", e);
-        }
-        try {
-            mMetadata.release();
-        } catch (Exception e) {
-            LOG.w("Could not release metadata:", e);
-        }
-    }
-
-    @Override
-    public void rewind() {
-        mSelectedTracks.clear();
-        mFirstTimestampUs = Long.MIN_VALUE;
-        mLastTimestampUs.setAudio(0L);
-        mLastTimestampUs.setVideo(0L);
-        // Release the extractor and recreate.
-        try {
-            mExtractor.release();
-        } catch (Exception ignore) { }
-        mExtractor = new MediaExtractor();
-        mExtractorApplied = false;
-        // Release the metadata and recreate.
-        // This is not strictly needed but some subclasses could have
-        // to close the underlying resource during rewind() and this could
-        // make the metadata unusable as well.
-        try {
-            mMetadata.release();
-        } catch (Exception ignore) { }
-        mMetadata = new MediaMetadataRetriever();
-        mMetadataApplied = false;
+        LOG.i("getTrackFormat(" + type + ")");
+        return mFormat.getOrNull(type);
     }
 }
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/source/FileDescriptorDataSource.java b/lib/src/main/java/com/otaliastudios/transcoder/source/FileDescriptorDataSource.java
index 4cbd7424..0ea15158 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/source/FileDescriptorDataSource.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/source/FileDescriptorDataSource.java
@@ -1,5 +1,6 @@
 package com.otaliastudios.transcoder.source;
 
+import android.content.res.AssetFileDescriptor;
 import android.media.MediaExtractor;
 import android.media.MediaMetadataRetriever;
 
@@ -10,23 +11,33 @@
 
 /**
  * A {@link DataSource} backed by a file descriptor.
+ * It is the caller responsibility to close the file descriptor.
  */
 public class FileDescriptorDataSource extends DefaultDataSource {
 
     @NonNull
-    private FileDescriptor descriptor;
+    private final FileDescriptor descriptor;
+    private final long offset;
+    private final long length;
 
     public FileDescriptorDataSource(@NonNull FileDescriptor descriptor) {
+        // length is intentionally less than LONG_MAX, see retriever
+        this(descriptor, 0, 0x7ffffffffffffffL);
+    }
+
+    public FileDescriptorDataSource(@NonNull FileDescriptor descriptor, long offset, long length) {
         this.descriptor = descriptor;
+        this.offset = offset;
+        this.length = length > 0 ? length : 0x7ffffffffffffffL;
     }
 
     @Override
-    protected void applyExtractor(@NonNull MediaExtractor extractor) throws IOException  {
-        extractor.setDataSource(descriptor);
+    protected void initializeExtractor(@NonNull MediaExtractor extractor) throws IOException  {
+        extractor.setDataSource(descriptor, offset, length);
     }
 
     @Override
-    protected void applyRetriever(@NonNull MediaMetadataRetriever retriever) {
-        retriever.setDataSource(descriptor);
+    protected void initializeRetriever(@NonNull MediaMetadataRetriever retriever) {
+        retriever.setDataSource(descriptor, offset, length);
     }
 }
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/source/FilePathDataSource.java b/lib/src/main/java/com/otaliastudios/transcoder/source/FilePathDataSource.java
index a0f89e76..e7ef0aa3 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/source/FilePathDataSource.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/source/FilePathDataSource.java
@@ -1,12 +1,7 @@
 package com.otaliastudios.transcoder.source;
 
 import android.media.MediaExtractor;
-import android.media.MediaMetadataRetriever;
 
-import com.otaliastudios.transcoder.engine.TrackType;
-import com.otaliastudios.transcoder.internal.Logger;
-
-import java.io.FileDescriptor;
 import java.io.FileInputStream;
 import java.io.IOException;
 
@@ -21,11 +16,7 @@
  *
  * See {@link MediaExtractor#setDataSource(String)} documentation.
  */
-public class FilePathDataSource extends DefaultDataSource {
-    private static final String TAG = FilePathDataSource.class.getSimpleName();
-    private static final Logger LOG = new Logger(TAG);
-
-    private FileDescriptorDataSource mDescriptorSource;
+public class FilePathDataSource extends DataSourceWrapper {
     private FileInputStream mStream;
     private final String mPath;
 
@@ -33,59 +24,20 @@ public FilePathDataSource(@NonNull String path) {
         mPath = path;
     }
 
-    private void ensureDescriptorSource() {
-        if (mDescriptorSource == null) {
-            try {
-                mStream = new FileInputStream(mPath);
-                mDescriptorSource = new FileDescriptorDataSource(mStream.getFD());
-            } catch (IOException e) {
-                release();
-                throw new RuntimeException(e);
-            }
-        }
-    }
-
-    @Override
-    protected void applyExtractor(@NonNull MediaExtractor extractor) throws IOException {
-        ensureDescriptorSource();
-        mDescriptorSource.applyExtractor(extractor);
-    }
-
-    @Override
-    protected void applyRetriever(@NonNull MediaMetadataRetriever retriever) {
-        ensureDescriptorSource();
-        mDescriptorSource.applyRetriever(retriever);
-    }
-
     @Override
-    protected void release() {
-        super.release();
-        if (mDescriptorSource != null) {
-            mDescriptorSource.release();
-        }
-        if (mStream != null) {
-            try {
-                mStream.close();
-            } catch (IOException e) {
-                LOG.e("Can't close input stream: ", e);
-            }
+    public void initialize() {
+        try {
+            mStream = new FileInputStream(mPath);
+            setSource(new FileDescriptorDataSource(mStream.getFD()));
+        } catch (IOException e) {
+            throw new RuntimeException(e);
         }
+        super.initialize();
     }
 
     @Override
-    public void rewind() {
-        super.rewind();
-        // I think we must recreate the stream to restart reading from the very first bytes.
-        // This means that we must also recreate the underlying source.
-        if (mDescriptorSource != null) {
-            mDescriptorSource.release();
-        }
-        if (mStream != null) {
-            try {
-                mStream.close();
-            } catch (IOException ignore) { }
-        }
-        mDescriptorSource = null;
-        mStream = null;
+    public void deinitialize() {
+        try { mStream.close(); } catch (IOException ignore) { }
+        super.deinitialize();
     }
 }
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/source/TrimDataSource.java b/lib/src/main/java/com/otaliastudios/transcoder/source/TrimDataSource.java
index 773bd776..28f1b788 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/source/TrimDataSource.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/source/TrimDataSource.java
@@ -3,18 +3,20 @@
 
 import androidx.annotation.NonNull;
 
-import com.otaliastudios.transcoder.engine.TrackType;
-import com.otaliastudios.transcoder.internal.Logger;
+import com.otaliastudios.transcoder.common.TrackType;
+import com.otaliastudios.transcoder.internal.utils.Logger;
 
 /**
  * A {@link DataSource} that trims the inner source at both ends.
  */
 public class TrimDataSource extends DataSourceWrapper {
-    private static final String TAG = TrimDataSource.class.getSimpleName();
-    private static final Logger LOG = new Logger(TAG);
+    private static final Logger LOG = new Logger("TrimDataSource");
 
-    private long trimStartUs;
-    private long trimDurationUs;
+    private final long trimStartUs;
+    private final long trimEndUs;
+
+    private long extraDurationUs = 0;
+    private long trimDurationUs = Long.MIN_VALUE;
     private boolean trimDone = false;
 
     @SuppressWarnings("WeakerAccess")
@@ -28,24 +30,68 @@ public TrimDataSource(@NonNull DataSource source, long trimStartUs, long trimEnd
         if (trimStartUs < 0 || trimEndUs < 0) {
             throw new IllegalArgumentException("Trim values cannot be negative.");
         }
-        long duration = source.getDurationUs();
+        this.trimStartUs = trimStartUs;
+        this.trimEndUs = trimEndUs;
+    }
+
+    @Override
+    public boolean isInitialized() {
+        return super.isInitialized() && trimDurationUs != Long.MIN_VALUE;
+    }
+
+    @Override
+    public void deinitialize() {
+        super.deinitialize();
+        trimDurationUs = Long.MIN_VALUE;
+        trimDone = false;
+    }
+
+    @Override
+    public void initialize() {
+        super.initialize();
+        long duration = getSource().getDurationUs();
         if (trimStartUs + trimEndUs >= duration) {
+            LOG.w("Trim values are too large! " +
+                    "start=" + trimStartUs + ", " +
+                    "end=" + trimEndUs + ", " +
+                    "duration=" + duration);
             throw new IllegalArgumentException(
                     "Trim values cannot be greater than media duration.");
         }
-        this.trimStartUs = trimStartUs;
-        this.trimDurationUs = duration - trimStartUs - trimEndUs;
+        LOG.i("initialize(): duration=" + duration
+                + " trimStart=" + trimStartUs
+                + " trimEnd=" + trimEndUs
+                + " trimDuration=" + (duration - trimStartUs - trimEndUs));
+        trimDurationUs = duration - trimStartUs - trimEndUs;
     }
 
+    // Assuming e.g. video of 8 seconds, trimStart=2s, trimEnd=1s.
+    // The trimmed duration is 5 seconds. Assume also that the video has sparse keyframes
+    // so that when we do seekTo(2s), it returns 0 because it couldn't seek there. We have two options:
+    // - duration=5s, position goes from -2s to 5s
+    // - duration=7s, position goes from 0s to 7s
+    // What we choose is not so important, as long as we use the same approach in getDurationUs
+    // and getPositionUs consistently. Approach 1 would be easier (no extra) but also weird.
+
     @Override
     public long getDurationUs() {
-        return trimDurationUs;
+        return trimDurationUs + extraDurationUs;
+    }
+    
+    @Override
+    public long getPositionUs() {
+        return super.getPositionUs() - trimStartUs + extraDurationUs;
     }
 
     @Override
     public boolean canReadTrack(@NonNull TrackType type) {
+        // Seek lazily here instead of in initialize, so that it is done once all
+        // tracks have been selected.
         if (!trimDone && trimStartUs > 0) {
-            trimStartUs = getSource().seekTo(trimStartUs);
+            extraDurationUs = trimStartUs - getSource().seekTo(trimStartUs);
+            LOG.i("canReadTrack(): extraDurationUs=" + extraDurationUs
+                    + " trimStartUs=" + trimStartUs
+                    + " source.seekTo(trimStartUs)=" + (extraDurationUs - trimStartUs));
             trimDone = true;
         }
         return super.canReadTrack(type);
@@ -53,19 +99,17 @@ public boolean canReadTrack(@NonNull TrackType type) {
 
     @Override
     public boolean isDrained() {
-        return super.isDrained() || getReadUs() >= getDurationUs();
+        // Enforce the trim end: this works thanks to the fact that extraDurationUs is added
+        // to the duration, otherwise it would fail for videos with sparse keyframes.
+        return super.isDrained() || getPositionUs() >= getDurationUs();
     }
 
     @Override
-    public long seekTo(long durationUs) {
+    public long seekTo(long desiredPositionUs) {
         // our 0 is the wrapped source's trimStartUs
-        long result = super.seekTo(trimStartUs + durationUs);
-        return result - trimStartUs;
-    }
-
-    @Override
-    public void rewind() {
-        super.rewind();
-        trimDone = false;
+        // TODO should we update extraDurationUs?
+        long superDesiredUs = trimStartUs + desiredPositionUs;
+        long superReceivedUs = getSource().seekTo(superDesiredUs);
+        return superReceivedUs - trimStartUs;
     }
 }
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/source/UriDataSource.java b/lib/src/main/java/com/otaliastudios/transcoder/source/UriDataSource.java
index 86f29f24..82562773 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/source/UriDataSource.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/source/UriDataSource.java
@@ -15,8 +15,8 @@
  */
 public class UriDataSource extends DefaultDataSource {
 
-    @NonNull private Context context;
-    @NonNull private Uri uri;
+    @NonNull private final Context context;
+    @NonNull private final Uri uri;
 
     public UriDataSource(@NonNull Context context, @NonNull Uri uri) {
         this.context = context.getApplicationContext();
@@ -24,12 +24,12 @@ public UriDataSource(@NonNull Context context, @NonNull Uri uri) {
     }
 
     @Override
-    protected void applyExtractor(@NonNull MediaExtractor extractor) throws IOException  {
+    protected void initializeExtractor(@NonNull MediaExtractor extractor) throws IOException {
         extractor.setDataSource(context, uri, null);
     }
 
     @Override
-    protected void applyRetriever(@NonNull MediaMetadataRetriever retriever) {
+    protected void initializeRetriever(@NonNull MediaMetadataRetriever retriever) {
         retriever.setDataSource(context, uri);
     }
 }
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/strategy/DefaultAudioStrategy.java b/lib/src/main/java/com/otaliastudios/transcoder/strategy/DefaultAudioStrategy.java
index da3f85a1..6cba9686 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/strategy/DefaultAudioStrategy.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/strategy/DefaultAudioStrategy.java
@@ -3,10 +3,10 @@
 import android.media.MediaCodecInfo;
 import android.media.MediaFormat;
 
-import com.otaliastudios.transcoder.engine.TrackStatus;
-import com.otaliastudios.transcoder.internal.BitRates;
-import com.otaliastudios.transcoder.internal.Logger;
-import com.otaliastudios.transcoder.internal.MediaFormatConstants;
+import com.otaliastudios.transcoder.common.TrackStatus;
+import com.otaliastudios.transcoder.internal.utils.BitRates;
+import com.otaliastudios.transcoder.internal.utils.Logger;
+import com.otaliastudios.transcoder.internal.media.MediaFormatConstants;
 
 import androidx.annotation.NonNull;
 
@@ -18,9 +18,6 @@
  */
 public class DefaultAudioStrategy implements TrackStrategy {
 
-    private final static String TAG = DefaultAudioStrategy.class.getSimpleName();
-    private final static Logger LOG = new Logger(TAG);
-
     public final static int CHANNELS_AS_INPUT = -1;
     public final static int SAMPLE_RATE_AS_INPUT = -1;
 
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/strategy/DefaultVideoStrategy.java b/lib/src/main/java/com/otaliastudios/transcoder/strategy/DefaultVideoStrategy.java
index bbab7255..22c5cebb 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/strategy/DefaultVideoStrategy.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/strategy/DefaultVideoStrategy.java
@@ -4,18 +4,18 @@
 import android.media.MediaFormat;
 import android.os.Build;
 
-import com.otaliastudios.transcoder.engine.TrackStatus;
-import com.otaliastudios.transcoder.internal.BitRates;
-import com.otaliastudios.transcoder.strategy.size.AspectRatioResizer;
-import com.otaliastudios.transcoder.strategy.size.AtMostResizer;
-import com.otaliastudios.transcoder.strategy.size.ExactResizer;
-import com.otaliastudios.transcoder.strategy.size.ExactSize;
-import com.otaliastudios.transcoder.strategy.size.FractionResizer;
-import com.otaliastudios.transcoder.strategy.size.MultiResizer;
-import com.otaliastudios.transcoder.strategy.size.Size;
-import com.otaliastudios.transcoder.strategy.size.Resizer;
-import com.otaliastudios.transcoder.internal.Logger;
-import com.otaliastudios.transcoder.internal.MediaFormatConstants;
+import com.otaliastudios.transcoder.common.TrackStatus;
+import com.otaliastudios.transcoder.internal.utils.BitRates;
+import com.otaliastudios.transcoder.resize.AspectRatioResizer;
+import com.otaliastudios.transcoder.resize.AtMostResizer;
+import com.otaliastudios.transcoder.resize.ExactResizer;
+import com.otaliastudios.transcoder.common.ExactSize;
+import com.otaliastudios.transcoder.resize.FractionResizer;
+import com.otaliastudios.transcoder.resize.MultiResizer;
+import com.otaliastudios.transcoder.common.Size;
+import com.otaliastudios.transcoder.resize.Resizer;
+import com.otaliastudios.transcoder.internal.utils.Logger;
+import com.otaliastudios.transcoder.internal.media.MediaFormatConstants;
 
 import androidx.annotation.NonNull;
 
@@ -26,8 +26,7 @@
  * The input and output aspect ratio must match.
  */
 public class DefaultVideoStrategy implements TrackStrategy {
-    private final static String TAG = DefaultVideoStrategy.class.getSimpleName();
-    private final static Logger LOG = new Logger(TAG);
+    private final static Logger LOG = new Logger("DefaultVideoStrategy");
 
     @SuppressWarnings("WeakerAccess")
     public final static long BITRATE_UNKNOWN = Long.MIN_VALUE;
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/strategy/PassThroughTrackStrategy.java b/lib/src/main/java/com/otaliastudios/transcoder/strategy/PassThroughTrackStrategy.java
index afab5615..d02b38b6 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/strategy/PassThroughTrackStrategy.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/strategy/PassThroughTrackStrategy.java
@@ -3,9 +3,8 @@
 import android.media.MediaFormat;
 
 import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 
-import com.otaliastudios.transcoder.engine.TrackStatus;
+import com.otaliastudios.transcoder.common.TrackStatus;
 
 import java.util.List;
 
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/strategy/RemoveTrackStrategy.java b/lib/src/main/java/com/otaliastudios/transcoder/strategy/RemoveTrackStrategy.java
index 38b5114f..213730c2 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/strategy/RemoveTrackStrategy.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/strategy/RemoveTrackStrategy.java
@@ -3,9 +3,8 @@
 import android.media.MediaFormat;
 
 import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 
-import com.otaliastudios.transcoder.engine.TrackStatus;
+import com.otaliastudios.transcoder.common.TrackStatus;
 
 import java.util.List;
 
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/strategy/TrackStrategy.java b/lib/src/main/java/com/otaliastudios/transcoder/strategy/TrackStrategy.java
index fa2df2a4..44f85201 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/strategy/TrackStrategy.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/strategy/TrackStrategy.java
@@ -2,11 +2,10 @@
 
 import android.media.MediaFormat;
 
-import com.otaliastudios.transcoder.engine.TrackStatus;
-import com.otaliastudios.transcoder.strategy.size.Resizer;
+import com.otaliastudios.transcoder.common.TrackStatus;
+import com.otaliastudios.transcoder.resize.Resizer;
 
 import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
 
 import java.util.List;
 
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/thumbnail/CoverThumbnailRequest.kt b/lib/src/main/java/com/otaliastudios/transcoder/thumbnail/CoverThumbnailRequest.kt
new file mode 100644
index 00000000..4edebede
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/thumbnail/CoverThumbnailRequest.kt
@@ -0,0 +1,5 @@
+package com.otaliastudios.transcoder.thumbnail
+
+class CoverThumbnailRequest : ThumbnailRequest {
+    override fun locate(durationUs: Long) = listOf(0L)
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/thumbnail/SingleThumbnailRequest.kt b/lib/src/main/java/com/otaliastudios/transcoder/thumbnail/SingleThumbnailRequest.kt
new file mode 100644
index 00000000..7e17a213
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/thumbnail/SingleThumbnailRequest.kt
@@ -0,0 +1,10 @@
+package com.otaliastudios.transcoder.thumbnail
+
+class SingleThumbnailRequest(private val positionUs: Long) : ThumbnailRequest {
+    override fun locate(durationUs: Long): List<Long> {
+        require(positionUs in 0L..durationUs) {
+            "Thumbnail position is out of range. position=$positionUs range=${0L..durationUs}"
+        }
+        return listOf(positionUs)
+    }
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/thumbnail/Thumbnail.kt b/lib/src/main/java/com/otaliastudios/transcoder/thumbnail/Thumbnail.kt
new file mode 100644
index 00000000..760ad983
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/thumbnail/Thumbnail.kt
@@ -0,0 +1,9 @@
+package com.otaliastudios.transcoder.thumbnail
+
+import android.graphics.Bitmap
+
+class Thumbnail internal constructor(
+        val request: ThumbnailRequest,
+        val positionUs: Long,
+        val bitmap: Bitmap
+)
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/thumbnail/ThumbnailRequest.kt b/lib/src/main/java/com/otaliastudios/transcoder/thumbnail/ThumbnailRequest.kt
new file mode 100644
index 00000000..48019689
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/thumbnail/ThumbnailRequest.kt
@@ -0,0 +1,13 @@
+package com.otaliastudios.transcoder.thumbnail
+
+interface ThumbnailRequest {
+    fun locate(durationUs: Long): List<Long>
+
+    // Could make it so that if locate() is empty, accept is called for each frame (no seeking).
+    // But this only makes sense if accept signature has more information (segment, ...), and
+    // it should also have a way to say - we're done, stop transcoding.
+    // fun accept(positionUs: Long): Boolean
+
+    // Could add resizing per request
+    // val resizer = PassThroughResizer()
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/thumbnail/UniformThumbnailRequest.kt b/lib/src/main/java/com/otaliastudios/transcoder/thumbnail/UniformThumbnailRequest.kt
new file mode 100644
index 00000000..b87bf385
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/thumbnail/UniformThumbnailRequest.kt
@@ -0,0 +1,21 @@
+package com.otaliastudios.transcoder.thumbnail
+
+class UniformThumbnailRequest(private val count: Int) : ThumbnailRequest {
+
+    init {
+        require(count >= 2) {
+            "At least 2 thumbnails should be requested when using UniformThumbnailRequest."
+        }
+    }
+
+    override fun locate(durationUs: Long): List<Long> {
+        val list = mutableListOf<Long>()
+        var positionUs = 0L
+        val stepUs = durationUs / (count - 1)
+        repeat(count) {
+            list.add(positionUs)
+            positionUs += stepUs
+        }
+        return list
+    }
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/time/DefaultTimeInterpolator.java b/lib/src/main/java/com/otaliastudios/transcoder/time/DefaultTimeInterpolator.java
index d4936afc..439f6538 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/time/DefaultTimeInterpolator.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/time/DefaultTimeInterpolator.java
@@ -2,7 +2,7 @@
 
 import androidx.annotation.NonNull;
 
-import com.otaliastudios.transcoder.engine.TrackType;
+import com.otaliastudios.transcoder.common.TrackType;
 
 /**
  * A {@link TimeInterpolator} that does no time interpolation or correction -
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/time/MonotonicTimeInterpolator.kt b/lib/src/main/java/com/otaliastudios/transcoder/time/MonotonicTimeInterpolator.kt
new file mode 100644
index 00000000..0f230976
--- /dev/null
+++ b/lib/src/main/java/com/otaliastudios/transcoder/time/MonotonicTimeInterpolator.kt
@@ -0,0 +1,28 @@
+package com.otaliastudios.transcoder.time
+
+import android.media.MediaMuxer
+import com.otaliastudios.transcoder.common.TrackType
+import com.otaliastudios.transcoder.internal.utils.mutableTrackMapOf
+
+/**
+ * A [TimeInterpolator] that ensures timestamps are monotonically increasing.
+ * Timestamps can go back and forth for many reasons, like miscalculations in MediaCodec output
+ * or manually generated timestamps, or at the boundary between one data source and another.
+ *
+ * Since [MediaMuxer.writeSampleData] can throw in case of invalid timestamps, this interpolator
+ * ensures that the next timestamp is at least equal to the previous timestamp plus 1.
+ * It does no effort to preserve the input deltas, so the input stream must be as consistent as possible.
+ *
+ * For example, 20 30 40 50 10 20 30 would become 20 30 40 50 51 52 53.
+ */
+internal class MonotonicTimeInterpolator : TimeInterpolator {
+    private val last = mutableTrackMapOf(Long.MIN_VALUE, Long.MIN_VALUE)
+    override fun interpolate(type: TrackType, time: Long): Long {
+        return interpolate(last[type], time).also { last[type] = it }
+    }
+    private fun interpolate(prev: Long, next: Long): Long {
+        if (prev == Long.MIN_VALUE) return next
+        return next.coerceAtLeast(prev + 1)
+    }
+
+}
\ No newline at end of file
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/time/SpeedTimeInterpolator.java b/lib/src/main/java/com/otaliastudios/transcoder/time/SpeedTimeInterpolator.java
index f82d3f14..2e960e5b 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/time/SpeedTimeInterpolator.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/time/SpeedTimeInterpolator.java
@@ -2,9 +2,11 @@
 
 import androidx.annotation.NonNull;
 
-import com.otaliastudios.transcoder.engine.TrackType;
-import com.otaliastudios.transcoder.internal.TrackTypeMap;
-import com.otaliastudios.transcoder.internal.Logger;
+import com.otaliastudios.transcoder.common.TrackType;
+import com.otaliastudios.transcoder.internal.utils.TrackMap;
+import com.otaliastudios.transcoder.internal.utils.Logger;
+
+import static com.otaliastudios.transcoder.internal.utils.TrackMapKt.trackMapOf;
 
 
 /**
@@ -14,11 +16,10 @@
  */
 public class SpeedTimeInterpolator implements TimeInterpolator {
 
-    private final static String TAG = SpeedTimeInterpolator.class.getSimpleName();
-    private final static Logger LOG = new Logger(TAG);
+    private final static Logger LOG = new Logger("SpeedTimeInterpolator");
 
-    private double mFactor;
-    private final TrackTypeMap<TrackData> mTrackData = new TrackTypeMap<>();
+    private final double mFactor;
+    private final TrackMap<TrackData> mTrackData = trackMapOf(new TrackData(), new TrackData());
 
     /**
      * Creates a new speed interpolator for the given factor.
@@ -37,27 +38,23 @@ public SpeedTimeInterpolator(float factor) {
      * @return the factor
      */
     @SuppressWarnings("unused")
-    public float getFactor() {
+    public float getFactor(@NonNull TrackType type, long time) {
         return (float) mFactor;
     }
 
     @Override
     public long interpolate(@NonNull TrackType type, long time) {
-        if (!mTrackData.has(type)) {
-            mTrackData.set(type, new TrackData());
-        }
         TrackData data = mTrackData.get(type);
-        //noinspection ConstantConditions
         if (data.lastRealTime == Long.MIN_VALUE) {
             data.lastRealTime = time;
             data.lastCorrectedTime = time;
         } else {
             long realDelta = time - data.lastRealTime;
-            long correctedDelta = (long) ((double) realDelta / mFactor);
+            long correctedDelta = (long) ((double) realDelta / getFactor(type, time));
             data.lastRealTime = time;
             data.lastCorrectedTime += correctedDelta;
         }
-        LOG.i("Track:" + type + " inputTime:" + time + " outputTime:" + data.lastCorrectedTime);
+        LOG.v("Track:" + type + " inputTime:" + time + " outputTime:" + data.lastCorrectedTime);
         return data.lastCorrectedTime;
     }
 
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/time/TimeInterpolator.java b/lib/src/main/java/com/otaliastudios/transcoder/time/TimeInterpolator.java
index 515b4471..06de4f49 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/time/TimeInterpolator.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/time/TimeInterpolator.java
@@ -2,7 +2,7 @@
 
 import androidx.annotation.NonNull;
 
-import com.otaliastudios.transcoder.engine.TrackType;
+import com.otaliastudios.transcoder.common.TrackType;
 
 /**
  * An interface to redefine the time between video or audio frames.
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/transcode/AudioTrackTranscoder.java b/lib/src/main/java/com/otaliastudios/transcoder/transcode/AudioTrackTranscoder.java
deleted file mode 100644
index f7ac989f..00000000
--- a/lib/src/main/java/com/otaliastudios/transcoder/transcode/AudioTrackTranscoder.java
+++ /dev/null
@@ -1,72 +0,0 @@
-package com.otaliastudios.transcoder.transcode;
-
-import android.media.MediaCodec;
-import android.media.MediaExtractor;
-import android.media.MediaFormat;
-
-import androidx.annotation.NonNull;
-
-import com.otaliastudios.transcoder.engine.TrackType;
-import com.otaliastudios.transcoder.internal.MediaCodecBuffers;
-import com.otaliastudios.transcoder.resample.AudioResampler;
-import com.otaliastudios.transcoder.sink.DataSink;
-import com.otaliastudios.transcoder.source.DataSource;
-import com.otaliastudios.transcoder.stretch.AudioStretcher;
-import com.otaliastudios.transcoder.time.TimeInterpolator;
-import com.otaliastudios.transcoder.transcode.internal.AudioEngine;
-
-import java.nio.ByteBuffer;
-
-public class AudioTrackTranscoder extends BaseTrackTranscoder {
-
-    private TimeInterpolator mTimeInterpolator;
-    private AudioStretcher mAudioStretcher;
-    private AudioResampler mAudioResampler;
-    private AudioEngine mAudioEngine;
-    private MediaCodec mEncoder; // to create the channel
-    private MediaFormat mEncoderOutputFormat; // to create the channel
-
-    public AudioTrackTranscoder(@NonNull DataSource dataSource,
-                                @NonNull DataSink dataSink,
-                                @NonNull TimeInterpolator timeInterpolator,
-                                @NonNull AudioStretcher audioStretcher,
-                                @NonNull AudioResampler audioResampler) {
-        super(dataSource, dataSink, TrackType.AUDIO);
-        mTimeInterpolator = timeInterpolator;
-        mAudioStretcher = audioStretcher;
-        mAudioResampler = audioResampler;
-    }
-
-    @Override
-    protected void onCodecsStarted(@NonNull MediaFormat inputFormat, @NonNull MediaFormat outputFormat, @NonNull MediaCodec decoder, @NonNull MediaCodec encoder) {
-        super.onCodecsStarted(inputFormat, outputFormat, decoder, encoder);
-        mEncoder = encoder;
-        mEncoderOutputFormat = outputFormat;
-    }
-
-    @Override
-    protected boolean onFeedEncoder(@NonNull MediaCodec encoder, @NonNull MediaCodecBuffers encoderBuffers, long timeoutUs) {
-        if (mAudioEngine == null) return false;
-        return mAudioEngine.feedEncoder(encoderBuffers, timeoutUs);
-    }
-
-    @Override
-    protected void onDecoderOutputFormatChanged(@NonNull MediaCodec decoder, @NonNull MediaFormat format) {
-        super.onDecoderOutputFormatChanged(decoder, format);
-        mAudioEngine = new AudioEngine(decoder, format,
-                mEncoder, mEncoderOutputFormat,
-                mTimeInterpolator,
-                mAudioStretcher,
-                mAudioResampler);
-        mEncoder = null;
-        mEncoderOutputFormat = null;
-        mTimeInterpolator = null;
-        mAudioStretcher = null;
-        mAudioResampler = null;
-    }
-
-    @Override
-    protected void onDrainDecoder(@NonNull MediaCodec decoder, int bufferIndex, @NonNull ByteBuffer bufferData, long presentationTimeUs, boolean endOfStream) {
-        mAudioEngine.drainDecoder(bufferIndex, bufferData, presentationTimeUs, endOfStream);
-    }
-}
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/transcode/BaseTrackTranscoder.java b/lib/src/main/java/com/otaliastudios/transcoder/transcode/BaseTrackTranscoder.java
deleted file mode 100644
index 53f547c1..00000000
--- a/lib/src/main/java/com/otaliastudios/transcoder/transcode/BaseTrackTranscoder.java
+++ /dev/null
@@ -1,319 +0,0 @@
-package com.otaliastudios.transcoder.transcode;
-
-import android.media.MediaCodec;
-import android.media.MediaExtractor;
-import android.media.MediaFormat;
-
-import androidx.annotation.CallSuper;
-import androidx.annotation.NonNull;
-
-import com.otaliastudios.transcoder.engine.TrackType;
-import com.otaliastudios.transcoder.internal.MediaCodecBuffers;
-import com.otaliastudios.transcoder.sink.DataSink;
-import com.otaliastudios.transcoder.source.DataSource;
-
-import java.io.IOException;
-import java.nio.ByteBuffer;
-
-/**
- * A base implementation of {@link TrackTranscoder} that reads
- * from {@link MediaExtractor} and does feeding and draining job.
- */
-public abstract class BaseTrackTranscoder implements TrackTranscoder {
-
-    private static final int DRAIN_STATE_NONE = 0;
-    private static final int DRAIN_STATE_SHOULD_RETRY_IMMEDIATELY = 1;
-    private static final int DRAIN_STATE_CONSUMED = 2;
-
-    private final DataSource mDataSource;
-    private final DataSource.Chunk mDataChunk;
-    private final DataSink mDataSink;
-    private final TrackType mTrackType;
-
-    private final MediaCodec.BufferInfo mBufferInfo = new MediaCodec.BufferInfo();
-    private MediaCodec mDecoder;
-    private MediaCodec mEncoder;
-    private MediaCodecBuffers mDecoderBuffers;
-    private MediaCodecBuffers mEncoderBuffers;
-    private boolean mDecoderStarted;
-    private boolean mEncoderStarted;
-    private MediaFormat mActualOutputFormat;
-
-    private boolean mIsDecoderEOS;
-    private boolean mIsEncoderEOS;
-    private boolean mIsExtractorEOS;
-
-    @SuppressWarnings("WeakerAccess")
-    protected BaseTrackTranscoder(@NonNull DataSource dataSource,
-                                  @NonNull DataSink dataSink,
-                                  @NonNull TrackType trackType) {
-        mDataSource = dataSource;
-        mDataSink = dataSink;
-        mTrackType = trackType;
-        mDataChunk = new DataSource.Chunk();
-    }
-
-    @Override
-    public final void setUp(@NonNull MediaFormat desiredOutputFormat) {
-        try {
-            mEncoder = MediaCodec.createEncoderByType(desiredOutputFormat.getString(MediaFormat.KEY_MIME));
-        } catch (IOException e) {
-            throw new IllegalStateException(e);
-        }
-        onConfigureEncoder(desiredOutputFormat, mEncoder);
-        onStartEncoder(desiredOutputFormat, mEncoder);
-
-        final MediaFormat inputFormat = mDataSource.getTrackFormat(mTrackType);
-        if (inputFormat == null) {
-            throw new IllegalArgumentException("Input format is null!");
-        }
-        try {
-            mDecoder = MediaCodec.createDecoderByType(inputFormat.getString(MediaFormat.KEY_MIME));
-        } catch (IOException e) {
-            throw new IllegalStateException(e);
-        }
-        onConfigureDecoder(inputFormat, mDecoder);
-        onStartDecoder(inputFormat, mDecoder);
-        onCodecsStarted(inputFormat, desiredOutputFormat, mDecoder, mEncoder);
-    }
-
-    /**
-     * Wraps the configure operation on the encoder.
-     * @param format output format
-     * @param encoder encoder
-     */
-    protected void onConfigureEncoder(@NonNull MediaFormat format, @NonNull MediaCodec encoder) {
-        encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
-    }
-
-    /**
-     * Wraps the start operation on the encoder.
-     * @param format output format
-     * @param encoder encoder
-     */
-    @CallSuper
-    protected void onStartEncoder(@NonNull MediaFormat format, @NonNull MediaCodec encoder) {
-        encoder.start();
-        mEncoderStarted = true;
-        mEncoderBuffers = new MediaCodecBuffers(encoder);
-    }
-
-    /**
-     * Wraps the configure operation on the decoder.
-     * @param format input format
-     * @param decoder decoder
-     */
-    protected void onConfigureDecoder(@NonNull MediaFormat format, @NonNull MediaCodec decoder) {
-        decoder.configure(format, null, null, 0);
-    }
-
-    /**
-     * Wraps the start operation on the decoder.
-     * @param format input format
-     * @param decoder decoder
-     */
-    @SuppressWarnings({"WeakerAccess", "unused"})
-    @CallSuper
-    protected void onStartDecoder(@NonNull MediaFormat format, @NonNull MediaCodec decoder) {
-        decoder.start();
-        mDecoderStarted = true;
-        mDecoderBuffers = new MediaCodecBuffers(decoder);
-    }
-
-    /**
-     * Called when both codecs have been started with the given formats.
-     * @param inputFormat input format
-     * @param outputFormat output format
-     * @param decoder decoder
-     * @param encoder encoder
-     */
-    protected void onCodecsStarted(@NonNull MediaFormat inputFormat, @NonNull MediaFormat outputFormat,
-                                   @NonNull MediaCodec decoder, @NonNull MediaCodec encoder) {
-    }
-
-    @Override
-    public final boolean isFinished() {
-        return mIsEncoderEOS;
-    }
-
-    @Override
-    public void release() {
-        if (mDecoder != null) {
-            if (mDecoderStarted) {
-                mDecoder.stop();
-                mDecoderStarted = false;
-            }
-            mDecoder.release();
-            mDecoder = null;
-        }
-        if (mEncoder != null) {
-            if (mEncoderStarted) {
-                mEncoder.stop();
-                mEncoderStarted = false;
-            }
-            mEncoder.release();
-            mEncoder = null;
-        }
-    }
-
-    @Override
-    public final boolean transcode(boolean forceInputEos) {
-        boolean busy = false;
-        int status;
-        while (drainEncoder(0) != DRAIN_STATE_NONE) busy = true;
-        do {
-            status = drainDecoder(0);
-            if (status != DRAIN_STATE_NONE) busy = true;
-            // NOTE: not repeating to keep from deadlock when encoder is full.
-        } while (status == DRAIN_STATE_SHOULD_RETRY_IMMEDIATELY);
-
-        while (feedEncoder(0)) busy = true;
-        while (feedDecoder(0, forceInputEos) != DRAIN_STATE_NONE) busy = true;
-        return busy;
-    }
-
-    /**
-     * Called when the decoder has defined its actual output format.
-     * @param format format
-     */
-    @CallSuper
-    protected void onDecoderOutputFormatChanged(@NonNull MediaCodec decoder, @NonNull MediaFormat format) {}
-
-    /**
-     * Called when the encoder has defined its actual output format.
-     * @param format format
-     */
-    @CallSuper
-    @SuppressWarnings("WeakerAccess")
-    protected void onEncoderOutputFormatChanged(@NonNull MediaCodec encoder, @NonNull MediaFormat format) {
-        if (mActualOutputFormat != null) {
-            throw new RuntimeException("Audio output format changed twice.");
-        }
-        mActualOutputFormat = format;
-        mDataSink.setTrackFormat(mTrackType, mActualOutputFormat);
-    }
-
-    @SuppressWarnings("SameParameterValue")
-    private int feedDecoder(long timeoutUs, boolean forceInputEos) {
-        if (mIsExtractorEOS) {
-            return DRAIN_STATE_NONE;
-        }
-
-        if (mDataSource.isDrained() || forceInputEos) {
-            int result = mDecoder.dequeueInputBuffer(timeoutUs);
-            if (result < 0) return DRAIN_STATE_NONE;
-            mIsExtractorEOS = true;
-            mDecoder.queueInputBuffer(result, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM);
-            return DRAIN_STATE_NONE;
-        }
-
-        if (!mDataSource.canReadTrack(mTrackType)) {
-            return DRAIN_STATE_NONE;
-        }
-
-        final int result = mDecoder.dequeueInputBuffer(timeoutUs);
-        if (result < 0) return DRAIN_STATE_NONE;
-
-        mDataChunk.buffer = mDecoderBuffers.getInputBuffer(result);
-        mDataSource.readTrack(mDataChunk);
-        mDecoder.queueInputBuffer(result,
-                0,
-                mDataChunk.bytes,
-                mDataChunk.timestampUs,
-                mDataChunk.isKeyFrame ? MediaCodec.BUFFER_FLAG_SYNC_FRAME : 0);
-        return DRAIN_STATE_CONSUMED;
-    }
-
-    @SuppressWarnings("SameParameterValue")
-    private boolean feedEncoder(long timeoutUs) {
-        return onFeedEncoder(mEncoder, mEncoderBuffers, timeoutUs);
-    }
-
-    @SuppressWarnings("SameParameterValue")
-    private int drainDecoder(long timeoutUs) {
-        if (mIsDecoderEOS) return DRAIN_STATE_NONE;
-        int result = mDecoder.dequeueOutputBuffer(mBufferInfo, timeoutUs);
-        switch (result) {
-            case MediaCodec.INFO_TRY_AGAIN_LATER:
-                return DRAIN_STATE_NONE;
-            case MediaCodec.INFO_OUTPUT_FORMAT_CHANGED:
-                onDecoderOutputFormatChanged(mDecoder, mDecoder.getOutputFormat());
-                return DRAIN_STATE_SHOULD_RETRY_IMMEDIATELY;
-            case MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED:
-                return DRAIN_STATE_SHOULD_RETRY_IMMEDIATELY;
-        }
-
-        boolean isEos = (mBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0;
-        boolean hasSize = mBufferInfo.size > 0;
-        if (isEos) mIsDecoderEOS = true;
-        if (isEos || hasSize) {
-            onDrainDecoder(mDecoder,
-                    result,
-                    mDecoderBuffers.getOutputBuffer(result),
-                    mBufferInfo.presentationTimeUs,
-                    isEos);
-        }
-        return DRAIN_STATE_CONSUMED;
-    }
-
-    @SuppressWarnings("SameParameterValue")
-    private int drainEncoder(long timeoutUs) {
-        if (mIsEncoderEOS) return DRAIN_STATE_NONE;
-
-        int result = mEncoder.dequeueOutputBuffer(mBufferInfo, timeoutUs);
-        switch (result) {
-            case MediaCodec.INFO_TRY_AGAIN_LATER:
-                return DRAIN_STATE_NONE;
-            case MediaCodec.INFO_OUTPUT_FORMAT_CHANGED:
-                onEncoderOutputFormatChanged(mEncoder, mEncoder.getOutputFormat());
-                return DRAIN_STATE_SHOULD_RETRY_IMMEDIATELY;
-            case MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED:
-                mEncoderBuffers.onOutputBuffersChanged();
-                return DRAIN_STATE_SHOULD_RETRY_IMMEDIATELY;
-        }
-
-        if (mActualOutputFormat == null) {
-            throw new RuntimeException("Could not determine actual output format.");
-        }
-
-        if ((mBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
-            mIsEncoderEOS = true;
-            mBufferInfo.set(0, 0, 0, mBufferInfo.flags);
-        }
-        if ((mBufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) {
-            // SPS or PPS, which should be passed by MediaFormat.
-            mEncoder.releaseOutputBuffer(result, false);
-            return DRAIN_STATE_SHOULD_RETRY_IMMEDIATELY;
-        }
-        mDataSink.writeTrack(mTrackType, mEncoderBuffers.getOutputBuffer(result), mBufferInfo);
-        mEncoder.releaseOutputBuffer(result, false);
-        return DRAIN_STATE_CONSUMED;
-    }
-
-    /**
-     * Called to drain the decoder. Implementors are required to call {@link MediaCodec#releaseOutputBuffer(int, boolean)}
-     * with the given bufferIndex at some point.
-     *
-     * @param decoder the decoder
-     * @param bufferIndex the buffer index to be released
-     * @param bufferData  the buffer data
-     * @param presentationTimeUs frame timestamp
-     * @param endOfStream whether we are in end of stream
-     */
-    protected abstract void onDrainDecoder(@NonNull MediaCodec decoder,
-                                           int bufferIndex,
-                                           @NonNull ByteBuffer bufferData,
-                                           long presentationTimeUs,
-                                           boolean endOfStream);
-
-
-    /**
-     * Called to feed the encoder with processed data.
-     * @param encoder the encoder
-     * @param timeoutUs a timeout for this op
-     * @return true if we want to keep working
-     */
-    protected abstract boolean onFeedEncoder(@NonNull MediaCodec encoder,
-                                             @NonNull MediaCodecBuffers encoderBuffers,
-                                             long timeoutUs);
-}
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/transcode/NoOpTrackTranscoder.java b/lib/src/main/java/com/otaliastudios/transcoder/transcode/NoOpTrackTranscoder.java
deleted file mode 100644
index b3aab9a9..00000000
--- a/lib/src/main/java/com/otaliastudios/transcoder/transcode/NoOpTrackTranscoder.java
+++ /dev/null
@@ -1,44 +0,0 @@
-/*
- * Copyright (C) 2014 Yuya Tanaka
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.otaliastudios.transcoder.transcode;
-
-import android.media.MediaFormat;
-
-import androidx.annotation.NonNull;
-
-public class NoOpTrackTranscoder implements TrackTranscoder {
-
-    public NoOpTrackTranscoder() {
-    }
-
-    @Override
-    public void setUp(@NonNull MediaFormat desiredOutputFormat) {
-    }
-
-    @Override
-    public boolean transcode(boolean forceInputEos) {
-        return false;
-    }
-
-    @Override
-    public boolean isFinished() {
-        return true;
-    }
-
-    @Override
-    public void release() {
-    }
-}
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/transcode/PassThroughTrackTranscoder.java b/lib/src/main/java/com/otaliastudios/transcoder/transcode/PassThroughTrackTranscoder.java
deleted file mode 100644
index 7b86b714..00000000
--- a/lib/src/main/java/com/otaliastudios/transcoder/transcode/PassThroughTrackTranscoder.java
+++ /dev/null
@@ -1,98 +0,0 @@
-/*
- * Copyright (C) 2014 Yuya Tanaka
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.otaliastudios.transcoder.transcode;
-
-import android.media.MediaCodec;
-import android.media.MediaFormat;
-
-import androidx.annotation.NonNull;
-
-import com.otaliastudios.transcoder.engine.TrackType;
-import com.otaliastudios.transcoder.sink.DataSink;
-import com.otaliastudios.transcoder.source.DataSource;
-import com.otaliastudios.transcoder.time.TimeInterpolator;
-
-import java.nio.ByteBuffer;
-import java.nio.ByteOrder;
-
-public class PassThroughTrackTranscoder implements TrackTranscoder {
-
-    private final DataSource mDataSource;
-    private final DataSink mDataSink;
-    private final DataSource.Chunk mDataChunk;
-    private final TrackType mTrackType;
-    private final MediaCodec.BufferInfo mBufferInfo = new MediaCodec.BufferInfo();
-    private boolean mIsEOS;
-    private final MediaFormat mOutputFormat;
-    private boolean mOutputFormatSet = false;
-    private TimeInterpolator mTimeInterpolator;
-
-    public PassThroughTrackTranscoder(@NonNull DataSource dataSource,
-                                      @NonNull DataSink dataSink,
-                                      @NonNull TrackType trackType,
-                                      @NonNull TimeInterpolator timeInterpolator) {
-        mDataSource = dataSource;
-        mDataSink = dataSink;
-        mTrackType = trackType;
-        mOutputFormat = dataSource.getTrackFormat(trackType);
-        if (mOutputFormat == null) {
-            throw new IllegalArgumentException("Output format is null!");
-        }
-        int bufferSize = mOutputFormat.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE);
-        mDataChunk = new DataSource.Chunk();
-        mDataChunk.buffer = ByteBuffer.allocateDirect(bufferSize).order(ByteOrder.nativeOrder());
-        mTimeInterpolator = timeInterpolator;
-    }
-
-    @Override
-    public void setUp(@NonNull MediaFormat desiredOutputFormat) { }
-
-    @Override
-    public boolean transcode(boolean forceInputEos) {
-        if (mIsEOS) return false;
-        if (!mOutputFormatSet) {
-            mDataSink.setTrackFormat(mTrackType, mOutputFormat);
-            mOutputFormatSet = true;
-        }
-        if (mDataSource.isDrained() || forceInputEos) {
-            mDataChunk.buffer.clear();
-            mBufferInfo.set(0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM);
-            mDataSink.writeTrack(mTrackType, mDataChunk.buffer, mBufferInfo);
-            mIsEOS = true;
-            return true;
-        }
-        if (!mDataSource.canReadTrack(mTrackType)) {
-            return false;
-        }
-
-        mDataChunk.buffer.clear();
-        mDataSource.readTrack(mDataChunk);
-        long timestampUs = mTimeInterpolator.interpolate(mTrackType, mDataChunk.timestampUs);
-        int flags = mDataChunk.isKeyFrame ? MediaCodec.BUFFER_FLAG_SYNC_FRAME : 0;
-        mBufferInfo.set(0, mDataChunk.bytes, timestampUs, flags);
-        mDataSink.writeTrack(mTrackType, mDataChunk.buffer, mBufferInfo);
-        return true;
-    }
-
-    @Override
-    public boolean isFinished() {
-        return mIsEOS;
-    }
-
-    @Override
-    public void release() {
-    }
-}
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/transcode/TrackTranscoder.java b/lib/src/main/java/com/otaliastudios/transcoder/transcode/TrackTranscoder.java
deleted file mode 100644
index 0d9c18e7..00000000
--- a/lib/src/main/java/com/otaliastudios/transcoder/transcode/TrackTranscoder.java
+++ /dev/null
@@ -1,40 +0,0 @@
-/*
- * Copyright (C) 2014 Yuya Tanaka
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.otaliastudios.transcoder.transcode;
-
-import android.media.MediaFormat;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import com.otaliastudios.transcoder.engine.TrackType;
-
-public interface TrackTranscoder {
-
-    void setUp(@NonNull MediaFormat desiredOutputFormat);
-
-    /**
-     * Perform transcoding if output is available in any step of it.
-     * It assumes muxer has been started, so you should call muxer.start() first.
-     *
-     * @return true if data moved in pipeline.
-     */
-    boolean transcode(boolean forceInputEos);
-
-    boolean isFinished();
-
-    void release();
-}
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/transcode/VideoTrackTranscoder.java b/lib/src/main/java/com/otaliastudios/transcoder/transcode/VideoTrackTranscoder.java
deleted file mode 100644
index 1f782566..00000000
--- a/lib/src/main/java/com/otaliastudios/transcoder/transcode/VideoTrackTranscoder.java
+++ /dev/null
@@ -1,172 +0,0 @@
-/*
- * Copyright (C) 2014 Yuya Tanaka
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.otaliastudios.transcoder.transcode;
-
-import android.media.MediaCodec;
-import android.media.MediaExtractor;
-import android.media.MediaFormat;
-
-import androidx.annotation.NonNull;
-
-import com.otaliastudios.transcoder.engine.TrackType;
-import com.otaliastudios.transcoder.internal.MediaCodecBuffers;
-import com.otaliastudios.transcoder.sink.DataSink;
-import com.otaliastudios.transcoder.source.DataSource;
-import com.otaliastudios.transcoder.time.TimeInterpolator;
-import com.otaliastudios.transcoder.transcode.internal.VideoDecoderOutput;
-import com.otaliastudios.transcoder.transcode.internal.VideoEncoderInput;
-import com.otaliastudios.transcoder.internal.Logger;
-import com.otaliastudios.transcoder.internal.MediaFormatConstants;
-import com.otaliastudios.transcoder.transcode.internal.VideoFrameDropper;
-
-import java.nio.ByteBuffer;
-
-// Refer: https://android.googlesource.com/platform/cts/+/lollipop-release/tests/tests/media/src/android/media/cts/ExtractDecodeEditEncodeMuxTest.java
-public class VideoTrackTranscoder extends BaseTrackTranscoder {
-
-    private static final String TAG = VideoTrackTranscoder.class.getSimpleName();
-    @SuppressWarnings("unused")
-    private static final Logger LOG = new Logger(TAG);
-
-    private VideoDecoderOutput mDecoderOutputSurface;
-    private VideoEncoderInput mEncoderInputSurface;
-    private MediaCodec mEncoder; // Keep this since we want to signal EOS on it.
-    private VideoFrameDropper mFrameDropper;
-    private final TimeInterpolator mTimeInterpolator;
-    private final int mSourceRotation;
-    private final int mExtraRotation;
-
-    public VideoTrackTranscoder(
-            @NonNull DataSource dataSource,
-            @NonNull DataSink dataSink,
-            @NonNull TimeInterpolator timeInterpolator,
-            int rotation) {
-        super(dataSource, dataSink, TrackType.VIDEO);
-        mTimeInterpolator = timeInterpolator;
-        mSourceRotation = dataSource.getOrientation();
-        mExtraRotation = rotation;
-    }
-
-    @Override
-    protected void onConfigureEncoder(@NonNull MediaFormat format, @NonNull MediaCodec encoder) {
-        // Flip the width and height as needed. This means rotating the VideoStrategy rotation
-        // by the amount that was set in the TranscoderOptions.
-        // It is possible that the format has its own KEY_ROTATION, but we don't care, that will
-        // be respected at playback time.
-        int width = format.getInteger(MediaFormat.KEY_WIDTH);
-        int height = format.getInteger(MediaFormat.KEY_HEIGHT);
-        boolean flip = (mExtraRotation % 180) != 0;
-        format.setInteger(MediaFormat.KEY_WIDTH, flip ? height : width);
-        format.setInteger(MediaFormat.KEY_HEIGHT, flip ? width : height);
-        super.onConfigureEncoder(format, encoder);
-    }
-
-    @Override
-    protected void onStartEncoder(@NonNull MediaFormat format, @NonNull MediaCodec encoder) {
-        mEncoderInputSurface = new VideoEncoderInput(encoder.createInputSurface());
-        super.onStartEncoder(format, encoder);
-    }
-
-    @Override
-    protected void onConfigureDecoder(@NonNull MediaFormat format, @NonNull MediaCodec decoder) {
-        // Just a sanity check that the rotation coming from DataSource is not different from
-        // the one found in the DataSource's MediaFormat for video.
-        int sourceRotation = 0;
-        if (format.containsKey(MediaFormatConstants.KEY_ROTATION_DEGREES)) {
-            sourceRotation = format.getInteger(MediaFormatConstants.KEY_ROTATION_DEGREES);
-        }
-        if (sourceRotation != mSourceRotation) {
-            throw new RuntimeException("Unexpected difference in rotation." +
-                    " DataSource:" + mSourceRotation +
-                    " MediaFormat:" + sourceRotation);
-        }
-
-        // Decoded video is rotated automatically in Android 5.0 lollipop. Turn off here because we don't want to encode rotated one.
-        // refer: https://android.googlesource.com/platform/frameworks/av/+blame/lollipop-release/media/libstagefright/Utils.cpp
-        format.setInteger(MediaFormatConstants.KEY_ROTATION_DEGREES, 0);
-
-        // The rotation we should apply is the intrinsic source rotation, plus any extra
-        // rotation that was set into the TranscoderOptions.
-        mDecoderOutputSurface = new VideoDecoderOutput();
-        mDecoderOutputSurface.setRotation((mSourceRotation + mExtraRotation) % 360);
-        decoder.configure(format, mDecoderOutputSurface.getSurface(), null, 0);
-    }
-
-    @Override
-    protected void onCodecsStarted(@NonNull MediaFormat inputFormat, @NonNull MediaFormat outputFormat, @NonNull MediaCodec decoder, @NonNull MediaCodec encoder) {
-        super.onCodecsStarted(inputFormat, outputFormat, decoder, encoder);
-        mFrameDropper = VideoFrameDropper.newDropper(
-                inputFormat.getInteger(MediaFormat.KEY_FRAME_RATE),
-                outputFormat.getInteger(MediaFormat.KEY_FRAME_RATE));
-        mEncoder = encoder;
-
-        // Cropping support.
-        // Ignoring any outputFormat KEY_ROTATION (which is applied at playback time), the rotation
-        // difference between input and output is mSourceRotation + mExtraRotation.
-        int rotation = (mSourceRotation + mExtraRotation) % 360;
-        boolean flip = (rotation % 180) != 0;
-        float inputWidth = inputFormat.getInteger(MediaFormat.KEY_WIDTH);
-        float inputHeight = inputFormat.getInteger(MediaFormat.KEY_HEIGHT);
-        float inputRatio = inputWidth / inputHeight;
-        float outputWidth = flip ? outputFormat.getInteger(MediaFormat.KEY_HEIGHT) : outputFormat.getInteger(MediaFormat.KEY_WIDTH);
-        float outputHeight = flip ? outputFormat.getInteger(MediaFormat.KEY_WIDTH) : outputFormat.getInteger(MediaFormat.KEY_HEIGHT);
-        float outputRatio = outputWidth / outputHeight;
-        float scaleX = 1, scaleY = 1;
-        if (inputRatio > outputRatio) { // Input wider. We have a scaleX.
-            scaleX = inputRatio / outputRatio;
-        } else if (inputRatio < outputRatio) { // Input taller. We have a scaleY.
-            scaleY = outputRatio / inputRatio;
-        }
-        mDecoderOutputSurface.setScale(scaleX, scaleY);
-    }
-
-    @Override
-    public void release() {
-        if (mDecoderOutputSurface != null) {
-            mDecoderOutputSurface.release();
-            mDecoderOutputSurface = null;
-        }
-        if (mEncoderInputSurface != null) {
-            mEncoderInputSurface.release();
-            mEncoderInputSurface = null;
-        }
-        super.release();
-        mEncoder = null;
-    }
-
-    @Override
-    protected boolean onFeedEncoder(@NonNull MediaCodec encoder, @NonNull MediaCodecBuffers encoderBuffers, long timeoutUs) {
-        // We do not feed the encoder, instead we wait for the encoder surface onFrameAvailable callback.
-        return false;
-    }
-
-    @Override
-    protected void onDrainDecoder(@NonNull MediaCodec decoder, int bufferIndex, @NonNull ByteBuffer bufferData, long presentationTimeUs, boolean endOfStream) {
-        if (endOfStream) {
-            mEncoder.signalEndOfInputStream();
-            decoder.releaseOutputBuffer(bufferIndex, false);
-        } else {
-            long interpolatedTimeUs = mTimeInterpolator.interpolate(TrackType.VIDEO, presentationTimeUs);
-            if (mFrameDropper.shouldRenderFrame(interpolatedTimeUs)) {
-                decoder.releaseOutputBuffer(bufferIndex, true);
-                mDecoderOutputSurface.drawFrame();
-                mEncoderInputSurface.onFrame(interpolatedTimeUs);
-            } else {
-                decoder.releaseOutputBuffer(bufferIndex, false);
-            }
-        }
-    }
-}
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/transcode/internal/AudioBuffer.java b/lib/src/main/java/com/otaliastudios/transcoder/transcode/internal/AudioBuffer.java
deleted file mode 100644
index 0f8150b8..00000000
--- a/lib/src/main/java/com/otaliastudios/transcoder/transcode/internal/AudioBuffer.java
+++ /dev/null
@@ -1,10 +0,0 @@
-package com.otaliastudios.transcoder.transcode.internal;
-
-import java.nio.ShortBuffer;
-
-class AudioBuffer {
-    int decoderBufferIndex = -1;
-    long decoderTimestampUs = 0;
-    ShortBuffer decoderData = null;
-    boolean isEndOfStream = false;
-}
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/transcode/internal/AudioConversions.java b/lib/src/main/java/com/otaliastudios/transcoder/transcode/internal/AudioConversions.java
deleted file mode 100644
index be0df1b8..00000000
--- a/lib/src/main/java/com/otaliastudios/transcoder/transcode/internal/AudioConversions.java
+++ /dev/null
@@ -1,34 +0,0 @@
-package com.otaliastudios.transcoder.transcode.internal;
-
-class AudioConversions {
-
-    private static final int BYTES_PER_SAMPLE_PER_CHANNEL = 2; // Assuming 16bit audio, so 2
-    private static final long MICROSECONDS_PER_SECOND = 1000000L;
-    private static final int BYTES_PER_SHORT = 2;
-
-    @SuppressWarnings("WeakerAccess")
-    static long bytesToUs(
-            int bytes /* bytes */,
-            int sampleRate /* samples/sec */,
-            int channels /* channel */
-    ) {
-        int byteRatePerChannel = sampleRate * BYTES_PER_SAMPLE_PER_CHANNEL; // bytes/sec/channel
-        int byteRate = byteRatePerChannel * channels; // bytes/sec
-        return MICROSECONDS_PER_SECOND * bytes / byteRate; // usec
-    }
-
-    @SuppressWarnings("WeakerAccess")
-    static int usToBytes(long us, int sampleRate, int channels) {
-        int byteRatePerChannel = sampleRate * BYTES_PER_SAMPLE_PER_CHANNEL;
-        int byteRate = byteRatePerChannel * channels;
-        return (int) Math.ceil((double) us * byteRate / MICROSECONDS_PER_SECOND);
-    }
-
-    static long shortsToUs(int shorts, int sampleRate, int channels) {
-        return bytesToUs(shorts * BYTES_PER_SHORT, sampleRate, channels);
-    }
-
-    static int usToShorts(long us, int sampleRate, int channels) {
-        return usToBytes(us, sampleRate, channels) / BYTES_PER_SHORT;
-    }
-}
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/transcode/internal/AudioEngine.java b/lib/src/main/java/com/otaliastudios/transcoder/transcode/internal/AudioEngine.java
deleted file mode 100644
index bb3f5cb5..00000000
--- a/lib/src/main/java/com/otaliastudios/transcoder/transcode/internal/AudioEngine.java
+++ /dev/null
@@ -1,316 +0,0 @@
-package com.otaliastudios.transcoder.transcode.internal;
-
-import android.media.MediaCodec;
-import android.media.MediaFormat;
-
-import androidx.annotation.NonNull;
-
-import com.otaliastudios.transcoder.engine.TrackType;
-import com.otaliastudios.transcoder.internal.Logger;
-import com.otaliastudios.transcoder.internal.MediaCodecBuffers;
-import com.otaliastudios.transcoder.remix.AudioRemixer;
-import com.otaliastudios.transcoder.resample.AudioResampler;
-import com.otaliastudios.transcoder.resample.DefaultAudioResampler;
-import com.otaliastudios.transcoder.stretch.AudioStretcher;
-import com.otaliastudios.transcoder.time.TimeInterpolator;
-
-import java.nio.Buffer;
-import java.nio.ByteBuffer;
-import java.nio.ByteOrder;
-import java.nio.ShortBuffer;
-import java.util.ArrayDeque;
-import java.util.Queue;
-
-/**
- * Channel of raw audio from decoder to encoder.
- * Performs the necessary conversion between different input & output audio formats.
- *
- * We currently support upmixing from mono to stereo & downmixing from stereo to mono.
- * Sample rate conversion is not supported yet.
- */
-public class AudioEngine {
-
-    private static final int BYTES_PER_SHORT = 2;
-
-    private static final String TAG = AudioEngine.class.getSimpleName();
-    private static final Logger LOG = new Logger(TAG);
-
-    private final Queue<AudioBuffer> mEmptyBuffers = new ArrayDeque<>();
-    private final Queue<AudioBuffer> mPendingBuffers = new ArrayDeque<>();
-    private final MediaCodec mDecoder;
-    private final MediaCodec mEncoder;
-    private final int mDecoderSampleRate;
-    private final int mEncoderSampleRate;
-    private final int mDecoderChannels;
-    @SuppressWarnings("FieldCanBeLocal") private final int mEncoderChannels;
-    private final AudioRemixer mRemixer;
-    private final AudioResampler mResampler;
-    private final AudioStretcher mStretcher;
-    private final TimeInterpolator mTimeInterpolator;
-    private long mLastDecoderUs = Long.MIN_VALUE;
-    private long mLastEncoderUs = Long.MIN_VALUE;
-    private ShortBuffer mTempBuffer1;
-    private ShortBuffer mTempBuffer2;
-
-    /**
-     * The AudioEngine should be created when we know the actual decoded format,
-     * which means that the decoder has reached {@link MediaCodec#INFO_OUTPUT_FORMAT_CHANGED}.
-     *
-     * @param decoder a decoder
-     * @param decoderOutputFormat the decoder output format
-     * @param encoder an encoder
-     * @param encoderOutputFormat the encoder output format
-     */
-    public AudioEngine(@NonNull MediaCodec decoder,
-                       @NonNull MediaFormat decoderOutputFormat,
-                       @NonNull MediaCodec encoder,
-                       @NonNull MediaFormat encoderOutputFormat,
-                       @NonNull TimeInterpolator timeInterpolator,
-                       @NonNull AudioStretcher audioStretcher,
-                       @NonNull AudioResampler audioResampler) {
-        mDecoder = decoder;
-        mEncoder = encoder;
-        mTimeInterpolator = timeInterpolator;
-
-        // Get sample rate.
-        mEncoderSampleRate = encoderOutputFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE);
-        mDecoderSampleRate = decoderOutputFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE);
-
-        // Check channel count.
-        mEncoderChannels = encoderOutputFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT);
-        mDecoderChannels = decoderOutputFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT);
-        if (mEncoderChannels != 1 && mEncoderChannels != 2) {
-            throw new UnsupportedOperationException("Output channel count (" + mEncoderChannels + ") not supported.");
-        }
-        if (mDecoderChannels != 1 && mDecoderChannels != 2) {
-            throw new UnsupportedOperationException("Input channel count (" + mDecoderChannels + ") not supported.");
-        }
-
-        // Create remixer, stretcher and resampler.
-        if (mDecoderChannels > mEncoderChannels) {
-            mRemixer = AudioRemixer.DOWNMIX;
-        } else if (mDecoderChannels < mEncoderChannels) {
-            mRemixer = AudioRemixer.UPMIX;
-        } else {
-            mRemixer = AudioRemixer.PASSTHROUGH;
-        }
-        mStretcher = audioStretcher;
-        mResampler = audioResampler;
-    }
-
-    /**
-     * Returns true if we have raw buffers to be processed.
-     * @return true if we have
-     */
-    private boolean hasPendingBuffers() {
-        return !mPendingBuffers.isEmpty();
-    }
-
-    /**
-     * Drains the decoder, which means scheduling data for processing.
-     * We fill a new {@link AudioBuffer} with data (not a copy, just a view of it)
-     * and add it to the filled buffers queue.
-     *
-     * @param bufferIndex the buffer index
-     * @param bufferData the buffer data
-     * @param presentationTimeUs the presentation time
-     * @param endOfStream true if end of stream
-     */
-    public void drainDecoder(final int bufferIndex,
-                             @NonNull ByteBuffer bufferData,
-                             final long presentationTimeUs,
-                             final boolean endOfStream) {
-        if (mRemixer == null) throw new RuntimeException("Buffer received before format!");
-        AudioBuffer buffer = mEmptyBuffers.poll();
-        if (buffer == null) buffer = new AudioBuffer();
-        buffer.decoderBufferIndex = bufferIndex;
-        buffer.decoderTimestampUs = endOfStream ? 0 : presentationTimeUs;
-        buffer.decoderData = endOfStream ? null : bufferData.asShortBuffer();
-        buffer.isEndOfStream = endOfStream;
-        mPendingBuffers.add(buffer);
-    }
-
-    /**
-     * Feeds the encoder, which in our case means processing a filled buffer,
-     * then releasing it and adding it back to {@link #mEmptyBuffers}.
-     *
-     * @param encoderBuffers the encoder buffers
-     * @param timeoutUs a timeout for this operation
-     * @return true if we want to keep working
-     */
-    public boolean feedEncoder(@NonNull MediaCodecBuffers encoderBuffers, long timeoutUs) {
-        if (!hasPendingBuffers()) return false;
-
-        // First of all, see if encoder has buffers that we can write into.
-        // If we don't have an output buffer, there's nothing we can do.
-        final int encoderBufferIndex = mEncoder.dequeueInputBuffer(timeoutUs);
-        if (encoderBufferIndex < 0) return false;
-        ShortBuffer encoderBuffer = encoderBuffers.getInputBuffer(encoderBufferIndex).asShortBuffer();
-        encoderBuffer.clear();
-
-        // Get the latest raw buffer to be processed.
-        AudioBuffer buffer = mPendingBuffers.peek();
-
-        // When endOfStream, just signal EOS and return false.
-        //noinspection ConstantConditions
-        if (buffer.isEndOfStream) {
-            mEncoder.queueInputBuffer(encoderBufferIndex,
-                    0,
-                    0,
-                    0,
-                    MediaCodec.BUFFER_FLAG_END_OF_STREAM);
-            return false;
-        }
-
-        // Process the buffer.
-        boolean overflows = process(buffer, encoderBuffer, encoderBufferIndex);
-        if (overflows) {
-            // If this buffer does overflow, we will keep it in the queue and do
-            // not release. It will be used at the next cycle (presumably soon,
-            // since we return true).
-            return true;
-        } else {
-            // If this buffer does not overflow, it can be removed from our queue,
-            // re-added to empty, and also released from the decoder buffers.
-            mPendingBuffers.remove();
-            mEmptyBuffers.add(buffer);
-            mDecoder.releaseOutputBuffer(buffer.decoderBufferIndex, false);
-            return true;
-        }
-    }
-
-    /**
-     * Processes a pending buffer.
-     *
-     * We have an output buffer of restricted size, and our input size can be already bigger
-     * or grow after a {@link TimeInterpolator} operation or a {@link AudioRemixer} one.
-     *
-     * For this reason, we instead start from the output size and compute the input size that
-     * would generate an output of that size. We then select this part of the input and only
-     * process.
-     *
-     * If input is restricted, this means that the input buffer must be processed again.
-     * We will return true in this case. At the next cycle, the input buffer should be identical
-     * to the previous, but:
-     *
-     * - {@link Buffer#position()} will be increased to exclude the already processed values
-     * - {@link AudioBuffer#decoderTimestampUs} will be increased to include the already processed values
-     *
-     * So everything should work as expected for repeated cycles.
-     *
-     * Before returning, this function should release the encoder buffer using
-     * {@link MediaCodec#queueInputBuffer(int, int, int, long, int)}.
-     *
-     * @param buffer coming from decoder. Has valid limit and position
-     * @param encoderBuffer coming from encoder. At this point this is in a cleared state
-     * @param encoderBufferIndex the index of encoderBuffer so we can release it
-     */
-    private boolean process(@NonNull AudioBuffer buffer, @NonNull ShortBuffer encoderBuffer, int encoderBufferIndex) {
-        // Only process the amount of data that can fill in the encoderBuffer.
-        final int outputSize = encoderBuffer.remaining();
-        final int totalInputSize = buffer.decoderData.remaining();
-        int processedTotalInputSize = totalInputSize;
-
-        // 1. Perform TimeInterpolator computation
-        // TODO we should compare the NEXT timestamp with this, instead of comparing this with previous!
-        long encoderUs = mTimeInterpolator.interpolate(TrackType.AUDIO, buffer.decoderTimestampUs);
-        if (mLastDecoderUs == Long.MIN_VALUE) {
-            mLastDecoderUs = buffer.decoderTimestampUs;
-            mLastEncoderUs = encoderUs;
-        }
-        long decoderDurationUs = buffer.decoderTimestampUs - mLastDecoderUs;
-        long encoderDurationUs = encoderUs - mLastEncoderUs;
-        mLastDecoderUs = buffer.decoderTimestampUs;
-        mLastEncoderUs = encoderUs;
-        double stretchFactor = (double) encoderDurationUs / decoderDurationUs;
-        LOG.i("process - time stretching -" +
-                " decoderDurationUs:" + decoderDurationUs +
-                " encoderDeltaUs:" + encoderDurationUs +
-                " stretchFactor:" + stretchFactor);
-        processedTotalInputSize = (int) Math.ceil(processedTotalInputSize * stretchFactor);
-
-        // 2. Ask remixers how much space they need for the given input
-        processedTotalInputSize = mRemixer.getRemixedSize(processedTotalInputSize);
-
-        // 3. After remixing we'll resample.
-        // Resampling will change the input size based on the sample rate ratio.
-        processedTotalInputSize = (int) Math.ceil((double) processedTotalInputSize
-                * mEncoderSampleRate / mDecoderSampleRate);
-
-        // 4. Compare processedInputSize and outputSize. If processedInputSize > outputSize,
-        // we overflow. In this case, isolate the valid data.
-        boolean overflow = processedTotalInputSize > outputSize;
-        int overflowReduction = 0;
-        if (overflow) {
-            // Compute the input size that matches this output size.
-            double ratio = (double) processedTotalInputSize / totalInputSize; // > 1
-            overflowReduction = totalInputSize - (int) Math.floor((double) outputSize / ratio);
-            LOG.w("process - overflowing! Reduction:" + overflowReduction);
-            buffer.decoderData.limit(buffer.decoderData.limit() - overflowReduction);
-        }
-        final int inputSize = buffer.decoderData.remaining();
-        LOG.i("process - totalInputSize:" + totalInputSize +
-                " processedTotalInputSize:" + processedTotalInputSize +
-                " outputSize:" + outputSize +
-                " inputSize:" + inputSize);
-
-        // 5. Do the stretching. We need a bridge buffer for its output.
-        ensureTempBuffer1((int) Math.ceil(inputSize * stretchFactor));
-        mStretcher.stretch(buffer.decoderData, mTempBuffer1, mDecoderChannels);
-        mTempBuffer1.rewind();
-
-        // 6. Do the actual remixing.
-        ensureTempBuffer2(mRemixer.getRemixedSize((int) Math.ceil(inputSize * stretchFactor)));
-        mRemixer.remix(mTempBuffer1, mTempBuffer2);
-        mTempBuffer2.rewind();
-
-        // 7. Do the actual resampling.
-        mResampler.resample(mTempBuffer2, mDecoderSampleRate, encoderBuffer, mEncoderSampleRate,
-                mDecoderChannels);
-
-        // 8. Add the bytes we have processed to the decoderTimestampUs, and restore the limit.
-        // We need an updated timestamp for the next cycle, since we will cycle on the same input
-        // buffer that has overflown.
-        if (overflow) {
-            buffer.decoderTimestampUs += AudioConversions.shortsToUs(inputSize, mDecoderSampleRate,
-                    mDecoderChannels);
-            buffer.decoderData.limit(buffer.decoderData.limit() + overflowReduction);
-        }
-
-        // 9. Write the buffer.
-        // This is the encoder buffer: we have likely written it all, but let's use
-        // encoderBuffer.position() to know how much anyway.
-        mEncoder.queueInputBuffer(encoderBufferIndex,
-                0,
-                encoderBuffer.position() * BYTES_PER_SHORT,
-                encoderUs,
-                0
-        );
-
-        return overflow;
-    }
-
-    private void ensureTempBuffer1(int desiredSize) {
-        LOG.w("ensureTempBuffer1 - desiredSize:" + desiredSize);
-        if (mTempBuffer1 == null || mTempBuffer1.capacity() < desiredSize) {
-            LOG.w("ensureTempBuffer1 - creating new buffer.");
-            mTempBuffer1 = ByteBuffer.allocateDirect(desiredSize * BYTES_PER_SHORT)
-                    .order(ByteOrder.nativeOrder())
-                    .asShortBuffer();
-        }
-        mTempBuffer1.clear();
-        mTempBuffer1.limit(desiredSize);
-    }
-
-    private void ensureTempBuffer2(int desiredSize) {
-        LOG.w("ensureTempBuffer2 - desiredSize:" + desiredSize);
-        if (mTempBuffer2 == null || mTempBuffer2.capacity() < desiredSize) {
-            LOG.w("ensureTempBuffer2 - creating new buffer.");
-            mTempBuffer2 = ByteBuffer.allocateDirect(desiredSize * BYTES_PER_SHORT)
-                    .order(ByteOrder.nativeOrder())
-                    .asShortBuffer();
-        }
-        mTempBuffer2.clear();
-        mTempBuffer2.limit(desiredSize);
-    }
-}
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/transcode/internal/VideoEncoderInput.java b/lib/src/main/java/com/otaliastudios/transcoder/transcode/internal/VideoEncoderInput.java
deleted file mode 100644
index fc0a6737..00000000
--- a/lib/src/main/java/com/otaliastudios/transcoder/transcode/internal/VideoEncoderInput.java
+++ /dev/null
@@ -1,51 +0,0 @@
-package com.otaliastudios.transcoder.transcode.internal;
-
-
-import android.media.MediaCodec;
-import android.opengl.EGL14;
-import android.view.Surface;
-
-import androidx.annotation.NonNull;
-
-import com.otaliastudios.opengl.core.EglCore;
-import com.otaliastudios.opengl.surface.EglWindowSurface;
-
-/**
- * The purpose of this class is basically doing OpenGL initialization.
- *
- * This class takes as input a Surface obtained from {@link MediaCodec#createInputSurface()},
- * and uses that to create an EGL context and window surface.
- *
- * Calls to {@link #onFrame(long)} cause a frame of data to be sent to the surface, thus
- * to the {@link android.media.MediaCodec} input.
- */
-public class VideoEncoderInput {
-    @SuppressWarnings("unused")
-    private static final String TAG = VideoEncoderInput.class.getSimpleName();
-
-    private EglCore mEglCore;
-    private EglWindowSurface mEglSurface;
-
-    /**
-     * Creates an VideoEncoderInput from a Surface.
-     * Makes the EGL surface current immediately.
-     * @param surface the surface
-     */
-    public VideoEncoderInput(@NonNull Surface surface) {
-        mEglCore = new EglCore(EGL14.EGL_NO_CONTEXT, EglCore.FLAG_RECORDABLE);
-        mEglSurface = new EglWindowSurface(mEglCore, surface, true);
-        mEglSurface.makeCurrent();
-    }
-
-    public void onFrame(long presentationTimeUs) {
-        mEglSurface.setPresentationTime(presentationTimeUs * 1000L);
-        mEglSurface.swapBuffers();
-    }
-
-    public void release() {
-        // NOTE: Original code calls android.view.Surface.release()
-        // after the egl core releasing. This should not be an issue.
-        mEglSurface.release();
-        mEglCore.release();
-    }
-}
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/transcode/internal/VideoFrameDropper.java b/lib/src/main/java/com/otaliastudios/transcoder/transcode/internal/VideoFrameDropper.java
deleted file mode 100644
index d1c7ea85..00000000
--- a/lib/src/main/java/com/otaliastudios/transcoder/transcode/internal/VideoFrameDropper.java
+++ /dev/null
@@ -1,116 +0,0 @@
-package com.otaliastudios.transcoder.transcode.internal;
-
-import androidx.annotation.NonNull;
-
-import com.otaliastudios.transcoder.internal.Logger;
-
-/**
- * Drops input frames to respect the output frame rate.
- */
-public abstract class VideoFrameDropper {
-
-    private final static String TAG = VideoFrameDropper.class.getSimpleName();
-    private final static Logger LOG = new Logger(TAG);
-
-    private VideoFrameDropper() {}
-
-    public abstract boolean shouldRenderFrame(long presentationTimeUs);
-
-    @NonNull
-    public static VideoFrameDropper newDropper(int inputFrameRate, int outputFrameRate) {
-        return new Dropper1(inputFrameRate, outputFrameRate);
-    }
-
-    /**
-     * A simple and more elegant dropper.
-     * Reference: https://stackoverflow.com/questions/4223766/dropping-video-frames
-     */
-    private static class Dropper1 extends VideoFrameDropper {
-
-        private double mInFrameRateReciprocal;
-        private double mOutFrameRateReciprocal;
-        private double mFrameRateReciprocalSum;
-        private int mFrameCount;
-
-        private Dropper1(int inputFrameRate, int outputFrameRate) {
-            mInFrameRateReciprocal = 1.0d / inputFrameRate;
-            mOutFrameRateReciprocal = 1.0d / outputFrameRate;
-            LOG.i("inFrameRateReciprocal:" + mInFrameRateReciprocal + " outFrameRateReciprocal:" + mOutFrameRateReciprocal);
-        }
-
-        @Override
-        public boolean shouldRenderFrame(long presentationTimeUs) {
-            mFrameRateReciprocalSum += mInFrameRateReciprocal;
-            if (mFrameCount++ == 0) {
-                LOG.v("RENDERING (first frame) - frameRateReciprocalSum:" + mFrameRateReciprocalSum);
-                return true;
-            } else if (mFrameRateReciprocalSum > mOutFrameRateReciprocal) {
-                mFrameRateReciprocalSum -= mOutFrameRateReciprocal;
-                LOG.v("RENDERING - frameRateReciprocalSum:" + mFrameRateReciprocalSum);
-                return true;
-            } else {
-                LOG.v("DROPPING - frameRateReciprocalSum:" + mFrameRateReciprocalSum);
-                return false;
-            }
-        }
-    }
-
-    /**
-     * The old dropper, keeping here just in case.
-     * Will test {@link Dropper1} and remove this soon.
-     */
-    @SuppressWarnings("unused")
-    private static class Dropper2 extends VideoFrameDropper {
-
-        // A step is defined as the microseconds between two frame.
-        // The average step is basically 1 / frame rate.
-        private float mAvgStep = 0;
-        private float mTargetAvgStep;
-        private int mRenderedSteps = -1; // frames - 1
-        private long mLastRenderedUs;
-        private long mLastStep;
-
-        private Dropper2(int outputFrameRate) {
-            mTargetAvgStep = (1F / outputFrameRate) * 1000 * 1000;
-
-        }
-
-        /**
-         * TODO improve this. as it is now, rendering a frame after dropping many,
-         * will not decrease avgStep but rather increase it (for this single frame; then it starts decreasing).
-         * This has the effect that, when a frame is rendered, the following frame is always rendered,
-         * because the conditions are worse then before. After this second frame things go back to normal,
-         * but this is terrible logic.
-         */
-        @Override
-        public boolean shouldRenderFrame(long presentationTimeUs) {
-            if (mRenderedSteps > 0 && mAvgStep < mTargetAvgStep) {
-                // We are rendering too much. Drop this frame.
-                // Always render first 2 frames, we need them to compute the avg.
-                LOG.v("DROPPING - avg:" + mAvgStep + " target:" + mTargetAvgStep);
-                long newLastStep = presentationTimeUs - mLastRenderedUs;
-                float allSteps = (mAvgStep * mRenderedSteps) - mLastStep + newLastStep;
-                mAvgStep = allSteps / mRenderedSteps; // we didn't add a step, just increased the last
-                mLastStep = newLastStep;
-                return false;
-            } else {
-                // Render this frame, since our average step is too long or exact.
-                LOG.v("RENDERING - avg:" + mAvgStep + " target:" + mTargetAvgStep + " newStepCount:" + (mRenderedSteps + 1));
-                if (mRenderedSteps >= 0) {
-                    // Update the average value, since now we have mLastRenderedUs.
-                    long step = presentationTimeUs - mLastRenderedUs;
-                    float allSteps = (mAvgStep * mRenderedSteps) + step;
-                    mAvgStep = allSteps / (mRenderedSteps + 1); // we added a step, so +1
-                    mLastStep = step;
-                }
-                // Increment both
-                mRenderedSteps++;
-                mLastRenderedUs = presentationTimeUs;
-                return true;
-            }
-        }
-    }
-
-
-
-}
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/validator/DefaultValidator.java b/lib/src/main/java/com/otaliastudios/transcoder/validator/DefaultValidator.java
index 38a3bc5b..877a392c 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/validator/DefaultValidator.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/validator/DefaultValidator.java
@@ -2,7 +2,7 @@
 
 import androidx.annotation.NonNull;
 
-import com.otaliastudios.transcoder.engine.TrackStatus;
+import com.otaliastudios.transcoder.common.TrackStatus;
 
 /**
  * The default {@link Validator} to understand whether to keep going with the
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/validator/Validator.java b/lib/src/main/java/com/otaliastudios/transcoder/validator/Validator.java
index ebd8099c..3f6b3407 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/validator/Validator.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/validator/Validator.java
@@ -2,7 +2,7 @@
 
 import androidx.annotation.NonNull;
 
-import com.otaliastudios.transcoder.engine.TrackStatus;
+import com.otaliastudios.transcoder.common.TrackStatus;
 import com.otaliastudios.transcoder.strategy.TrackStrategy;
 
 /**
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/validator/WriteAlwaysValidator.java b/lib/src/main/java/com/otaliastudios/transcoder/validator/WriteAlwaysValidator.java
index 11c16369..85119a0e 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/validator/WriteAlwaysValidator.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/validator/WriteAlwaysValidator.java
@@ -2,7 +2,7 @@
 
 import androidx.annotation.NonNull;
 
-import com.otaliastudios.transcoder.engine.TrackStatus;
+import com.otaliastudios.transcoder.common.TrackStatus;
 
 /**
  * A {@link Validator} that always writes to target file, no matter the track status,
diff --git a/lib/src/main/java/com/otaliastudios/transcoder/validator/WriteVideoValidator.java b/lib/src/main/java/com/otaliastudios/transcoder/validator/WriteVideoValidator.java
index a87759c8..ec62b904 100644
--- a/lib/src/main/java/com/otaliastudios/transcoder/validator/WriteVideoValidator.java
+++ b/lib/src/main/java/com/otaliastudios/transcoder/validator/WriteVideoValidator.java
@@ -2,7 +2,7 @@
 
 import androidx.annotation.NonNull;
 
-import com.otaliastudios.transcoder.engine.TrackStatus;
+import com.otaliastudios.transcoder.common.TrackStatus;
 
 /**
  * A {@link Validator} that gives priority to the video track.
diff --git a/settings.gradle b/settings.gradle
deleted file mode 100644
index 4178041e..00000000
--- a/settings.gradle
+++ /dev/null
@@ -1 +0,0 @@
-include ':lib', ':demo'
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 00000000..4e474bb4
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,19 @@
+pluginManagement {
+    repositories {
+        mavenLocal()
+        google()
+        gradlePluginPortal()
+        mavenCentral()
+    }
+}
+
+dependencyResolutionManagement {
+    repositories {
+        mavenCentral()
+        google()
+    }
+}
+
+include(":lib")
+include(":lib-legacy")
+include(":demo")
