├── .github ├── workflows │ ├── .java-version │ ├── gradle-wrapper.yaml │ └── build.yaml └── renovate.json5 ├── gradle.properties ├── example.png ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── libs.versions.toml ├── .gitignore ├── settings.gradle ├── .editorconfig ├── src ├── commonMain │ └── kotlin │ │ └── com │ │ └── jakewharton │ │ └── videoswatch │ │ ├── RgbColor.kt │ │ ├── native.kt │ │ ├── SliceSummarizer.kt │ │ ├── render.kt │ │ ├── Closer.kt │ │ └── main.kt ├── nativeInterop │ └── cinterop │ │ ├── libffmpeg.def │ │ └── libpng.def └── commonTest │ └── kotlin │ └── com │ └── jakewharton │ └── videoswatch │ ├── RenderTxtTest.kt │ ├── SliceSummarizerTest.kt │ └── RenderPngTest.kt ├── README.md ├── gradlew.bat ├── gradlew └── LICENSE.txt /.github/workflows/.java-version: -------------------------------------------------------------------------------- 1 | 25 2 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.native.cacheKind=none 2 | -------------------------------------------------------------------------------- /example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JakeWharton/video-swatch/HEAD/example.png -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JakeWharton/video-swatch/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Gradle 2 | build 3 | .gradle 4 | /reports 5 | 6 | # Kotlin 7 | .kotlin 8 | 9 | # IntelliJ 10 | .idea 11 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'video-swatch' 2 | 3 | dependencyResolutionManagement { 4 | repositories { 5 | mavenCentral() 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.yaml] 12 | indent_style = space 13 | 14 | [*.{kt,kts}] 15 | ij_kotlin_imports_layout=* 16 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /.github/workflows/gradle-wrapper.yaml: -------------------------------------------------------------------------------- 1 | name: gradle-wrapper 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - 'gradlew' 7 | - 'gradlew.bat' 8 | - 'gradle/wrapper/' 9 | 10 | jobs: 11 | validate: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v6 15 | - uses: gradle/actions/wrapper-validation@v5 16 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/jakewharton/videoswatch/RgbColor.kt: -------------------------------------------------------------------------------- 1 | package com.jakewharton.videoswatch 2 | 3 | data class RgbColor( 4 | val r: UByte, 5 | val g: UByte, 6 | val b: UByte, 7 | ) { 8 | override fun toString(): String { 9 | return "#" + 10 | r.toString(16).padStart(2, '0') + 11 | g.toString(16).padStart(2, '0') + 12 | b.toString(16).padStart(2, '0') 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/jakewharton/videoswatch/native.kt: -------------------------------------------------------------------------------- 1 | package com.jakewharton.videoswatch 2 | 3 | internal inline fun Int.checkReturn(message: () -> String) = apply { 4 | check(this >= 0) { message() + " ($this)" } 5 | } 6 | 7 | internal fun T?.checkAlloc(name: String? = null) = checkNotNull(this) { 8 | buildString { 9 | append("Unable to allocate") 10 | if (name != null) { 11 | append(": ") 12 | append(name) 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | kotlin = "2.3.0" 3 | 4 | [libraries] 5 | kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } 6 | spotless-gradlePlugin = "com.diffplug.spotless:spotless-plugin-gradle:8.1.0" 7 | kotlinx-dateTime = "org.jetbrains.kotlinx:kotlinx-datetime:0.7.1" 8 | ktlint = "com.pinterest.ktlint:ktlint-cli:1.8.0" 9 | clikt = "com.github.ajalt.clikt:clikt:5.0.3" 10 | assertk = "com.willowtreeapps.assertk:assertk:0.28.1" 11 | okio = "com.squareup.okio:okio:3.16.4" 12 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | pull_request: {} 5 | workflow_dispatch: {} 6 | push: 7 | branches: 8 | - 'trunk' 9 | tags-ignore: 10 | - '**' 11 | 12 | jobs: 13 | build: 14 | runs-on: macos-15 15 | steps: 16 | - run: brew install ffmpeg 17 | - uses: actions/checkout@v6 18 | - uses: actions/setup-java@v5 19 | with: 20 | distribution: 'zulu' 21 | java-version-file: .github/workflows/.java-version 22 | 23 | - run: ./gradlew build 24 | -------------------------------------------------------------------------------- /src/nativeInterop/cinterop/libffmpeg.def: -------------------------------------------------------------------------------- 1 | package=com.jakewharton.videoswatch.ffmpeg 2 | headers=libavcodec/avcodec.h libavformat/avformat.h libavutil/imgutils.h libswscale/swscale.h 3 | libraryPaths=/opt/homebrew/opt/ffmpeg/lib 4 | compilerOpts=-I/opt/homebrew/opt/ffmpeg/include 5 | linkerOpts=-L/opt/homebrew/opt/ffmpeg/lib -lavcodec -lavformat -lavutil -lswscale 6 | 7 | --- 8 | 9 | static void avformat_close_input2(AVFormatContext* ref) { 10 | AVFormatContext* copy = ref; 11 | avformat_close_input(©); 12 | } 13 | 14 | static void avcodec_free_context2(AVCodecContext* ref) { 15 | AVCodecContext* copy = ref; 16 | avcodec_free_context(©); 17 | } 18 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | $schema: 'https://docs.renovatebot.com/renovate-schema.json', 3 | extends: [ 4 | 'config:recommended', 5 | ], 6 | ignorePresets: [ 7 | // Ensure we get the latest version and are not pinned to old versions. 8 | 'workarounds:javaLTSVersions', 9 | ], 10 | customManagers: [ 11 | // Update .java-version file with the latest JDK version. 12 | { 13 | customType: 'regex', 14 | fileMatch: [ 15 | '\\.java-version$', 16 | ], 17 | matchStrings: [ 18 | '(?.*)\\n', 19 | ], 20 | datasourceTemplate: 'java-version', 21 | depNameTemplate: 'java', 22 | // Only write the major version. 23 | extractVersionTemplate: '^(?\\d+)', 24 | }, 25 | ], 26 | } 27 | -------------------------------------------------------------------------------- /src/nativeInterop/cinterop/libpng.def: -------------------------------------------------------------------------------- 1 | package=com.jakewharton.videoswatch.png 2 | headers=png.h 3 | libraryPaths=/opt/homebrew/opt/libpng/lib 4 | compilerOpts=-I/opt/homebrew/opt/libpng/include 5 | linkerOpts=-L/opt/homebrew/opt/libpng/lib -lpng 6 | 7 | --- 8 | 9 | static inline jmp_buf* png_jmpbuf2(png_structp p) { 10 | return png_jmpbuf(p); 11 | } 12 | 13 | static inline void png_destroy_write_struct2(png_structp png_ptr, png_infop info_ptr) { 14 | png_structp* png_ptr_copy = png_ptr; 15 | png_infop* info_ptr_copy = info_ptr; 16 | png_destroy_write_struct(&png_ptr_copy, &info_ptr_copy); 17 | } 18 | 19 | static inline void png_destroy_info_struct2(png_structp png_ptr, png_infop info_ptr) { 20 | png_infop* info_ptr_copy = info_ptr; 21 | png_destroy_info_struct(png_ptr, &info_ptr_copy); 22 | } 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Video Swatch 2 | 3 | Extract a per-frame color from video and render to an image. 4 | 5 | ![Example output from The Iron Giant showing thousands of single-color vertical lines consisting mostly of dark blues and oranges with some light blue sections towards the end.](example.png) 6 | 7 | 8 | # License 9 | 10 | Copyright 2024 Jake Wharton 11 | 12 | Licensed under the Apache License, Version 2.0 (the "License"); 13 | you may not use this file except in compliance with the License. 14 | You may obtain a copy of the License at 15 | 16 | http://www.apache.org/licenses/LICENSE-2.0 17 | 18 | Unless required by applicable law or agreed to in writing, software 19 | distributed under the License is distributed on an "AS IS" BASIS, 20 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 21 | See the License for the specific language governing permissions and 22 | limitations under the License. 23 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com/jakewharton/videoswatch/RenderTxtTest.kt: -------------------------------------------------------------------------------- 1 | package com.jakewharton.videoswatch 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEmpty 5 | import assertk.assertions.isEqualTo 6 | import kotlin.test.Test 7 | 8 | class RenderTxtTest { 9 | @Test fun empty() { 10 | assertThat(renderTxt(emptyList())).isEmpty() 11 | } 12 | 13 | @Test fun one() { 14 | assertThat( 15 | renderTxt( 16 | listOf( 17 | RgbColor(r = 16.toUByte(), 32.toUByte(), 64.toUByte()), 18 | ), 19 | ), 20 | ).isEqualTo( 21 | """ 22 | |#102040 23 | | 24 | """.trimMargin(), 25 | ) 26 | } 27 | 28 | @Test fun many() { 29 | assertThat( 30 | renderTxt( 31 | listOf( 32 | RgbColor(r = 16.toUByte(), 32.toUByte(), 64.toUByte()), 33 | RgbColor(r = 33.toUByte(), 1.toUByte(), 154.toUByte()), 34 | RgbColor(r = 218.toUByte(), 85.toUByte(), 49.toUByte()), 35 | RgbColor(r = 92.toUByte(), 77.toUByte(), 79.toUByte()), 36 | RgbColor(r = 12.toUByte(), 255.toUByte(), 202.toUByte()), 37 | ), 38 | ), 39 | ).isEqualTo( 40 | """ 41 | |#102040 42 | |#21019a 43 | |#da5531 44 | |#5c4d4f 45 | |#0cffca 46 | | 47 | """.trimMargin(), 48 | ) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com/jakewharton/videoswatch/SliceSummarizerTest.kt: -------------------------------------------------------------------------------- 1 | package com.jakewharton.videoswatch 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.containsExactly 5 | import assertk.assertions.isEmpty 6 | import assertk.assertions.isEqualTo 7 | import kotlin.test.Test 8 | 9 | class SliceSummarizerTest { 10 | @Test fun empty() { 11 | val summarizer = SliceSummarizer(framePixels = 10) 12 | assertThat(summarizer.summarize()).isEmpty() 13 | } 14 | 15 | @Test fun gapsAreKept() { 16 | val summarizer = SliceSummarizer(framePixels = 10) 17 | summarizer += FrameSummary(slice = 0, red = 40L, green = 160L, blue = 640L) 18 | summarizer += FrameSummary(slice = 2, red = 40L, green = 160L, blue = 640L) 19 | assertThat(summarizer.summarize()).containsExactly( 20 | RgbColor(r = 2.toUByte(), g = 4.toUByte(), b = 8.toUByte()), 21 | RgbColor(r = 0.toUByte(), g = 0.toUByte(), b = 0.toUByte()), 22 | RgbColor(r = 2.toUByte(), g = 4.toUByte(), b = 8.toUByte()), 23 | ) 24 | } 25 | 26 | @Test fun expandsPastEstimate() { 27 | val summarizer = SliceSummarizer(framePixels = 10, sliceEstimate = 10) 28 | repeat(11) { slice -> 29 | summarizer += FrameSummary( 30 | slice = slice, 31 | red = 2 * 2 * 10L, 32 | green = 4 * 4 * 10L, 33 | blue = 8 * 8 * 10L, 34 | ) 35 | } 36 | val expected = MutableList(11) { 37 | RgbColor(r = 2.toUByte(), g = 4.toUByte(), b = 8.toUByte()) 38 | } 39 | assertThat(summarizer.summarize()).isEqualTo(expected) 40 | } 41 | 42 | @Test fun summarizeDividesAndRoots() { 43 | val summarizer = SliceSummarizer(framePixels = 10) 44 | for (i in 10..100 step 10) { 45 | summarizer += FrameSummary( 46 | slice = 0, 47 | red = i * i * 10L, 48 | green = i * i * 10L, 49 | blue = i * i * 10L, 50 | ) 51 | } 52 | val colors = summarizer.summarize() 53 | assertThat(colors).containsExactly( 54 | RgbColor(r = 62.toUByte(), g = 62.toUByte(), b = 62.toUByte()), 55 | ) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com/jakewharton/videoswatch/RenderPngTest.kt: -------------------------------------------------------------------------------- 1 | package com.jakewharton.videoswatch 2 | 3 | import assertk.assertFailure 4 | import assertk.assertThat 5 | import assertk.assertions.hasMessage 6 | import assertk.assertions.isEqualTo 7 | import assertk.assertions.isInstanceOf 8 | import kotlin.test.Test 9 | import okio.ByteString 10 | import okio.ByteString.Companion.decodeHex 11 | 12 | class RenderPngTest { 13 | @Test fun empty() { 14 | assertFailure { renderPng(emptyList()) } 15 | .isInstanceOf() 16 | .hasMessage("Colors must be non-empty") 17 | } 18 | 19 | @Test fun one() { 20 | assertThat( 21 | renderPng( 22 | listOf( 23 | RgbColor(r = 16.toUByte(), 32.toUByte(), 64.toUByte()), 24 | ), 25 | ), 26 | ).isEqualTo( 27 | """ 28 | 8950 4e47 0d0a 1a0a 0000 000d 4948 4452 29 | 0000 0001 0000 0001 0802 0000 0090 7753 30 | de00 0000 0173 5247 4200 aece 1ce9 0000 31 | 000c 4944 4154 0899 6310 5070 0000 00b4 32 | 0071 24e1 96a9 0000 0000 4945 4e44 ae42 33 | 6082 34 | """.decodeHexWithWhitespace(), 35 | ) 36 | } 37 | 38 | @Test fun many() { 39 | assertThat( 40 | renderPng( 41 | listOf( 42 | RgbColor(r = 16.toUByte(), 32.toUByte(), 64.toUByte()), 43 | RgbColor(r = 33.toUByte(), 1.toUByte(), 154.toUByte()), 44 | RgbColor(r = 218.toUByte(), 85.toUByte(), 49.toUByte()), 45 | RgbColor(r = 92.toUByte(), 77.toUByte(), 79.toUByte()), 46 | RgbColor(r = 12.toUByte(), 255.toUByte(), 202.toUByte()), 47 | ), 48 | ), 49 | ).isEqualTo( 50 | """ 51 | 8950 4e47 0d0a 1a0a 0000 000d 4948 4452 52 | 0000 0005 0000 0002 0802 0000 001f 0881 53 | 0a00 0000 0173 5247 4200 aece 1ce9 0000 54 | 001c 4944 4154 0899 6316 5070 90fc 5875 55 | 2af4 c97b 65f3 7b37 1733 31a0 0200 ac8d 56 | 07a0 30ce ec9c 0000 0000 4945 4e44 ae42 57 | 6082 58 | """.decodeHexWithWhitespace(), 59 | ) 60 | } 61 | 62 | private fun String.decodeHexWithWhitespace(): ByteString { 63 | return replace(Regex("\\s"), "").decodeHex() 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/jakewharton/videoswatch/SliceSummarizer.kt: -------------------------------------------------------------------------------- 1 | package com.jakewharton.videoswatch 2 | 3 | import kotlin.math.sqrt 4 | 5 | data class FrameSummary( 6 | /** The slice of the video to which this frame belongs. */ 7 | val slice: Int, 8 | /** The sum of the square of each red pixel in a frame. */ 9 | val red: Long, 10 | /** The sum of the square of each green pixel in a frame. */ 11 | val green: Long, 12 | /** The sum of the square of each blue pixel in a frame. */ 13 | val blue: Long, 14 | ) 15 | 16 | class SliceSummarizer( 17 | /** The number of pixels in each frame. */ 18 | private val framePixels: Int, 19 | /** An estimate for the number of slices. */ 20 | sliceEstimate: Int = 1000, 21 | ) { 22 | private var length = sliceEstimate.coerceAtLeast(10) 23 | private var reds = DoubleArray(length) 24 | private var greens = DoubleArray(length) 25 | private var blues = DoubleArray(length) 26 | private var frameCounts = IntArray(length) 27 | private var sliceCount = 0 28 | 29 | private fun doubleStorage() { 30 | val newLength = length * 2 31 | reds = reds.copyOf(newLength) 32 | greens = greens.copyOf(newLength) 33 | blues = blues.copyOf(newLength) 34 | frameCounts = frameCounts.copyOf(newLength) 35 | length = newLength 36 | } 37 | 38 | operator fun plusAssign(frameSummary: FrameSummary) { 39 | val slice = frameSummary.slice 40 | if (slice >= length) { 41 | doubleStorage() 42 | } 43 | sliceCount = maxOf(sliceCount, slice + 1) 44 | 45 | reds[slice] += frameSummary.red.toDouble() 46 | greens[slice] += frameSummary.green.toDouble() 47 | blues[slice] += frameSummary.blue.toDouble() 48 | frameCounts[slice]++ 49 | } 50 | 51 | fun summarize(): List { 52 | return MutableList(sliceCount) { slice -> 53 | val frameCount = frameCounts[slice] 54 | val slicePixels = frameCount * framePixels 55 | RgbColor( 56 | r = sqrt(reds[slice] / slicePixels).toUInt().toUByte(), 57 | g = sqrt(greens[slice] / slicePixels).toUInt().toUByte(), 58 | b = sqrt(blues[slice] / slicePixels).toUInt().toUByte(), 59 | ) 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/jakewharton/videoswatch/render.kt: -------------------------------------------------------------------------------- 1 | package com.jakewharton.videoswatch 2 | 3 | import com.jakewharton.videoswatch.png.PNG_FORMAT_RGB 4 | import com.jakewharton.videoswatch.png.PNG_IMAGE_VERSION 5 | import com.jakewharton.videoswatch.png.png_alloc_size_tVar 6 | import com.jakewharton.videoswatch.png.png_byteVar 7 | import com.jakewharton.videoswatch.png.png_image 8 | import com.jakewharton.videoswatch.png.png_image_write_to_memory 9 | import kotlinx.cinterop.alloc 10 | import kotlinx.cinterop.allocArray 11 | import kotlinx.cinterop.convert 12 | import kotlinx.cinterop.memScoped 13 | import kotlinx.cinterop.ptr 14 | import kotlinx.cinterop.set 15 | import kotlinx.cinterop.toKString 16 | import kotlinx.cinterop.value 17 | import okio.ByteString 18 | import okio.readByteString 19 | import platform.posix.uint8_tVar 20 | 21 | internal fun renderTxt(colors: List): String = buildString(colors.size * 8) { 22 | for (color in colors) { 23 | append(color) 24 | append('\n') 25 | } 26 | } 27 | 28 | internal fun renderPng(colors: List): ByteString = memScoped { 29 | require(colors.isNotEmpty()) { "Colors must be non-empty" } 30 | 31 | val width = colors.size 32 | val stride = width * 3 33 | val height = (width * 9 / 16).coerceAtLeast(1) 34 | 35 | val png = alloc() 36 | png.width = width.toUInt() 37 | png.height = height.toUInt() 38 | png.format = PNG_FORMAT_RGB 39 | png.version = PNG_IMAGE_VERSION.convert() 40 | 41 | val buffer = allocArray(stride * height) 42 | for (y in 0 until height) { 43 | for (x in 0 until width) { 44 | val color = colors[x] 45 | val offset = y * stride + x * 3 46 | buffer[offset] = color.r 47 | buffer[offset + 1] = color.g 48 | buffer[offset + 2] = color.b 49 | } 50 | } 51 | 52 | val pngSizeVar = alloc().checkAlloc("pngSizeVar") 53 | val sizeWriteResult = png_image_write_to_memory( 54 | image = png.ptr, 55 | memory = null, 56 | memory_bytes = pngSizeVar.ptr, 57 | convert_to_8_bit = 0, 58 | buffer = buffer, 59 | row_stride = stride, 60 | colormap = null, 61 | ) 62 | check(sizeWriteResult != 0) { 63 | "Size write failed: ${png.message.toKString()}" 64 | } 65 | 66 | val pngSize = pngSizeVar.value.toInt() 67 | check(pngSizeVar.value == pngSize.toULong()) { 68 | "PNG size ${pngSizeVar.value} too large" 69 | } 70 | 71 | val pngBytes = allocArray(pngSize) 72 | val bufferWriteResult = png_image_write_to_memory( 73 | image = png.ptr, 74 | memory = pngBytes, 75 | memory_bytes = pngSizeVar.ptr, 76 | convert_to_8_bit = 0, 77 | buffer = buffer, 78 | row_stride = stride, 79 | colormap = null, 80 | ) 81 | check(bufferWriteResult != 0) { 82 | "Buffer write failed: ${png.message.toKString()}" 83 | } 84 | 85 | return pngBytes.readByteString(pngSize) 86 | } 87 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | 74 | 75 | @rem Execute Gradle 76 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 77 | 78 | :end 79 | @rem End local scope for the variables with windows NT shell 80 | if %ERRORLEVEL% equ 0 goto mainEnd 81 | 82 | :fail 83 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 84 | rem the _cmd.exe /c_ return code! 85 | set EXIT_CODE=%ERRORLEVEL% 86 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 87 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 88 | exit /b %EXIT_CODE% 89 | 90 | :mainEnd 91 | if "%OS%"=="Windows_NT" endlocal 92 | 93 | :omega 94 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/jakewharton/videoswatch/Closer.kt: -------------------------------------------------------------------------------- 1 | package com.jakewharton.videoswatch 2 | 3 | import kotlin.concurrent.AtomicInt 4 | import kotlin.contracts.InvocationKind.EXACTLY_ONCE 5 | import kotlin.contracts.contract 6 | 7 | /** Tracks closable resources and closes them all at once. */ 8 | internal class Closer : AutoCloseable { 9 | private val closeables = mutableListOf() 10 | 11 | /** Add an [AutoCloseable] resource to be closed. */ 12 | operator fun plusAssign(closeable: AutoCloseable) { 13 | closeables += closeable 14 | } 15 | 16 | /** Add a lambda resource to be invoked when closed. */ 17 | operator fun plusAssign(action: () -> Unit) { 18 | closeables += ActionCloseable(action) 19 | } 20 | 21 | private class ActionCloseable( 22 | private val action: () -> Unit, 23 | ) : AutoCloseable { 24 | private val closed = AtomicInt(0) 25 | override fun close() { 26 | if (closed.compareAndSet(0, 1)) { 27 | action() 28 | } 29 | } 30 | } 31 | 32 | /** 33 | * Create a new [Closer] and add it as a resource to this instance. 34 | * Closing the child will only close its resources. 35 | * Closing this instance will close the child's resources as well. 36 | */ 37 | fun childCloser(): Closer { 38 | val child = Closer() 39 | closeables += child 40 | return child 41 | } 42 | 43 | /** 44 | * Close the resources tracked by this instance. 45 | * Resources are closed in reverse order from which they were added. 46 | * Any exceptions which occur during closing will be thrown as the first exception 47 | * with all subsequent ones attached as [suppressed exceptions][Throwable.addSuppressed]. 48 | */ 49 | override fun close() { 50 | performClose(null)?.let { 51 | throw it 52 | } 53 | } 54 | 55 | /** 56 | * Close the resources tracked by this instance. 57 | * Resources are closed in reverse order from which they were added. 58 | * Any exceptions which occur during closing will be ignored. 59 | */ 60 | fun closeQuietly() { 61 | performClose(null) 62 | } 63 | 64 | /** 65 | * Close the resources tracked by this instance in response to an exception. 66 | * Resources are closed in reverse order from which they were added. 67 | * Any exceptions which occur during closing will be attached as 68 | * [suppressed exceptions][Throwable.addSuppressed] to [t]. 69 | */ 70 | fun closeAndRethrow(t: Throwable): Nothing { 71 | performClose(t) 72 | throw t 73 | } 74 | 75 | private fun performClose(initialThrowable: Throwable?): Throwable? { 76 | var t = initialThrowable 77 | for (closeable in closeables.asReversed()) { 78 | try { 79 | closeable.close() 80 | } catch (e: Exception) { 81 | if (t == null) { 82 | t = e 83 | } else { 84 | t.addSuppressed(e) 85 | } 86 | } 87 | } 88 | return t 89 | } 90 | } 91 | 92 | internal class CloserScope(val closer: Closer) { 93 | fun R.scopedUse(): R = also(closer::plusAssign) 94 | fun R.scopedUseWithClose(action: (R) -> Unit): R = apply { 95 | closer += { action(this) } 96 | } 97 | } 98 | 99 | /** 100 | * Create a child [Closer] which will be closed whether [block] returns successfully 101 | * or throws an exception. 102 | * 103 | * @see Closer.childCloser 104 | */ 105 | internal fun CloserScope.childCloseFinallyScope(block: CloserScope.() -> R): R { 106 | contract { 107 | callsInPlace(block, EXACTLY_ONCE) 108 | } 109 | return closer.childCloser().use { 110 | CloserScope(it).block() 111 | } 112 | } 113 | 114 | /** 115 | * Create a [Closer] which will be closed whether [block] returns successfully 116 | * or throws an exception. 117 | */ 118 | internal fun closeFinallyScope(block: CloserScope.() -> R): R { 119 | contract { 120 | callsInPlace(block, EXACTLY_ONCE) 121 | } 122 | return Closer().use { 123 | CloserScope(it).block() 124 | } 125 | } 126 | 127 | /** 128 | * Create a [Closer] which will be closed if [block] throws. 129 | * If no exception is thrown, you **must** encapsulate the [`closer`][CloserScope.closer] 130 | * within the returned value and close it at a later time. 131 | */ 132 | internal inline fun closeOnThrowScope(block: CloserScope.() -> R): R { 133 | contract { 134 | callsInPlace(block, EXACTLY_ONCE) 135 | } 136 | val closer = Closer() 137 | return try { 138 | CloserScope(closer).block() 139 | } catch (t: Throwable) { 140 | closer.closeAndRethrow(t) 141 | } 142 | } 143 | 144 | internal inline fun T.useWithClose(action: (T) -> Unit, block: (T) -> R): R { 145 | return try { 146 | block(this) 147 | } finally { 148 | action(this) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | 118 | 119 | # Determine the Java command to use to start the JVM. 120 | if [ -n "$JAVA_HOME" ] ; then 121 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 122 | # IBM's JDK on AIX uses strange locations for the executables 123 | JAVACMD=$JAVA_HOME/jre/sh/java 124 | else 125 | JAVACMD=$JAVA_HOME/bin/java 126 | fi 127 | if [ ! -x "$JAVACMD" ] ; then 128 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 129 | 130 | Please set the JAVA_HOME variable in your environment to match the 131 | location of your Java installation." 132 | fi 133 | else 134 | JAVACMD=java 135 | if ! command -v java >/dev/null 2>&1 136 | then 137 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 138 | 139 | Please set the JAVA_HOME variable in your environment to match the 140 | location of your Java installation." 141 | fi 142 | fi 143 | 144 | # Increase the maximum file descriptors if we can. 145 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 146 | case $MAX_FD in #( 147 | max*) 148 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 149 | # shellcheck disable=SC2039,SC3045 150 | MAX_FD=$( ulimit -H -n ) || 151 | warn "Could not query maximum file descriptor limit" 152 | esac 153 | case $MAX_FD in #( 154 | '' | soft) :;; #( 155 | *) 156 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 157 | # shellcheck disable=SC2039,SC3045 158 | ulimit -n "$MAX_FD" || 159 | warn "Could not set maximum file descriptor limit to $MAX_FD" 160 | esac 161 | fi 162 | 163 | # Collect all arguments for the java command, stacking in reverse order: 164 | # * args from the command line 165 | # * the main class name 166 | # * -classpath 167 | # * -D...appname settings 168 | # * --module-path (only if needed) 169 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 170 | 171 | # For Cygwin or MSYS, switch paths to Windows format before running java 172 | if "$cygwin" || "$msys" ; then 173 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 174 | 175 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 176 | 177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 178 | for arg do 179 | if 180 | case $arg in #( 181 | -*) false ;; # don't mess with options #( 182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 183 | [ -e "$t" ] ;; #( 184 | *) false ;; 185 | esac 186 | then 187 | arg=$( cygpath --path --ignore --mixed "$arg" ) 188 | fi 189 | # Roll the args list around exactly as many times as the number of 190 | # args, so each arg winds up back in the position where it started, but 191 | # possibly modified. 192 | # 193 | # NB: a `for` loop captures its iteration list before it begins, so 194 | # changing the positional parameters here affects neither the number of 195 | # iterations, nor the values presented in `arg`. 196 | shift # remove old arg 197 | set -- "$@" "$arg" # push replacement arg 198 | done 199 | fi 200 | 201 | 202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 204 | 205 | # Collect all arguments for the java command: 206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 207 | # and any embedded shellness will be escaped. 208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 209 | # treated as '${Hostname}' itself on the command line. 210 | 211 | set -- \ 212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 213 | -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ 214 | "$@" 215 | 216 | # Stop when "xargs" is not available. 217 | if ! command -v xargs >/dev/null 2>&1 218 | then 219 | die "xargs is not available" 220 | fi 221 | 222 | # Use "xargs" to parse quoted args. 223 | # 224 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 225 | # 226 | # In Bash we could simply go: 227 | # 228 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 229 | # set -- "${ARGS[@]}" "$@" 230 | # 231 | # but POSIX shell has neither arrays nor command substitution, so instead we 232 | # post-process each arg (as a line of input to sed) to backslash-escape any 233 | # character that might be a shell metacharacter, then use eval to reverse 234 | # that process (while maintaining the separation between arguments), and wrap 235 | # the whole thing up as a single "set" statement. 236 | # 237 | # This will of course break if any of these variables contains a newline or 238 | # an unmatched quote. 239 | # 240 | 241 | eval "set -- $( 242 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 243 | xargs -n1 | 244 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 245 | tr '\n' ' ' 246 | )" '"$@"' 247 | 248 | exec "$JAVACMD" "$@" 249 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/jakewharton/videoswatch/main.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("DEPRECATION") 2 | 3 | package com.jakewharton.videoswatch 4 | 5 | import com.github.ajalt.clikt.core.CliktCommand 6 | import com.github.ajalt.clikt.core.main 7 | import com.github.ajalt.clikt.parameters.arguments.argument 8 | import com.github.ajalt.clikt.parameters.options.convert 9 | import com.github.ajalt.clikt.parameters.options.flag 10 | import com.github.ajalt.clikt.parameters.options.option 11 | import com.github.ajalt.clikt.parameters.types.int 12 | import com.jakewharton.videoswatch.ffmpeg.AVCodec 13 | import com.jakewharton.videoswatch.ffmpeg.AVERROR_EOF 14 | import com.jakewharton.videoswatch.ffmpeg.AVFormatContext 15 | import com.jakewharton.videoswatch.ffmpeg.AVMEDIA_TYPE_VIDEO 16 | import com.jakewharton.videoswatch.ffmpeg.AVPacket 17 | import com.jakewharton.videoswatch.ffmpeg.AV_PIX_FMT_RGB0 18 | import com.jakewharton.videoswatch.ffmpeg.SWS_BILINEAR 19 | import com.jakewharton.videoswatch.ffmpeg.av_dump_format 20 | import com.jakewharton.videoswatch.ffmpeg.av_find_best_stream 21 | import com.jakewharton.videoswatch.ffmpeg.av_frame_alloc 22 | import com.jakewharton.videoswatch.ffmpeg.av_free 23 | import com.jakewharton.videoswatch.ffmpeg.av_image_fill_arrays 24 | import com.jakewharton.videoswatch.ffmpeg.av_image_get_buffer_size 25 | import com.jakewharton.videoswatch.ffmpeg.av_packet_unref 26 | import com.jakewharton.videoswatch.ffmpeg.av_read_frame 27 | import com.jakewharton.videoswatch.ffmpeg.avcodec_alloc_context3 28 | import com.jakewharton.videoswatch.ffmpeg.avcodec_free_context2 29 | import com.jakewharton.videoswatch.ffmpeg.avcodec_open2 30 | import com.jakewharton.videoswatch.ffmpeg.avcodec_parameters_to_context 31 | import com.jakewharton.videoswatch.ffmpeg.avcodec_receive_frame 32 | import com.jakewharton.videoswatch.ffmpeg.avcodec_send_packet 33 | import com.jakewharton.videoswatch.ffmpeg.avformat_close_input2 34 | import com.jakewharton.videoswatch.ffmpeg.avformat_find_stream_info 35 | import com.jakewharton.videoswatch.ffmpeg.avformat_open_input 36 | import com.jakewharton.videoswatch.ffmpeg.sws_getContext 37 | import com.jakewharton.videoswatch.ffmpeg.sws_scale 38 | import kotlin.time.Clock 39 | import kotlin.time.Duration.Companion.seconds 40 | import kotlin.time.TimeSource 41 | import kotlin.time.measureTime 42 | import kotlinx.cinterop.alloc 43 | import kotlinx.cinterop.allocArray 44 | import kotlinx.cinterop.allocPointerTo 45 | import kotlinx.cinterop.convert 46 | import kotlinx.cinterop.get 47 | import kotlinx.cinterop.memScoped 48 | import kotlinx.cinterop.pointed 49 | import kotlinx.cinterop.ptr 50 | import kotlinx.cinterop.value 51 | import kotlinx.datetime.TimeZone 52 | import kotlinx.datetime.toLocalDateTime 53 | import okio.FileSystem 54 | import okio.Path.Companion.toPath 55 | import platform.posix.EAGAIN 56 | import platform.posix.uint8_tVar 57 | 58 | fun main(vararg args: String) { 59 | SwatchCommand( 60 | clock = Clock.System, 61 | timeZone = TimeZone.currentSystemDefault(), 62 | outputFs = FileSystem.SYSTEM, 63 | ).main(args) 64 | } 65 | 66 | private class SwatchCommand( 67 | private val clock: Clock, 68 | private val timeZone: TimeZone, 69 | private val outputFs: FileSystem, 70 | ) : CliktCommand(name = "video-swatch") { 71 | private val fileName by argument(name = "VIDEO") 72 | private val outputPng by option(metavar = "FILE").convert { it.toPath() } 73 | private val outputTxt by option(metavar = "FILE").convert { it.toPath() } 74 | private val cropHeight by option(metavar = "PIXELS").int() 75 | private val cropWidth by option(metavar = "PIXELS").int() 76 | private val debug by option().flag() 77 | 78 | private fun debugLog(message: () -> String) { 79 | if (debug) { 80 | val time = clock.now().toLocalDateTime(timeZone).time.toString() 81 | val indented = message() 82 | .replace("\n", "\n" + " ".repeat(time.length + 3)) 83 | println("[$time] $indented") 84 | } 85 | } 86 | 87 | override fun run(): Unit = closeFinallyScope { 88 | memScoped { 89 | val formatContextVar = allocPointerTo() 90 | avformat_open_input(formatContextVar.ptr, fileName, null, null).checkReturn { 91 | "Unable to open $fileName" 92 | } 93 | val formatContext = formatContextVar.value!! 94 | closer += { avformat_close_input2(formatContext) } 95 | debugLog { "Opened input" } 96 | 97 | avformat_find_stream_info(formatContext, null).checkReturn { 98 | "Unable to get stream info for $fileName" 99 | } 100 | debugLog { "Got stream info" } 101 | 102 | if (debug) { 103 | av_dump_format(formatContext, 0, fileName, 0) 104 | } 105 | 106 | val codecVar = allocPointerTo() 107 | val videoIndex = av_find_best_stream(formatContext, AVMEDIA_TYPE_VIDEO, -1, -1, codecVar.ptr, 0).checkReturn { 108 | "Didn't find a video stream" 109 | } 110 | val codec = codecVar.value!! 111 | debugLog { "Found video stream (index: $videoIndex)" } 112 | 113 | val codecParameters = formatContext.pointed.streams!![videoIndex]!!.pointed.codecpar!! 114 | val codecContext = avcodec_alloc_context3(codec) 115 | .checkAlloc("codecContext") 116 | .scopedUseWithClose(::avcodec_free_context2) 117 | 118 | avcodec_parameters_to_context(codecContext, codecParameters).checkReturn { 119 | "Cannot copy parameters to context" 120 | } 121 | debugLog { "Parameters copied to context" } 122 | 123 | avcodec_open2(codecContext, codec, null).checkReturn { 124 | "Cannot open codec" 125 | } 126 | debugLog { "Opened codec" } 127 | 128 | val frameWidth = codecContext.pointed.width 129 | val frameHeight = codecContext.pointed.height 130 | val encodedFormat = codecContext.pointed.pix_fmt 131 | val decodedFormat = AV_PIX_FMT_RGB0 132 | 133 | val swsContext = sws_getContext( 134 | frameWidth, 135 | frameHeight, 136 | encodedFormat, 137 | frameWidth, 138 | frameHeight, 139 | decodedFormat, 140 | SWS_BILINEAR.convert(), 141 | null, 142 | null, 143 | null, 144 | ).checkAlloc("swsContext") 145 | 146 | val frameRate = codecParameters.pointed.framerate.run { num.toFloat() / den } 147 | 148 | val bufferSize = av_image_get_buffer_size(decodedFormat, frameWidth, frameHeight, 1) 149 | check(bufferSize == frameWidth * frameHeight * 4) 150 | val decodedBuffer = allocArray(bufferSize) 151 | .checkAlloc("decodedBuffer") 152 | val decodedFrame = av_frame_alloc() 153 | .checkAlloc("decodedFrame") 154 | .scopedUseWithClose(::av_free) 155 | .pointed 156 | av_image_fill_arrays(decodedFrame.data, decodedFrame.linesize, decodedBuffer, decodedFormat, frameWidth, frameHeight, 1) 157 | 158 | val encodedFrame = av_frame_alloc() 159 | .checkAlloc("encodedFrame") 160 | .scopedUseWithClose(::av_free) 161 | .pointed 162 | 163 | val frameXStart = cropWidth?.let { (frameWidth - it) / 2 } ?: 0 164 | val frameXEnd = frameXStart + (cropWidth ?: frameWidth) 165 | val frameYStart = cropHeight?.let { (frameHeight - it) / 2 } ?: 0 166 | val frameYEnd = frameYStart + (cropHeight ?: frameHeight) 167 | require(frameXStart >= 0) { "Expected crop width $cropWidth <= frame width $frameWidth" } 168 | require(frameYStart >= 0) { "Expected crop height $cropHeight <= frame height $frameHeight" } 169 | debugLog { 170 | "Sampling pixel rows $frameYStart..${frameYEnd - 1}, columns $frameXStart..${frameXEnd - 1}" 171 | } 172 | 173 | val framePixelCount = (cropWidth ?: frameWidth) * (cropHeight ?: frameHeight) 174 | val sliceSummarizer = SliceSummarizer(framePixelCount) 175 | 176 | var sliceRemainingFrames = frameRate 177 | var sliceIndex = 0 178 | 179 | var frameIndex = 0 180 | var lastFrameIndex = frameIndex 181 | val firstFrameTime = TimeSource.Monotonic.markNow() 182 | var lastFrameTime = firstFrameTime 183 | 184 | val avPacket = alloc() 185 | while (av_read_frame(formatContext, avPacket.ptr) >= 0) { 186 | if (avPacket.stream_index == videoIndex) { 187 | while (true) { 188 | when (val sendPacketResult = avcodec_send_packet(codecContext, avPacket.ptr)) { 189 | // Packet was accepted by decoder. Break to outer loop to read another. 190 | 0 -> break 191 | 192 | // Decoder buffers are full. Continue to inner drain loop before retrying this one. 193 | -EAGAIN -> {} 194 | 195 | else -> throw IllegalStateException("Error sending packet to decoder: $sendPacketResult") 196 | } 197 | 198 | while (true) { 199 | val receiveFrameResult: Int 200 | val receiveFrameTook = measureTime { 201 | receiveFrameResult = avcodec_receive_frame(codecContext, encodedFrame.ptr) 202 | } 203 | when (receiveFrameResult) { 204 | 0 -> { 205 | val conversionTook = measureTime { 206 | sws_scale( 207 | swsContext, 208 | encodedFrame.data, 209 | encodedFrame.linesize, 210 | 0, 211 | frameHeight, 212 | decodedFrame.data, 213 | decodedFrame.linesize, 214 | ) 215 | } 216 | 217 | val scanPixelsTook = measureTime { 218 | val data = decodedFrame.data[0]!! 219 | 220 | var frameRedSum = 0L 221 | var frameGreenSum = 0L 222 | var frameBlueSum = 0L 223 | for (y in frameYStart until frameYEnd) { 224 | val yOffset = y * frameWidth * 4 225 | for (x in frameXStart until frameXEnd) { 226 | val offset = yOffset + x * 4 227 | 228 | val red = data[offset].toInt() 229 | frameRedSum += red * red 230 | val green = data[offset + 1].toInt() 231 | frameGreenSum += green * green 232 | val blue = data[offset + 2].toInt() 233 | frameBlueSum += blue * blue 234 | } 235 | } 236 | 237 | sliceSummarizer += FrameSummary( 238 | slice = sliceIndex, 239 | red = frameRedSum, 240 | green = frameGreenSum, 241 | blue = frameBlueSum, 242 | ) 243 | } 244 | 245 | if (lastFrameTime.elapsedNow() > 1.seconds) { 246 | lastFrameTime = TimeSource.Monotonic.markNow() 247 | val frames = frameIndex - lastFrameIndex 248 | val avg = frameIndex / firstFrameTime.elapsedNow().inWholeSeconds 249 | println("${frameIndex + 1} frames processed, $frames fps ($avg average)") 250 | lastFrameIndex = frameIndex 251 | } 252 | 253 | debugLog { 254 | """ 255 | |FRAME $frameIndex 256 | | slice index: $sliceIndex 257 | | slice frames remaining: $sliceRemainingFrames 258 | | receiveFrame: $receiveFrameTook 259 | | conversion: $conversionTook 260 | | scanPixels: $scanPixelsTook 261 | """.trimMargin() 262 | } 263 | 264 | sliceRemainingFrames-- 265 | if (sliceRemainingFrames < 0) { 266 | sliceIndex++ 267 | // Add instead of assigning to retain fractional remainder. 268 | sliceRemainingFrames += frameRate 269 | } 270 | 271 | frameIndex++ 272 | } 273 | 274 | AVERROR_EOF, -EAGAIN -> break 275 | 276 | else -> throw IllegalStateException("Error receiving frame $receiveFrameResult") 277 | } 278 | } 279 | } 280 | } 281 | av_packet_unref(avPacket.ptr) 282 | } 283 | 284 | val totalDuration = firstFrameTime.elapsedNow() 285 | val totalFps = frameIndex / totalDuration.inWholeSeconds 286 | println() 287 | println("${frameIndex + 1} frames, $totalFps fps, $totalDuration") 288 | 289 | val colors = sliceSummarizer.summarize() 290 | 291 | outputPng?.let { outputPng -> 292 | val png = renderPng(colors) 293 | outputFs.write(outputPng) { 294 | write(png) 295 | } 296 | } 297 | 298 | outputTxt?.let { outputTxt -> 299 | val txt = renderTxt(colors) 300 | outputFs.write(outputTxt) { 301 | writeUtf8(txt) 302 | } 303 | } 304 | } 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | --------------------------------------------------------------------------------