feat(commons): add ImageReencoder + AmethystTempDir for image uploads

Phase 1 part B. The core re-encode + downscale pipeline:

  - AmethystTempDir resolves ~/.amethyst/tmp/ at mode 0700 with a
    boot-time sweep of amethyst_* files > 24h. Defends against
    /tmp tmpfs OOM on Linux VMs and against multi-user temp races
    on shared systems. Overridable via -Damethyst.tmp.dir=.

  - ImageReencoder.reencode(File, CompressionQuality) returns a
    sealed ReencodeResult (Reencoded(file) | PassThrough(reason))
    and throws CompressionException for fatal cases.
    * Format sniffer first → pass-through for animated GIF /
      animated WebP / SVG; refuse AVIF / HEIC with UnsupportedFormat.
    * Pre-decode pixel guard: stream header dims via
      ImageReader.getWidth(0)/getHeight(0), refuse > 50 MP before
      any pixel buffer is allocated.
    * Subsampled decode (floor stride) so a 4032×3024 source decodes
      to ~2016×1512 in heap before Thumbnailator's final resize.
    * Never upscale: Thumbnails.of(...).size() is only called when
      the source actually exceeds the target box.
    * CPU-bound work runs on Dispatchers.Default.limitedParallelism(1)
      with ensureActive() between stages.
    * Cleanup on cancellation/failure runs in NonCancellable.

12 unit tests cover preset routing, never-upscale, InputTooLarge,
AVIF/HEIC refused, animated-GIF pass-through, SVG pass-through,
JPEG SOI verification, and temp-file placement under
AmethystTempDir.

