feat: Switch video transcoding to AV1 and improve logging

This commit is contained in:
2025-07-26 13:37:31 +03:00
parent 7f6ae2872d
commit f850d079f8
11 changed files with 159 additions and 134 deletions

3
.gitignore vendored
View File

@@ -44,3 +44,6 @@ src/main/resources/application-local.properties
src/main/resources/static
!src/main/resources/static/index.html
# mac
.DS_Store

View File

@@ -3,18 +3,14 @@ package com.pischule.memevizor.bot
import com.github.kotlintelegrambot.Bot
import com.github.kotlintelegrambot.bot
import com.github.kotlintelegrambot.dispatch
import com.github.kotlintelegrambot.dispatcher.command
import com.github.kotlintelegrambot.dispatcher.*
import com.github.kotlintelegrambot.dispatcher.handlers.media.MediaHandlerEnvironment
import com.github.kotlintelegrambot.dispatcher.message
import com.github.kotlintelegrambot.dispatcher.photos
import com.github.kotlintelegrambot.dispatcher.video
import com.github.kotlintelegrambot.entities.ChatId
import com.github.kotlintelegrambot.entities.Message
import com.github.kotlintelegrambot.entities.ParseMode
import com.pischule.memevizor.bot.handler.MediaHandlerService
import com.pischule.memevizor.bot.handler.ThisCommandHandlerService
import com.pischule.memevizor.util.getMaxResPhotoId
import com.pischule.memevizor.util.getVideoFileId
import com.pischule.memevizor.util.getMedia
import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.oshai.kotlinlogging.withLoggingContext
import org.springframework.boot.context.properties.EnableConfigurationProperties
@@ -40,19 +36,12 @@ class BotConfiguration(
}
private fun Bot.Builder.setupDispatchers() = dispatch {
message {
withLoggingContext(messageContext(message)) {
try {
thisCommandHandlerService.create(this)
} catch (e: Error) {
logger.error(e) { "Error while handling message" }
}
}
}
message { withLogAndErrorHandling(message) { thisCommandHandlerService.create(this) } }
photos { handleMedia() }
video { handleMedia() }
videoNote { handleMedia() }
command("whoami") {
withLoggingContext(messageContext(message)) {
withLogAndErrorHandling(message) {
bot.sendMessage(
chatId = ChatId.fromId(message.chat.id),
text = "chatId: `${message.chat.id}`\nuserId: `${message.from?.id}`",
@@ -64,21 +53,22 @@ class BotConfiguration(
}
private suspend fun MediaHandlerEnvironment<*>.handleMedia() {
withLoggingContext(messageContext(message)) {
try {
mediaHandlerService.create(this)
} catch (e: Error) {
logger.error(e) { "Error while handling photo" }
}
}
withLogAndErrorHandling(message) { mediaHandlerService.create(this) }
}
private fun messageContext(message: Message): Map<String, String?> =
mapOf(
private suspend fun withLogAndErrorHandling(message: Message, block: suspend () -> Unit) {
withLoggingContext(
"message_id" to message.messageId.toString(),
"chat_id" to message.chat.id.toString(),
"from_user_id" to message.from?.id.toString(),
"from_user_username" to message.from?.username.toString(),
"file_id" to (message.getMaxResPhotoId() ?: message.getVideoFileId()),
)
"file_id" to (message.getMedia()?.fileId),
) {
try {
block.invoke()
} catch (e: Exception) {
logger.error(e) { "Error while handling message" }
}
}
}
}

View File

@@ -6,9 +6,8 @@ import com.github.kotlintelegrambot.entities.ChatId
import com.github.kotlintelegrambot.entities.reaction.ReactionType
import com.pischule.memevizor.bot.BotProps
import com.pischule.memevizor.upload.FileUploaderService
import com.pischule.memevizor.util.getMaxResPhotoId
import com.pischule.memevizor.util.getVideoFileId
import com.pischule.memevizor.video.VideoEncoderService
import com.pischule.memevizor.util.*
import com.pischule.memevizor.video.VideoTranscoderService
import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.oshai.kotlinlogging.withLoggingContext
import org.springframework.stereotype.Component
@@ -19,39 +18,30 @@ private val logger = KotlinLogging.logger {}
class ThisCommandHandlerService(
private val botProps: BotProps,
private val fileUploaderService: FileUploaderService,
private val videoEncoderService: VideoEncoderService,
private val videoTranscoderService: VideoTranscoderService,
) {
private val confirmCommands = listOf("this", "!soxok")
private val mediaFileName = "media"
suspend fun create(env: MessageHandlerEnvironment) {
if (!shouldHandleMessage(env)) return
val replyToMessage = env.message.replyToMessage ?: return
val imageFileId = replyToMessage.getMaxResPhotoId()
val videoFileId = replyToMessage.getVideoFileId()
val fileId = imageFileId ?: videoFileId ?: return
val media = replyToMessage.getMedia() ?: return
env.bot.sendChatAction(ChatId.fromId(env.message.chat.id), ChatAction.TYPING)
withLoggingContext("file_id" to fileId) {
val fileBytes = env.bot.downloadFileBytes(fileId) ?: return
withLoggingContext("file_id" to media.fileId) {
val fileBytes = env.bot.downloadFileBytes(media.fileId) ?: return
logger.info { "Downloaded a file from Telegram, size=${fileBytes.size}" }
val convertedBytes =
if (videoFileId != null) {
videoEncoderService.encodeToWebm(fileBytes)
} else {
fileBytes
val (convertedBytes, contentType) =
when (media.type) {
MessageMedia.Type.PHOTO -> Pair(fileBytes, "image/jpeg")
MessageMedia.Type.VIDEO ->
Pair(videoTranscoderService.transcode(fileBytes), "video/webm")
}
val contentType =
if (videoFileId != null) {
"video/webm"
} else {
"image/jpeg"
}
fileUploaderService.uploadFile(convertedBytes, "_", contentType)
fileUploaderService.uploadFile(convertedBytes, mediaFileName, contentType)
reactToMessage(env, "👍")
}
@@ -61,11 +51,7 @@ class ThisCommandHandlerService(
val isApprover = env.message.from?.id?.let { botProps.approverUserIds.contains(it) } == true
val command = env.message.text?.lowercase()
val isConfirmCommand = command in confirmCommands
val hasMediaReply =
env.message.replyToMessage?.let {
it.photo?.isNotEmpty() == true || it.video != null
} == true
return isApprover && isConfirmCommand && hasMediaReply
return isApprover && isConfirmCommand
}
private suspend fun reactToMessage(env: MessageHandlerEnvironment, emoji: String) {

View File

@@ -1,6 +1,7 @@
package com.pischule.memevizor.upload
import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.oshai.kotlinlogging.withLoggingContext
import io.minio.MinioClient
import io.minio.PutObjectArgs
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean
@@ -12,15 +13,16 @@ private val logger = KotlinLogging.logger {}
@Service
class FileUploaderService(private val s3Client: MinioClient, private val s3Props: S3Props) {
fun uploadFile(fileBytes: ByteArray, filename: String, contentType: String) {
logger.info { "Before upload" }
s3Client.putObject(
PutObjectArgs.builder()
.bucket(s3Props.bucket)
.`object`(filename)
.stream(fileBytes.inputStream(), fileBytes.size.toLong(), -1)
.contentType(contentType)
.build()
)
logger.info { "File $filename has been uploaded to S3" }
withLoggingContext("filename" to filename, "bucket" to s3Props.bucket) {
s3Client.putObject(
PutObjectArgs.builder()
.bucket(s3Props.bucket)
.`object`(filename)
.stream(fileBytes.inputStream(), fileBytes.size.toLong(), -1)
.contentType(contentType)
.build()
)
logger.info { "Uploaded a file to S3" }
}
}
}

View File

@@ -15,7 +15,7 @@ class IndexInitializer(val fileUploaderService: FileUploaderService) {
@EventListener
fun applicationStartedHandler(event: ApplicationStartedEvent) {
try {
val fileBytes = readResourceAsByteArray("/static/index.html")
val fileBytes = readResourceAsByteArray("static/index.html")
fileUploaderService.uploadFile(fileBytes, "index.html", "text/html")
} catch (e: Exception) {
logger.warn(e) { "Failed to upload " }

View File

@@ -2,6 +2,21 @@ package com.pischule.memevizor.util
import com.github.kotlintelegrambot.entities.Message
fun Message.getMaxResPhotoId(): String? = photo?.lastOrNull()?.fileId
fun Message.getMedia(): MessageMedia? {
photo?.lastOrNull()?.fileId?.let { fileId ->
return MessageMedia(fileId, MessageMedia.Type.PHOTO)
}
fun Message.getVideoFileId(): String? = video?.fileId ?: videoNote?.fileId
(video?.fileId ?: videoNote?.fileId)?.let { fileId ->
return MessageMedia(fileId, MessageMedia.Type.VIDEO)
}
return null
}
data class MessageMedia(val fileId: String, val type: Type) {
enum class Type {
PHOTO,
VIDEO,
}
}

View File

@@ -1,63 +0,0 @@
package com.pischule.memevizor.video
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlin.io.path.createTempFile
import kotlin.io.path.deleteIfExists
import kotlin.io.path.readBytes
import kotlin.io.path.writeBytes
import org.springframework.stereotype.Service
private val logger = KotlinLogging.logger {}
@Service
class VideoEncoderService {
fun encodeToWebm(inputVideo: ByteArray): ByteArray {
logger.info { "Started encoding a file" }
val inputFile = createTempFile()
val outputFile = createTempFile()
val command: List<String> =
listOf(
"ffmpeg",
"-i",
"$inputFile",
// video
"-map",
"0:v",
"-c:v",
"libvpx-vp9",
"-cpu-used",
"6",
"-crf",
"40",
"-b:v",
"0k",
// file
"-f",
"webm",
"-y",
"$outputFile",
)
try {
inputFile.writeBytes(inputVideo)
val process = ProcessBuilder(command).redirectErrorStream(true).start()
val output = process.inputStream.bufferedReader().use { it.readText() }
val exitCode = process.waitFor()
if (exitCode != 0) {
error("Video conversion failed: exit code $exitCode, $output")
}
logger.info { "Converted" }
return outputFile.readBytes()
} finally {
inputFile.deleteIfExists()
outputFile.deleteIfExists()
}
}
}

View File

@@ -0,0 +1,85 @@
package com.pischule.memevizor.video
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlin.io.path.createTempFile
import kotlin.io.path.deleteIfExists
import kotlin.io.path.readBytes
import kotlin.io.path.writeBytes
import kotlin.time.measureTime
import org.springframework.stereotype.Service
private val logger = KotlinLogging.logger {}
@Service
class VideoTranscoderService {
fun transcode(inputVideo: ByteArray): ByteArray {
logger.info { "Started transcoding a file" }
val inputFile = createTempFile()
val outputFile = createTempFile()
val command: List<String> =
listOf(
"ffmpeg",
"-nostdin",
"-nostats",
"-hide_banner",
// input
"-i",
"$inputFile",
// video
"-map",
"0:v",
"-c:v",
"libsvtav1",
"-preset",
"6",
"-crf",
"35",
"-svtav1-params",
"film-grain=10",
// audio
"-map",
"0:a?",
"-c:a",
"libopus",
"-b:a",
"96k",
"-vbr",
"on",
"-compression_level",
"10",
// output
"-f",
"webm",
"-y",
"$outputFile",
)
try {
inputFile.writeBytes(inputVideo)
val process = ProcessBuilder(command).redirectErrorStream(true).start()
val exitCode: Int
val timeTaken = measureTime { exitCode = process.waitFor() }
val processOutput = process.inputStream.bufferedReader().use { it.readText() }
if (exitCode != 0) {
throw VideoTranscodingException(exitCode, processOutput)
}
logger.atInfo {
message = "Finished transcoding a file"
payload =
mapOf(
"processOutput" to processOutput,
"timeTakenMs" to timeTaken.inWholeMilliseconds,
)
}
return outputFile.readBytes()
} finally {
inputFile.deleteIfExists()
outputFile.deleteIfExists()
}
}
}

View File

@@ -0,0 +1,4 @@
package com.pischule.memevizor.video
data class VideoTranscodingException(val ffmpegExitCode: Int, val ffmpegOutput: String) :
RuntimeException("Video transcoding failed. Code=$ffmpegOutput. Output=$ffmpegOutput")

View File

@@ -1 +1,2 @@
spring.application.name=memevizor
spring.application.name=memevizor
logging.structured.format.console=ecs

View File

@@ -3,7 +3,9 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dynamic Media Refresh</title>
<link rel="icon"
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📺</text></svg>">
<title>memevizor</title>
<style>
/* Basic styling to make the media fill the container */
html, body {
@@ -31,7 +33,7 @@
<main id="media-container"></main>
<script defer>
const fileUrl = '_';
const fileUrl = 'media';
const refreshIntervalMs = 15_000; // 15 seconds
const mediaContainer = document.getElementById("media-container");