Implement video conversion

This commit is contained in:
2025-07-23 23:39:16 +03:00
parent 98ef7d1ef2
commit ac8770a538
9 changed files with 188 additions and 118 deletions

View File

@@ -1,13 +1,14 @@
package com.pischule.memevizor.bot.handler package com.pischule.memevizor.bot.handler
import com.github.kotlintelegrambot.dispatcher.handlers.MessageHandlerEnvironment import com.github.kotlintelegrambot.dispatcher.handlers.MessageHandlerEnvironment
import com.github.kotlintelegrambot.entities.ChatAction
import com.github.kotlintelegrambot.entities.ChatId import com.github.kotlintelegrambot.entities.ChatId
import com.github.kotlintelegrambot.entities.Message
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.getMaxResPhotoId
import com.pischule.memevizor.util.getVideoFileId import com.pischule.memevizor.util.getVideoFileId
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
@@ -18,6 +19,7 @@ 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 confirmCommands = listOf("this", "!soxok") private val confirmCommands = listOf("this", "!soxok")
@@ -26,23 +28,35 @@ class ThisCommandHandlerService(
val replyToMessage = env.message.replyToMessage ?: return val replyToMessage = env.message.replyToMessage ?: return
val (fileId, contentType) = getFileInfo(replyToMessage) ?: 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)
withLoggingContext("file_id" to fileId) { withLoggingContext("file_id" to fileId) {
val fileBytes = env.bot.downloadFileBytes(fileId) ?: return val fileBytes = env.bot.downloadFileBytes(fileId) ?: return
logger.info { "Downloaded a file from Telegram, size=${fileBytes.size}" } logger.info { "Downloaded a file from Telegram, size=${fileBytes.size}" }
fileUploaderService.uploadFile(fileBytes, "_", contentType) val convertedBytes =
if (videoFileId != null) {
videoEncoderService.encodeToWebm(fileBytes)
} else {
fileBytes
}
val contentType =
if (videoFileId != null) {
"video/webm"
} else {
"image/jpeg"
}
fileUploaderService.uploadFile(convertedBytes, "_", contentType)
reactToMessage(env, "👍") reactToMessage(env, "👍")
} }
} }
private fun getFileInfo(message: Message): Pair<String, String>? {
return message.getMaxResPhotoId()?.let { it to "image/jpeg" }
?: message.getVideoFileId()?.let { it to "video/mp4" }
}
private fun shouldHandleMessage(env: MessageHandlerEnvironment): Boolean { private fun shouldHandleMessage(env: MessageHandlerEnvironment): Boolean {
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()

View File

@@ -1,16 +0,0 @@
package com.pischule.memevizor.upload
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Service
private val logger = KotlinLogging.logger {}
@Profile("local")
@Service
class DummyFileUploadService() : FileUploaderService {
override fun uploadFile(fileBytes: ByteArray, filename: String, contentType: String) {
logger.info { "File $filename has been successfully uploaded to nowhere" }
}
}

View File

@@ -1,8 +1,26 @@
package com.pischule.memevizor.upload package com.pischule.memevizor.upload
import io.github.oshai.kotlinlogging.KotlinLogging
import io.minio.MinioClient
import io.minio.PutObjectArgs
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
private val logger = KotlinLogging.logger {}
@ConditionalOnBean(S3Config::class)
@Service @Service
interface FileUploaderService { 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" }
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" }
}
} }

View File