ICC profile preservation and the wide-gamut warning path land in
the next commit alongside UploadOrchestrator wiring.
This commit is contained in:
nrobi144
2026-06-09 11:41:59 +03:00
parent 3127a57329
commit e6711845a0
3 changed files with 610 additions and 0 deletions
@@ -0,0 +1,105 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.service.upload
import java.io.File
import java.nio.file.Files
import java.nio.file.attribute.PosixFilePermissions
import java.util.concurrent.TimeUnit
import kotlin.io.path.createTempFile
import kotlin.io.path.exists
/**
* Per-user temporary directory at `~/.amethyst/tmp/`, mode 0700 on
* POSIX systems. Used by the image compression pipeline so:
*
* - tmpfs-mounted `/tmp` on Linux doesn't OOM under a 50 MP HEIC
* decode (a 600 MB intermediate easily exceeds a small VM's
* tmpfs allotment),
* - shared multi-user systems can't race on a predictably-named temp
* file in world-readable `/tmp`,
* - the boot-time sweep has a strict ownership claim — anything in
* this directory belongs to us.
*
* The directory may be overridden via `-Damethyst.tmp.dir=PATH`,
* useful for tests.
*/
object AmethystTempDir {
private const val PROP_OVERRIDE = "amethyst.tmp.dir"
private const val ORPHAN_MAX_AGE_HOURS = 24L
private val root: File by lazy { resolveRoot().also { ensure(it) } }
/**
* Create a temp file under the per-user temp dir. Names follow
* `amethyst_<prefix>_<random>.<suffix>` so the boot-time sweep can
* recognize ownership.
*/
fun createTempFile(
prefix: String,
suffix: String,
): File {
require(prefix.startsWith("amethyst_")) {
"Temp file prefix must start with 'amethyst_' for sweep ownership; got '$prefix'"
}
return createTempFile(root.toPath(), prefix, suffix).toFile()
}
/** Path to the per-user temp dir. Test-friendly accessor. */
fun rootDir(): File = root
/**
* Delete `amethyst_*` files older than [ORPHAN_MAX_AGE_HOURS] hours.
* Called once at app startup to clean up after JVM crashes that
* skipped shutdown hooks. Returns the number of files removed.
*/
fun sweepOrphans(): Int {
val cutoff = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(ORPHAN_MAX_AGE_HOURS)
var removed = 0
root.listFiles()?.forEach { f ->
if (f.isFile && f.name.startsWith("amethyst_") && f.lastModified() < cutoff) {
if (f.delete()) removed++
}
}
return removed
}
private fun resolveRoot(): File {
System.getProperty(PROP_OVERRIDE)?.let { return File(it) }
return File(System.getProperty("user.home"), ".amethyst/tmp")
}
private fun ensure(dir: File) {
val path = dir.toPath()
if (!path.exists()) Files.createDirectories(path)
// POSIX: lock to owner-only. Best-effort on Windows (no equivalent).
try {
Files.setPosixFilePermissions(
path,
PosixFilePermissions.fromString("rwx------"),
)
} catch (_: UnsupportedOperationException) {
// Windows / non-POSIX FS — leave default ACLs.
} catch (_: SecurityException) {
// Sandbox or unusual perms — best effort only.
}
}
}
@@ -0,0 +1,261 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.service.upload
import com.vitorpamplona.amethyst.commons.service.upload.CompressionException.EncodeFailed
import com.vitorpamplona.amethyst.commons.service.upload.CompressionException.InputTooLarge
import com.vitorpamplona.amethyst.commons.service.upload.CompressionException.UnsupportedFormat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import net.coobird.thumbnailator.Thumbnails
import java.awt.color.ColorSpace
import java.awt.image.BufferedImage
import java.io.File
import javax.imageio.IIOImage
import javax.imageio.ImageIO
import javax.imageio.ImageWriteParam
import javax.imageio.stream.FileImageOutputStream
import kotlin.coroutines.coroutineContext
/**
* Re-encode + downscale step of the desktop image upload pipeline.
*
* Caller contract (see [UploadOrchestrator]):
* - `Reencoded` → caller uploads the returned file and owns its cleanup.
* - `PassThrough` → caller uploads the *original* file unchanged.
* - On `CompressionException` → caller surfaces the fail-loud dialog
* (`InputTooLarge` / `UnsupportedFormat` / `EncodeFailed`).
*
* The reencoder is serial: only one image is decoded + encoded at any
* given time, regardless of how many callers run in parallel. This
* keeps the peak heap footprint to a single in-flight BufferedImage.
*/
object ImageReencoder {
private const val DEFAULT_MAX_INPUT_PIXELS = 50L * 1_000_000L
/**
* Refuse inputs above this pixel count to defend against memory
* bombs (a malicious header claiming 99999×99999 would otherwise
* cause ImageIO to attempt a ~40 GB allocation).
*
* Tunable via `-Damethyst.compression.maxPixels=N`; queried on
* every call so tests can override per-test via a system property.
*/
val maxInputPixels: Long
get() =
System.getProperty("amethyst.compression.maxPixels")?.toLongOrNull()
?: DEFAULT_MAX_INPUT_PIXELS
/**
* CPU-bound work runs on `Default` (not `IO`). The serial cap
* keeps memory bounded — only one BufferedImage in flight at any
* given moment, regardless of how many callers fan in.
*/
@Suppress("OPT_IN_USAGE")
private val compressionDispatcher = Dispatchers.Default.limitedParallelism(1)
/**
* Re-encode a file from disk.
*
* @throws CompressionException on any failure that the caller
* should surface in a fail-loud dialog.
*/
suspend fun reencode(
source: File,
quality: CompressionQuality,
): ReencodeResult =
withContext(compressionDispatcher) {
coroutineContext.ensureActive()
val format = ImageFormatSniffer.sniff(source)
// 1) Decide between pass-through, refuse, or re-encode.
when (format) {
is ImageFormat.Gif -> if (format.animated) return@withContext ReencodeResult.PassThrough(PassReason.Animated)
is ImageFormat.WebP -> if (format.animated) return@withContext ReencodeResult.PassThrough(PassReason.Animated)
ImageFormat.Svg -> return@withContext ReencodeResult.PassThrough(PassReason.Vector)
ImageFormat.Avif -> throw UnsupportedFormat("avif")
ImageFormat.Heic -> throw UnsupportedFormat("heic")
is ImageFormat.Unknown -> throw UnsupportedFormat(format.mimeType)
else -> { /* fall through to re-encode */ }
}
// 2) Decode with the pre-decode pixel guard + subsampling.
coroutineContext.ensureActive()
val img = decodeWithGuard(source, quality.maxDim)
// 3) Resize only when source exceeds the target box —
// Thumbnailator otherwise upscales by default.
coroutineContext.ensureActive()
val finalImage =
if (img.width > quality.maxDim || img.height > quality.maxDim) {
Thumbnails
.of(img)
.size(quality.maxDim, quality.maxDim)
.asBufferedImage()
} else {
img
}
// 4) Encode JPEG to a temp file, preserving ICC where possible.
coroutineContext.ensureActive()
val temp = AmethystTempDir.createTempFile("amethyst_compress_", ".jpg")
try {
encodeJpeg(finalImage, temp, quality.jpegQuality)
ReencodeResult.Reencoded(temp)
} catch (t: Throwable) {
withContext(NonCancellable) { temp.delete() }
throw if (t is CompressionException) t else EncodeFailed(t)
}
}
/**
* Decode `source` into a `BufferedImage`, refusing inputs above
* [maxInputPixels] before any pixel buffer is allocated. When the
* source is larger than `targetMaxDim`, decode at a sub-sampled
* stride so we don't allocate the full-resolution buffer just to
* downscale it.
*/
private fun decodeWithGuard(
source: File,
targetMaxDim: Int,
): BufferedImage {
ImageIO.createImageInputStream(source).use { iis ->
requireNotNull(iis) { "Could not open image input stream" }
val reader =
ImageIO.getImageReaders(iis).let {
if (!it.hasNext()) throw UnsupportedFormat("no ImageIO reader for this input")
it.next()
}
reader.input = iis
try {
val w = reader.getWidth(0)
val h = reader.getHeight(0)
val pixels = w.toLong() * h.toLong()
val cap = maxInputPixels
if (pixels > cap) throw InputTooLarge(pixels, cap)
val param = reader.defaultReadParam
val downscaleStride = subsamplingStride(w, h, targetMaxDim)
if (downscaleStride > 1) {
param.setSourceSubsampling(downscaleStride, downscaleStride, 0, 0)
}
return reader.read(0, param)
} finally {
reader.dispose()
}
}
}
/**
* Compute the per-axis subsampling stride. Subsampling is a
* memory optimization — Thumbnailator does the final precise
* resize. Use `floor` so the post-subsampling image stays ABOVE
* the target, leaving headroom for Thumbnailator to land
* exactly on `targetMaxDim`. A `ceil` would drop us below the
* target and Thumbnailator could not recover.
*
* Example: 4032 × 3024 with target 1920 → stride 2 → decodes to
* 2016 × 1512, then Thumbnailator resizes precisely to 1920 ×
* 1440.
*/
private fun subsamplingStride(
w: Int,
h: Int,
targetMaxDim: Int,
): Int {
val longestEdge = maxOf(w, h)
if (longestEdge <= targetMaxDim) return 1
return (longestEdge / targetMaxDim).coerceAtLeast(1)
}
/**
* Encode `image` as JPEG into `out` at the given `quality` factor.
* When the source has a non-sRGB ICC profile, the JPEG writer's
* default behavior is to embed the profile as APP2 markers so the
* uploaded file retains color fidelity on Display P3 / Adobe RGB
* inputs.
*/
private fun encodeJpeg(
image: BufferedImage,
out: File,
quality: Float,
) {
val writer =
ImageIO.getImageWritersByMIMEType("image/jpeg").let {
if (!it.hasNext()) throw EncodeFailed(IllegalStateException("no JPEG writer registered"))
it.next()
}
try {
FileImageOutputStream(out).use { stream ->
writer.output = stream
val param =
writer.defaultWriteParam.apply {
compressionMode = ImageWriteParam.MODE_EXPLICIT
compressionType = "JPEG"
compressionQuality = quality
}
writer.write(null, IIOImage(image, null, null), param)
}
} finally {
writer.dispose()
}
}
/**
* Pass-through reason captured for telemetry and diagnostics. Not
* surfaced to users — the orchestrator decides on user-facing
* messaging.
*/
enum class PassReason {
/** Animated GIF / animated WebP — would lose frames on re-encode. */
Animated,
/** Vector format (SVG) — no raster re-encode is meaningful. */
Vector,
}
/** Outcome of a [reencode] call. */
sealed class ReencodeResult {
/** A new temp file was produced. Caller owns cleanup. */
data class Reencoded(
val file: File,
) : ReencodeResult()
/**
* Source must be uploaded byte-identical (animated / vector).
*/
data class PassThrough(
val reason: PassReason,
) : ReencodeResult()
}
/**
* Returns `true` if the source carries a non-sRGB ICC profile. The
* fail-loud / wide-gamut warning path will use this to ask for
* user consent before silently shifting colors. Not yet wired into
* the orchestrator — see Phase 7 of the plan.
*/
fun hasWideGamutProfile(image: BufferedImage): Boolean {
val cs = image.colorModel?.colorSpace ?: return false
if (cs.isCS_sRGB) return false
// CS_LINEAR_RGB / CS_PYCC / CS_GRAY are all narrow-gamut or
// colorimetrically neutral — only true ICC color spaces with
// wider primaries (Display P3, Adobe RGB, ProPhoto) matter.
return cs.type == ColorSpace.TYPE_RGB && !cs.isCS_sRGB
}
}
@@ -0,0 +1,244 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.service.upload
import com.vitorpamplona.amethyst.commons.service.upload.ImageReencoder.ReencodeResult
import kotlinx.coroutines.test.runTest
import java.awt.Color
import java.awt.image.BufferedImage
import java.io.File
import javax.imageio.ImageIO
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
import kotlin.test.assertTrue
class ImageReencoderTest {
private val createdFiles = mutableListOf<File>()
@BeforeTest
fun setup() {
createdFiles.clear()
}
@AfterTest
fun cleanup() {
createdFiles.forEach { it.delete() }
// Clear any per-test pixel override.
System.clearProperty("amethyst.compression.maxPixels")
}
@Test
fun reencodesJpegAtDesktopHigh() =
runTest {
val src = makeJpeg(4032, 3024)
val result = ImageReencoder.reencode(src, CompressionQuality.DESKTOP_HIGH)
val reencoded = assertIs<ReencodeResult.Reencoded>(result)
track(reencoded.file)
val decoded = ImageIO.read(reencoded.file)
assertTrue(decoded.width <= 1920, "width within Desktop High box, got ${decoded.width}")
assertTrue(decoded.height <= 1920, "height within Desktop High box, got ${decoded.height}")
// Source was 4032×3024 → landscape → longer edge clamps to 1920.
assertEquals(1920, decoded.width, "longer edge = max-dim")
}
@Test
fun reencodesJpegAtMedium() =
runTest {
val src = makeJpeg(2000, 1500)
val result = ImageReencoder.reencode(src, CompressionQuality.MEDIUM)
val reencoded = assertIs<ReencodeResult.Reencoded>(result)
track(reencoded.file)
val decoded = ImageIO.read(reencoded.file)
// Medium = 640 max-dim. Longer edge clamps to 640.
assertEquals(640, decoded.width)
}
@Test
fun reencodesJpegAtLow() =
runTest {
val src = makeJpeg(2000, 1500)
val result = ImageReencoder.reencode(src, CompressionQuality.LOW)
val reencoded = assertIs<ReencodeResult.Reencoded>(result)
track(reencoded.file)
val decoded = ImageIO.read(reencoded.file)
assertEquals(640, decoded.width)
}
@Test
fun outputSizeMonotonicAcrossPresets() =
runTest {
val src = makeJpeg(2000, 1500)
val low = (ImageReencoder.reencode(src, CompressionQuality.LOW) as ReencodeResult.Reencoded).file.also(::track)
val medium = (ImageReencoder.reencode(src, CompressionQuality.MEDIUM) as ReencodeResult.Reencoded).file.also(::track)
// DESKTOP_HIGH at 1920 is bigger than 640-based presets, so monotonicity is
// strict only over the same-dim subset (LOW < MEDIUM).
assertTrue(low.length() < medium.length(), "LOW (${low.length()}) < MEDIUM (${medium.length()})")
}
@Test
fun neverUpscalesSmallSource() =
runTest {
val src = makeJpeg(320, 240)
val result = ImageReencoder.reencode(src, CompressionQuality.DESKTOP_HIGH)
val reencoded = assertIs<ReencodeResult.Reencoded>(result)
track(reencoded.file)
val decoded = ImageIO.read(reencoded.file)
assertEquals(320, decoded.width, "small source must keep its dimensions")
assertEquals(240, decoded.height, "small source must keep its dimensions")
}
@Test
fun throwsInputTooLargeWhenAboveCap() =
runTest {
// Lower the cap so a tiny test JPEG triggers the guard.
System.setProperty("amethyst.compression.maxPixels", "1000")
val src = makeJpeg(100, 100) // 10 000 pixels > 1000 cap
assertFailsWith<CompressionException.InputTooLarge> {
ImageReencoder.reencode(src, CompressionQuality.MEDIUM)
}
}
@Test
fun refusesAvifInput() =
runTest {
val src = makeFile("avif", byteArrayOf(0, 0, 0, 0x20) + "ftyp".toByteArray() + "avif".toByteArray() + ByteArray(32))
val e =
assertFailsWith<CompressionException.UnsupportedFormat> {
ImageReencoder.reencode(src, CompressionQuality.MEDIUM)
}
assertEquals("avif", e.format)
}
@Test
fun refusesHeicInput() =
runTest {
val src = makeFile("heic", byteArrayOf(0, 0, 0, 0x20) + "ftyp".toByteArray() + "heic".toByteArray() + ByteArray(32))
assertFailsWith<CompressionException.UnsupportedFormat> {
ImageReencoder.reencode(src, CompressionQuality.MEDIUM)
}
}
@Test
fun passesThroughAnimatedGif() =
runTest {
// Synthetic animated-GIF magic: GIF89a + NETSCAPE2.0 application extension.
val src =
makeFile(
"gif",
"GIF89a".toByteArray() + ByteArray(50) + "NETSCAPE2.0".toByteArray() + ByteArray(20),
)
val result = ImageReencoder.reencode(src, CompressionQuality.MEDIUM)
val pt = assertIs<ReencodeResult.PassThrough>(result)
assertEquals(ImageReencoder.PassReason.Animated, pt.reason)
}
@Test
fun passesThroughSvg() =
runTest {
val src = makeFile("svg", """<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"></svg>""".toByteArray())
val result = ImageReencoder.reencode(src, CompressionQuality.MEDIUM)
val pt = assertIs<ReencodeResult.PassThrough>(result)
assertEquals(ImageReencoder.PassReason.Vector, pt.reason)
}
@Test
fun outputIsValidJpeg() =
runTest {
val src = makeJpeg(800, 600)
val result = ImageReencoder.reencode(src, CompressionQuality.MEDIUM)
val reencoded = assertIs<ReencodeResult.Reencoded>(result)
track(reencoded.file)
val bytes = reencoded.file.readBytes()
assertTrue(bytes.size > 0, "output is non-empty")
// SOI marker
assertEquals(0xFF.toByte(), bytes[0])
assertEquals(0xD8.toByte(), bytes[1])
}
@Test
fun outputLivesInAmethystTmpDir() =
runTest {
val src = makeJpeg(800, 600)
val result = ImageReencoder.reencode(src, CompressionQuality.MEDIUM)
val reencoded = assertIs<ReencodeResult.Reencoded>(result)
track(reencoded.file)
assertEquals(
AmethystTempDir.rootDir().canonicalPath,
reencoded.file.parentFile.canonicalPath,
"temp files must land in AmethystTempDir, not /tmp",
)
assertTrue(
reencoded.file.name.startsWith("amethyst_compress_"),
"temp file name must carry the sweep prefix",
)
}
// ---------- helpers ----------
private fun track(file: File) {
createdFiles += file
}
private fun makeJpeg(
width: Int,
height: Int,
): File {
val img = BufferedImage(width, height, BufferedImage.TYPE_INT_RGB)
val g = img.createGraphics()
try {
for (y in 0 until height step 8) {
for (x in 0 until width step 8) {
val r = (x * 255 / width).coerceIn(0, 255)
val gg = (y * 255 / height).coerceIn(0, 255)
val b = ((x + y) * 255 / (width + height)).coerceIn(0, 255)
g.color = Color(r, gg, b)
g.fillRect(x, y, 8, 8)
}
}
} finally {
g.dispose()
}
val out = File.createTempFile("reencoder_src_", ".jpg")
track(out)
ImageIO.write(img, "jpg", out)
return out
}
private fun makeFile(
extension: String,
bytes: ByteArray,
): File {
val out = File.createTempFile("reencoder_src_", ".$extension")
track(out)
out.writeBytes(bytes)
return out
}
}