mirror of
https://github.com/pischule/memevizor.git
synced 2025-12-19 06:56:42 +00:00
feat: Switch video transcoding to AV1 and improve logging
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -44,3 +44,6 @@ src/main/resources/application-local.properties
|
||||
|
||||
src/main/resources/static
|
||||
!src/main/resources/static/index.html
|
||||
|
||||
# mac
|
||||
.DS_Store
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,7 +13,7 @@ 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" }
|
||||
withLoggingContext("filename" to filename, "bucket" to s3Props.bucket) {
|
||||
s3Client.putObject(
|
||||
PutObjectArgs.builder()
|
||||
.bucket(s3Props.bucket)
|
||||
@@ -21,6 +22,7 @@ class FileUploaderService(private val s3Client: MinioClient, private val s3Props
|
||||
.contentType(contentType)
|
||||
.build()
|
||||
)
|
||||
logger.info { "File $filename has been uploaded to S3" }
|
||||
logger.info { "Uploaded a file to S3" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 " }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
@@ -1 +1,2 @@
|
||||
spring.application.name=memevizor
|
||||
logging.structured.format.console=ecs
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user