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
!src/main/resources/static/index.html !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.bot import com.github.kotlintelegrambot.bot
import com.github.kotlintelegrambot.dispatch 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.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.ChatId
import com.github.kotlintelegrambot.entities.Message import com.github.kotlintelegrambot.entities.Message
import com.github.kotlintelegrambot.entities.ParseMode import com.github.kotlintelegrambot.entities.ParseMode
import com.pischule.memevizor.bot.handler.MediaHandlerService import com.pischule.memevizor.bot.handler.MediaHandlerService
import com.pischule.memevizor.bot.handler.ThisCommandHandlerService import com.pischule.memevizor.bot.handler.ThisCommandHandlerService
import com.pischule.memevizor.util.getMaxResPhotoId import com.pischule.memevizor.util.getMedia
import com.pischule.memevizor.util.getVideoFileId
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.oshai.kotlinlogging.withLoggingContext import io.github.oshai.kotlinlogging.withLoggingContext
import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.context.properties.EnableConfigurationProperties
@@ -40,19 +36,12 @@ class BotConfiguration(
} }
private fun Bot.Builder.setupDispatchers() = dispatch { private fun Bot.Builder.setupDispatchers() = dispatch {
message { message { withLogAndErrorHandling(message) { thisCommandHandlerService.create(this) } }
withLoggingContext(messageContext(message)) {
try {
thisCommandHandlerService.create(this)
} catch (e: Error) {
logger.error(e) { "Error while handling message" }
}
}
}
photos { handleMedia() } photos { handleMedia() }
video { handleMedia() } video { handleMedia() }
videoNote { handleMedia() }
command("whoami") { command("whoami") {
withLoggingContext(messageContext(message)) { withLogAndErrorHandling(message) {
bot.sendMessage( bot.sendMessage(
chatId = ChatId.fromId(message.chat.id), chatId = ChatId.fromId(message.chat.id),
text = "chatId: `${message.chat.id}`\nuserId: `${message.from?.id}`", text = "chatId: `${message.chat.id}`\nuserId: `${message.from?.id}`",
@@ -64,21 +53,22 @@ class BotConfiguration(
} }
private suspend fun MediaHandlerEnvironment<*>.handleMedia() { private suspend fun MediaHandlerEnvironment<*>.handleMedia() {
withLoggingContext(messageContext(message)) { withLogAndErrorHandling(message) { mediaHandlerService.create(this) }
try {
mediaHandlerService.create(this)
} catch (e: Error) {
logger.error(e) { "Error while handling photo" }
}
}
} }
private fun messageContext(message: Message): Map<String, String?> = private suspend fun withLogAndErrorHandling(message: Message, block: suspend () -> Unit) {
mapOf( withLoggingContext(
"message_id" to message.messageId.toString(), "message_id" to message.messageId.toString(),
"chat_id" to message.chat.id.toString(), "chat_id" to message.chat.id.toString(),
"from_user_id" to message.from?.id.toString(), "from_user_id" to message.from?.id.toString(),
"from_user_username" to message.from?.username.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.github.kotlintelegrambot.entities.reaction.ReactionType
import com.pischule.memevizor.bot.BotProps import com.pischule.memevizor.bot.BotProps
import com.pischule.memevizor.upload.FileUploaderService import com.pischule.memevizor.upload.FileUploaderService
import com.pischule.memevizor.util.getMaxResPhotoId import com.pischule.memevizor.util.*
import com.pischule.memevizor.util.getVideoFileId import com.pischule.memevizor.video.VideoTranscoderService
import com.pischule.memevizor.video.VideoEncoderService
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.oshai.kotlinlogging.withLoggingContext import io.github.oshai.kotlinlogging.withLoggingContext
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
@@ -19,39 +18,30 @@ private val logger = KotlinLogging.logger {}
class ThisCommandHandlerService( class ThisCommandHandlerService(
private val botProps: BotProps, private val botProps: BotProps,
private val fileUploaderService: FileUploaderService, private val fileUploaderService: FileUploaderService,
private val videoEncoderService: VideoEncoderService, private val videoTranscoderService: VideoTranscoderService,
) { ) {
private val confirmCommands = listOf("this", "!soxok") private val confirmCommands = listOf("this", "!soxok")
private val mediaFileName = "media"
suspend fun create(env: MessageHandlerEnvironment) { suspend fun create(env: MessageHandlerEnvironment) {
if (!shouldHandleMessage(env)) return if (!shouldHandleMessage(env)) return
val replyToMessage = env.message.replyToMessage ?: return val replyToMessage = env.message.replyToMessage ?: return
val media = replyToMessage.getMedia() ?: return
val imageFileId = replyToMessage.getMaxResPhotoId()
val videoFileId = replyToMessage.getVideoFileId()
val fileId = imageFileId ?: videoFileId ?: return
env.bot.sendChatAction(ChatId.fromId(env.message.chat.id), ChatAction.TYPING) env.bot.sendChatAction(ChatId.fromId(env.message.chat.id), ChatAction.TYPING)
withLoggingContext("file_id" to fileId) { withLoggingContext("file_id" to media.fileId) {
val fileBytes = env.bot.downloadFileBytes(fileId) ?: return val fileBytes = env.bot.downloadFileBytes(media.fileId) ?: return
logger.info { "Downloaded a file from Telegram, size=${fileBytes.size}" } logger.info { "Downloaded a file from Telegram, size=${fileBytes.size}" }
val convertedBytes = val (convertedBytes, contentType) =
if (videoFileId != null) { when (media.type) {
videoEncoderService.encodeToWebm(fileBytes) MessageMedia.Type.PHOTO -> Pair(fileBytes, "image/jpeg")
} else { MessageMedia.Type.VIDEO ->
fileBytes Pair(videoTranscoderService.transcode(fileBytes), "video/webm")
} }
val contentType = fileUploaderService.uploadFile(convertedBytes, mediaFileName, contentType)
if (videoFileId != null) {
"video/webm"
} else {
"image/jpeg"
}
fileUploaderService.uploadFile(convertedBytes, "_", contentType)
reactToMessage(env, "👍") reactToMessage(env, "👍")
} }
@@ -61,11 +51,7 @@ class ThisCommandHandlerService(
val isApprover = env.message.from?.id?.let { botProps.approverUserIds.contains(it) } == true val isApprover = env.message.from?.id?.let { botProps.approverUserIds.contains(it) } == true
val command = env.message.text?.lowercase() val command = env.message.text?.lowercase()
val isConfirmCommand = command in confirmCommands val isConfirmCommand = command in confirmCommands
val hasMediaReply = return isApprover && isConfirmCommand
env.message.replyToMessage?.let {
it.photo?.isNotEmpty() == true || it.video != null
} == true
return isApprover && isConfirmCommand && hasMediaReply
} }
private suspend fun reactToMessage(env: MessageHandlerEnvironment, emoji: String) { private suspend fun reactToMessage(env: MessageHandlerEnvironment, emoji: String) {

View File

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

View File

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

View File

@@ -2,6 +2,21 @@ package com.pischule.memevizor.util
import com.github.kotlintelegrambot.entities.Message 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> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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> <style>
/* Basic styling to make the media fill the container */ /* Basic styling to make the media fill the container */
html, body { html, body {
@@ -31,7 +33,7 @@
<main id="media-container"></main> <main id="media-container"></main>
<script defer> <script defer>
const fileUrl = '_'; const fileUrl = 'media';
const refreshIntervalMs = 15_000; // 15 seconds const refreshIntervalMs = 15_000; // 15 seconds
const mediaContainer = document.getElementById("media-container"); const mediaContainer = document.getElementById("media-container");