mirror of
https://github.com/pischule/memevizor.git
synced 2025-12-19 06:56:42 +00:00
Implement video conversion
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1 @@
|
|||||||
spring.application.name=memevizor
|
spring.application.name=memevizor
|
||||||
logging.structured.format.console=ecs
|
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 'no-store' ensures we always check with the server.
|
||||||
const response = await fetch(fileUrl, { method: 'GET', headers, cache: 'no-store' });
|
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>
|
||||||
|
|||||||
Reference in New Issue
Block a user