@@ -4,9 +4,7 @@ import io.minio.MinioClient
import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Profile
@Profile("!local")
@EnableConfigurationProperties(S3Props::class) @EnableConfigurationProperties(S3Props::class)
@Configuration @Configuration
class S3Config { class S3Config {

View File

@@ -1,27 +0,0 @@
package com.pischule.memevizor.upload
import io.github.oshai.kotlinlogging.KotlinLogging
import io.minio.MinioClient
import io.minio.PutObjectArgs
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean
import org.springframework.stereotype.Service
private val logger = KotlinLogging.logger {}
@ConditionalOnBean(S3Config::class)
@Service
class S3FileUploaderService(private val s3Client: MinioClient, private val s3Props: S3Props) :
FileUploaderService {
override 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" }
}
}

View File

@@ -2,6 +2,6 @@ package com.pischule.memevizor.util
import com.github.kotlintelegrambot.entities.Message import com.github.kotlintelegrambot.entities.Message
fun Message.getMaxResPhotoId(): String? = this.photo?.lastOrNull()?.fileId fun Message.getMaxResPhotoId(): String? = photo?.lastOrNull()?.fileId
fun Message.getVideoFileId(): String? = this.video?.fileId fun Message.getVideoFileId(): String? = video?.fileId ?: videoNote?.fileId

View File

@@ -0,0 +1,74 @@
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",
// audio
"-map",
"0:a",
"-c:a",
"libopus",
"-b:a",
"96k",
"-vbr",
"on",
"-compression_level",
"10",
// 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

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

View File

@@ -3,116 +3,126 @@
<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">
<link rel="icon" <title>Dynamic Media Refresh</title>
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>
:root { /* Basic styling to make the media fill the container */
--bg-image: linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5)); html, body {
} height: 100%;
* {
margin: 0; margin: 0;
padding: 0; background-color: #111;
box-sizing: border-box;
} }
#media-container {
body {
background-color: black;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
height: 100vh; height: 100%;
overflow: hidden; width: 100%;
position: relative;
} }
#media-container > img,
img { #media-container > video {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain; object-fit: contain;
position: relative;
z-index: 1;
}
video {
width: 100%;
height: 100%;
object-fit: contain;
position: relative;
z-index: 1;
} }
</style> </style>
</head> </head>
<body> <body>
<img id="image" alt="Смешная картинка" src="">
</body> <main id="media-container"></main>
<script defer> <script defer>
const fileUrl = '_'; const fileUrl = '_';
const refreshIntervalMs = 30_000; // Time in milliseconds (e.g., 10000 = 10 seconds) const refreshIntervalMs = 15_000; // 15 seconds
const mediaContainer = document.getElementById("media-container");
let lastModified = null; let lastModified = null;
const mediaContainer = document.querySelector("body") /**
* Creates a new media element (<img> or <video>) and replaces the current one.
* @param {Response} response The fetch response object.
*/
async function updateMediaElement(response) {
// Get the current element to revoke its blob URL later, preventing memory leaks.
const currentElement = mediaContainer.firstElementChild;
const oldBlobUrl = currentElement?.src;
async function refreshMediaDom(response) {
const blob = await response.blob(); const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob); const newBlobUrl = URL.createObjectURL(blob);
const contentType = response.headers.get("content-type"); const contentType = response.headers.get("content-type") || "";
let newElement;
if (contentType.startsWith("video/")) { if (contentType.startsWith("video/")) {
const video = document.createElement("video"); newElement = document.createElement("video");
video.src = blobUrl; newElement.src = newBlobUrl;
video.controls = true; newElement.controls = true;
video.muted = true; newElement.muted = true;
video.loop = true; newElement.loop = true;
video.autoplay = true; newElement.autoplay = true;
mediaContainer.replaceChildren(video);
} else { } else {
const img = document.createElement("img"); newElement = document.createElement("img");
img.src = blobUrl; newElement.src = newBlobUrl;
mediaContainer.replaceChildren(img); newElement.alt = "Dynamically loaded media content";
}
// Replace the entire content of the container with the new element.
mediaContainer.replaceChildren(newElement);
// IMPORTANT: Revoke the old blob URL to free up memory.
if (oldBlobUrl && oldBlobUrl.startsWith('blob:')) {
URL.revokeObjectURL(oldBlobUrl);
} }
} }
/**
* Fetches the media file and updates it if modified.
*/
async function refreshMedia() { async function refreshMedia() {
try { try {
const headers = new Headers(); const headers = new Headers();
if (lastModified != null) { if (lastModified) {
headers.append('If-Modified-Since', lastModified); headers.append('If-Modified-Since', lastModified);
} }
const response = await fetch(fileUrl, {method: 'GET', headers, cache: 'no-store'}); // 'no-store' ensures we always check with the server.
const response = await fetch(fileUrl, { method: 'GET', headers, cache: 'no-store' });
const now = new Date().toLocaleTimeString(); const now = new Date().toLocaleTimeString();
if (response.status === 304) { if (response.status === 304) { // Not Modified
console.log(`[${now}] Status: 304 Not Modified. Image is up to date.`); console.log(`[${now}] Status: 304 Not Modified. Media is up to date.`);
return; return;
} }
if (response.status === 200) { if (response.status === 200) { // OK
const newLastModified = response.headers.get('last-modified'); const newLastModified = response.headers.get('last-modified');
if (newLastModified) { if (newLastModified) {
lastModified = newLastModified; lastModified = newLastModified;
await refreshMediaDom(response) await updateMediaElement(response);
console.log(`[${now}] Status: 200 OK. Media updated.`);
} else { } else {
console.warn(`No Last-Modified header found. Cannot perform conditional checks`); console.warn(`[${now}] Warning: No 'Last-Modified' header found. Conditional checks are disabled.`);
} }
return; return;
} }
console.error(`Unexpected server response: ${response.status} ${response.statusText}`) console.error(`[${now}] Unexpected server response: ${response.status} ${response.statusText}`);
} catch (error) { } catch (error) {
console.error('Failed to fetch image:', error); console.error('Failed to fetch media:', error);
} }
} }
refreshMedia(); // --- Event Listeners ---
setInterval(refreshMedia, refreshIntervalMs); document.addEventListener('DOMContentLoaded', refreshMedia); // Initial fetch
document.addEventListener('click', refreshMedia); setInterval(refreshMedia, refreshIntervalMs); // Periodic refresh
document.addEventListener('keydown', function (event) { document.addEventListener('click', refreshMedia); // Manual refresh on click
document.addEventListener('keydown', (event) => {
if (event.code === 'Space') { if (event.code === 'Space') {
event.preventDefault(); event.preventDefault(); // Prevent page scroll
refreshMedia(); refreshMedia();
} }
}); });
</script> </script>
</body>
</html> </html>