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
import com.github.kotlintelegrambot.dispatcher.handlers.MessageHandlerEnvironment
import com.github.kotlintelegrambot.entities.ChatAction
import com.github.kotlintelegrambot.entities.ChatId
import com.github.kotlintelegrambot.entities.Message
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 io.github.oshai.kotlinlogging.KotlinLogging
import io.github.oshai.kotlinlogging.withLoggingContext
import org.springframework.stereotype.Component
@@ -18,6 +19,7 @@ private val logger = KotlinLogging.logger {}
class ThisCommandHandlerService(
private val botProps: BotProps,
private val fileUploaderService: FileUploaderService,
private val videoEncoderService: VideoEncoderService,
) {
private val confirmCommands = listOf("this", "!soxok")
@@ -26,23 +28,35 @@ class ThisCommandHandlerService(
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) {
val fileBytes = env.bot.downloadFileBytes(fileId) ?: return
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, "👍")
}
}
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 {
val isApprover = env.message.from?.id?.let { botProps.approverUserIds.contains(it) } == true
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
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
interface FileUploaderService {
fun uploadFile(fileBytes: ByteArray, filename: String, contentType: String)
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" }
}
}

View File

@@ -4,9 +4,7 @@ import io.minio.MinioClient
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Profile
@Profile("!local")
@EnableConfigurationProperties(S3Props::class)
@Configuration
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
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
logging.structured.format.console=ecs

View File

@@ -3,116 +3,126 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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>
<title>Dynamic Media Refresh</title>
<style>
:root {
--bg-image: linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5));
}
* {
/* Basic styling to make the media fill the container */
html, body {
height: 100%;
margin: 0;
padding: 0;
box-sizing: border-box;
background-color: #111;
}
body {
background-color: black;
#media-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
overflow: hidden;
position: relative;
height: 100%;
width: 100%;
}
img {
#media-container > img,
#media-container > video {
width: 100%;
height: 100%;
object-fit: contain;
position: relative;
z-index: 1;
}
video {
width: 100%;
height: 100%;
object-fit: contain;
position: relative;
z-index: 1;
}
</style>
</head>
<body>
<img id="image" alt="Смешная картинка" src="">
</body>
<main id="media-container"></main>
<script defer>
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;
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 blobUrl = URL.createObjectURL(blob);
const contentType = response.headers.get("content-type");
const newBlobUrl = URL.createObjectURL(blob);
const contentType = response.headers.get("content-type") || "";
let newElement;
if (contentType.startsWith("video/")) {
const video = document.createElement("video");
video.src = blobUrl;
video.controls = true;
video.muted = true;
video.loop = true;
video.autoplay = true;
mediaContainer.replaceChildren(video);
newElement = document.createElement("video");
newElement.src = newBlobUrl;
newElement.controls = true;
newElement.muted = true;
newElement.loop = true;
newElement.autoplay = true;
} else {
const img = document.createElement("img");
img.src = blobUrl;
mediaContainer.replaceChildren(img);
newElement = document.createElement("img");
newElement.src = newBlobUrl;
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() {
try {
const headers = new Headers();
if (lastModified != null) {
if (lastModified) {
headers.append('If-Modified-Since', lastModified);
}
// '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();
if (response.status === 304) {
console.log(`[${now}] Status: 304 Not Modified. Image is up to date.`);
if (response.status === 304) { // Not Modified
console.log(`[${now}] Status: 304 Not Modified. Media is up to date.`);
return;
}
if (response.status === 200) {
if (response.status === 200) { // OK
const newLastModified = response.headers.get('last-modified');
if (newLastModified) {
lastModified = newLastModified;
await refreshMediaDom(response)
await updateMediaElement(response);
console.log(`[${now}] Status: 200 OK. Media updated.`);
} 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;
}
console.error(`Unexpected server response: ${response.status} ${response.statusText}`)
console.error(`[${now}] Unexpected server response: ${response.status} ${response.statusText}`);
} catch (error) {
console.error('Failed to fetch image:', error);
console.error('Failed to fetch media:', error);
}
}
refreshMedia();
setInterval(refreshMedia, refreshIntervalMs);
document.addEventListener('click', refreshMedia);
document.addEventListener('keydown', function (event) {
// --- Event Listeners ---
document.addEventListener('DOMContentLoaded', refreshMedia); // Initial fetch
setInterval(refreshMedia, refreshIntervalMs); // Periodic refresh
document.addEventListener('click', refreshMedia); // Manual refresh on click
document.addEventListener('keydown', (event) => {
if (event.code === 'Space') {
event.preventDefault();
event.preventDefault(); // Prevent page scroll
refreshMedia();
}
});
</script>
</body>
</html>