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
|
||||||
!src/main/resources/static/index.html
|
!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.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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,7 +13,7 @@ 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)
|
||||||
@@ -21,6 +22,7 @@ class FileUploaderService(private val s3Client: MinioClient, private val s3Props
|
|||||||
.contentType(contentType)
|
.contentType(contentType)
|
||||||
.build()
|
.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
|
@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 " }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
spring.application.name=memevizor
|
||||||
|
logging.structured.format.console=ecs
|
||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user