mirror of
https://github.com/pischule/memevizor.git
synced 2026-02-04 09:00:52 +00:00
Compare commits
14 Commits
pischule-l
...
8291c72b0d
| Author | SHA1 | Date | |
|---|---|---|---|
| 8291c72b0d | |||
|
|
20e6c61f31 | ||
| 99188bea32 | |||
| c791db409a | |||
| b31bd87687 | |||
| 061743492e | |||
| 1266b0f440 | |||
| 23dcd998bc | |||
| 8337df89e6 | |||
| 702444d016 | |||
| 70bec5ea4f | |||
| 6dae22ad14 | |||
| 2aa8dee019 | |||
|
|
d7656e7675 |
@@ -1,37 +1,13 @@
|
||||
name: CI
|
||||
name: release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Tests
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
cache: 'gradle'
|
||||
|
||||
- name: Test with Gradle
|
||||
run: ./gradlew check
|
||||
|
||||
build-and-publish:
|
||||
name: Build and Publish
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
23
.github/workflows/test.yaml
vendored
Normal file
23
.github/workflows/test.yaml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
name: test
|
||||
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
cache: 'gradle'
|
||||
|
||||
- name: Test with Gradle
|
||||
run: ./gradlew check
|
||||
19
Dockerfile
19
Dockerfile
@@ -1,11 +1,20 @@
|
||||
FROM docker.io/eclipse-temurin:21
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y ffmpeg \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN groupadd -r spring && useradd --no-log-init -r -g spring spring
|
||||
USER spring:spring
|
||||
|
||||
RUN groupadd --system app \
|
||||
&& useradd --no-log-init --system --shell /sbin/nologin --gid app app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG JAR_FILE=build/libs/*.jar
|
||||
COPY ${JAR_FILE} app.jar
|
||||
COPY build/resources/main/static BOOT-INF/classes/static
|
||||
ENTRYPOINT ["java","-jar","/app.jar"]
|
||||
COPY --chown=app:app ${JAR_FILE} app.jar
|
||||
COPY --chown=app:app build/resources/main/static BOOT-INF/classes/static
|
||||
|
||||
USER app:app
|
||||
|
||||
ENTRYPOINT ["java","-jar","app.jar"]
|
||||
|
||||
|
||||
73
README.md
73
README.md
@@ -2,12 +2,24 @@
|
||||
|
||||
A Telegram bot for meme management with cloud storage integration and a web interface for display.
|
||||
|
||||
## Features
|
||||
- 🤖 Telegram bot that handles image and video memes
|
||||
- ☁️ Cloud storage integration (AWS S3/Yandex Cloud compatible)
|
||||
- 🖼️ Web interface with auto-refreshing media display
|
||||
- 🔐 Approver user system for content moderation
|
||||
- 📁 Local development mode with dummy storage
|
||||
## Core Functionality (User Stories)
|
||||
|
||||
### Suggesting a Meme (User)
|
||||
|
||||
* User scans QR code.
|
||||
* User sends media (video/picture) to the bot.
|
||||
* Bot forwards the media to the **Approver Group**.
|
||||
|
||||
### Approving a Meme (Approver)
|
||||
|
||||
* **Approver** replies to media in the **Approver Group** with an approval command (e.g., `!SOXOK`, `this`).
|
||||
* **Processing:** If video, bot re-encodes to WebM (AV1 + Opus).
|
||||
* Bot uploads the final media to **S3**.
|
||||
* **Client Update:** Frontend detects S3 resource change and downloads the latest media.
|
||||
|
||||
### Direct Meme Submission (Approver)
|
||||
|
||||
* **Approver** sends media directly to the **Approver Group** or the bot.
|
||||
|
||||
## Architecture
|
||||
The system consists of:
|
||||
@@ -16,46 +28,29 @@ The system consists of:
|
||||
- **Web Interface**: Simple HTML page for viewing memes
|
||||
- **Spring Boot**: Kotlin-based backend with dependency injection
|
||||
|
||||

|
||||
|
||||
## Setup
|
||||
1. Create `.env` file with:
|
||||
```properties
|
||||
# Telegram Bot Configuration
|
||||
BOT_TOKEN=your_bot_token
|
||||
FORWARD_CHAT_ID=your_forward_chat_id
|
||||
APPROVER_USER_IDS=id1,id2,id3
|
||||
|
||||
# S3 Configuration (for production)
|
||||
S3_ACCESS_KEY_ID=your_key
|
||||
S3_SECRET_ACCESS_KEY=your_secret
|
||||
S3_BUCKET=your_bucket_name
|
||||
```
|
||||
|
||||
2. For local development:
|
||||
```bash
|
||||
./gradlew bootRun --args='--spring.profiles.active=local'
|
||||
```
|
||||
|
||||
## Usage
|
||||
1. Send a meme (image/video) to the Telegram bot
|
||||
2. Reply with `this` or `!soxok` to approve and upload
|
||||
3. View the meme at `http://localhost:8080` (or your deployed URL)
|
||||
The application is configured via Spring Boot properties.
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker
|
||||
|
||||
1. Run the container with environment variables:
|
||||
```bash
|
||||
docker build -t memevizor .
|
||||
docker run -d -p 8080:8080 memevizor
|
||||
docker run -d \
|
||||
-e BOT_TOKEN='your_bot_token' \
|
||||
-e BOT_FORWARD_CHAT_ID='your_forward_chat_id' \
|
||||
-e BOT_APPROVER_USER_IDS='id1,id2,id3' \
|
||||
-e S3_ENDPOINT='https.s3.example.com' \
|
||||
-e S3_REGION='us-east-1' \
|
||||
-e S3_BUCKET='your_bucket_name' \
|
||||
-e S3_ACCESS_KEY_ID='your_key' \
|
||||
-e S3_SECRET_ACCESS_KEY='your_secret' \
|
||||
ghcr.io/pischule/memevizor:latest
|
||||
```
|
||||
|
||||
### Cloud (Heroku/AWS/etc)
|
||||
1. Set environment variables
|
||||
2. Deploy with your preferred provider
|
||||
|
||||
## Contributing
|
||||
Contributions welcome! Please:
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Submit a pull request
|
||||
|
||||
## License
|
||||
GNU GPLv3
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
plugins {
|
||||
kotlin("jvm") version "2.2.0"
|
||||
kotlin("plugin.spring") version "2.2.0"
|
||||
id("org.springframework.boot") version "3.4.4"
|
||||
kotlin("jvm") version "2.2.21"
|
||||
kotlin("plugin.spring") version "2.2.21"
|
||||
id("org.springframework.boot") version "4.0.0"
|
||||
id("io.spring.dependency-management") version "1.1.7"
|
||||
id("com.diffplug.spotless") version "7.0.3"
|
||||
id("com.diffplug.spotless") version "8.1.0"
|
||||
}
|
||||
|
||||
group = "com.pischule"
|
||||
@@ -31,7 +31,7 @@ dependencies {
|
||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||
implementation("io.github.kotlin-telegram-bot.kotlin-telegram-bot:telegram:6.3.0")
|
||||
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
|
||||
implementation("io.minio:minio:8.5.17")
|
||||
implementation(awssdk.services.s3)
|
||||
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
||||
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
@@ -51,6 +51,6 @@ tasks.withType<Test> {
|
||||
|
||||
spotless {
|
||||
kotlin {
|
||||
ktfmt("0.54").kotlinlangStyle()
|
||||
ktfmt("0.59").kotlinlangStyle()
|
||||
}
|
||||
}
|
||||
|
||||
3
docs/structurizr/.gitignore
vendored
Normal file
3
docs/structurizr/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.structurizr
|
||||
workspace.json
|
||||
!out
|
||||
4
docs/structurizr/launch-structurizr.sh
Executable file
4
docs/structurizr/launch-structurizr.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
docker run -it --rm -p 8080:8080 -v "${PWD}:/usr/local/structurizr" structurizr/lite
|
||||
|
||||
1
docs/structurizr/out/structurizr-1-ContainerDiagram.svg
Normal file
1
docs/structurizr/out/structurizr-1-ContainerDiagram.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 20 KiB |
50
docs/structurizr/workspace.dsl
Normal file
50
docs/structurizr/workspace.dsl
Normal file
@@ -0,0 +1,50 @@
|
||||
workspace "memevizor" "A meme sharing service" {
|
||||
model {
|
||||
// Actors
|
||||
u = person "User" "Meme submitter."
|
||||
a = person "Approver" "Moderates/Approves memes."
|
||||
tv = person "Viewer (Any Web Browser)" "Opens the S3-hosted UI on a browser (e.g., on a TV screen) to view the current approved meme."
|
||||
|
||||
// External Systems
|
||||
tg = softwareSystem "Telegram" "Messaging platform used for content submission and moderation."
|
||||
|
||||
// The Software System under development
|
||||
ss = softwareSystem "Memevizor" "A Telegram-based service for submitting, approving, and displaying memes." {
|
||||
|
||||
// Core Application Container (Renamed and specified technology)
|
||||
bot = container "Memevizor Bot Backend" "Kotlin/Spring Application." "Processes Telegram updates, handles media conversion, and manages S3 content."
|
||||
|
||||
// Storage Container (Clarified role)
|
||||
s3 = container "Content S3 Bucket" "AWS S3." "Stores static UI assets (HTML/CSS/JS) and approved media content."
|
||||
}
|
||||
|
||||
// Actor Relationships
|
||||
u -> tg "Sends memes and content to"
|
||||
a -> tg "Moderates/Approves content by replying to messages in"
|
||||
|
||||
// Backend Interactions
|
||||
// 1. Core Telegram Communication (Polling for messages and sending replies)
|
||||
bot -> tg "Polls for updates and sends reactions/replies/Downloads media" "HTTPS/Telegram Bot API"
|
||||
|
||||
// 3. S3 Interactions
|
||||
bot -> s3 "Uploads UI assets/media" "HTTPS"
|
||||
|
||||
// Viewer/Client Interactions
|
||||
tv -> s3 "Fetches UI and polls for media files" "HTTPS"
|
||||
}
|
||||
|
||||
views {
|
||||
systemContext ss "SystemContext" {
|
||||
// Focus on the Memevizor system and its neighbors
|
||||
include *
|
||||
autolayout lr
|
||||
//
|
||||
}
|
||||
|
||||
container ss "ContainerDiagram" {
|
||||
// Focus on the containers within Memevizor and their external dependencies
|
||||
include *
|
||||
autolayout tb
|
||||
}
|
||||
}
|
||||
}
|
||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
[tools]
|
||||
java = "temurin-21"
|
||||
|
||||
[tasks.release]
|
||||
description = 'Build and push docker image'
|
||||
run = [
|
||||
'./gradlew build',
|
||||
'docker build -t memevizor:latest .',
|
||||
'docker pussh memevizor:latest apps0.pischule.com'
|
||||
]
|
||||
|
||||
[tasks.fmt]
|
||||
description = 'Fourmat source code'
|
||||
run = './gradlew :spotlessApply'
|
||||
|
||||
@@ -4,4 +4,10 @@ dependencyResolutionManagement {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
versionCatalogs {
|
||||
create("awssdk") {
|
||||
from("aws.sdk.kotlin:version-catalog:1.5.97")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ class BotConfiguration(
|
||||
photos { handleMedia() }
|
||||
video { handleMedia() }
|
||||
videoNote { handleMedia() }
|
||||
document { handleMedia() }
|
||||
command("whoami") {
|
||||
handleMessage(message) {
|
||||
bot.sendMessage(
|
||||
|
||||
@@ -20,7 +20,7 @@ class ThisCommandHandlerService(
|
||||
private val fileUploaderService: FileUploaderService,
|
||||
private val videoTranscoderService: VideoTranscoderService,
|
||||
) {
|
||||
private val confirmCommands = listOf("this", "!soxok")
|
||||
private val confirmCommands = listOf("this", "true", "!soxok")
|
||||
private val mediaFileName = "media"
|
||||
|
||||
fun create(env: MessageHandlerEnvironment) {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.pischule.memevizor.upload
|
||||
|
||||
import aws.sdk.kotlin.services.s3.S3Client
|
||||
import aws.sdk.kotlin.services.s3.model.PutObjectRequest
|
||||
import aws.smithy.kotlin.runtime.content.ByteStream
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.github.oshai.kotlinlogging.withLoggingContext
|
||||
import io.minio.MinioClient
|
||||
import io.minio.PutObjectArgs
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@@ -11,17 +13,18 @@ private val logger = KotlinLogging.logger {}
|
||||
|
||||
@ConditionalOnBean(S3Config::class)
|
||||
@Service
|
||||
class FileUploaderService(private val s3Client: MinioClient, private val s3Props: S3Props) {
|
||||
class FileUploaderService(private val s3Client: S3Client, private val s3Props: S3Props) {
|
||||
fun uploadFile(fileBytes: ByteArray, filename: String, contentType: String) {
|
||||
withLoggingContext("filename" to filename, "bucket" to s3Props.bucket) {
|
||||
s3Client.putObject(
|
||||
PutObjectArgs.builder()
|
||||
.bucket(s3Props.bucket)
|
||||
.`object`(filename)
|
||||
.stream(fileBytes.inputStream(), fileBytes.size.toLong(), -1)
|
||||
.contentType(contentType)
|
||||
.build()
|
||||
)
|
||||
val request = PutObjectRequest {
|
||||
bucket = s3Props.bucket
|
||||
key = filename
|
||||
body = ByteStream.fromBytes(fileBytes)
|
||||
this.contentType = contentType
|
||||
}
|
||||
|
||||
logger.info { "Started uploading a file" }
|
||||
runBlocking { s3Client.putObject(request) }
|
||||
logger.info { "Uploaded a file to S3" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.pischule.memevizor.upload
|
||||
|
||||
import io.minio.MinioClient
|
||||
import aws.sdk.kotlin.runtime.auth.credentials.StaticCredentialsProvider
|
||||
import aws.sdk.kotlin.services.s3.S3Client
|
||||
import aws.smithy.kotlin.runtime.net.url.Url
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
@@ -8,11 +10,17 @@ import org.springframework.context.annotation.Configuration
|
||||
@EnableConfigurationProperties(S3Props::class)
|
||||
@Configuration
|
||||
class S3Config {
|
||||
|
||||
@Bean
|
||||
fun s3Client(s3Props: S3Props): MinioClient =
|
||||
MinioClient.builder()
|
||||
.endpoint(s3Props.endpoint)
|
||||
.region(s3Props.region)
|
||||
.credentials(s3Props.accessKeyId, s3Props.secretAccessKey)
|
||||
.build()
|
||||
fun s3Client(s3Props: S3Props) = S3Client {
|
||||
endpointUrl = Url.parse(s3Props.endpoint)
|
||||
region = s3Props.region
|
||||
credentialsProvider = StaticCredentialsProvider {
|
||||
accessKeyId = s3Props.accessKeyId
|
||||
secretAccessKey = s3Props.secretAccessKey
|
||||
}
|
||||
|
||||
// not supported by Yandex Object Storage
|
||||
continueHeaderThresholdBytes = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,18 @@ fun Message.getMedia(): MessageMedia? {
|
||||
return MessageMedia(fileId, MessageMedia.Type.PHOTO)
|
||||
}
|
||||
|
||||
(video?.fileId ?: videoNote?.fileId)?.let { fileId ->
|
||||
return MessageMedia(fileId, MessageMedia.Type.VIDEO)
|
||||
video?.let {
|
||||
return MessageMedia(it.fileId, MessageMedia.Type.VIDEO)
|
||||
}
|
||||
|
||||
videoNote?.let {
|
||||
return MessageMedia(it.fileId, MessageMedia.Type.VIDEO)
|
||||
}
|
||||
|
||||
document
|
||||
?.takeIf { it.mimeType?.startsWith("video/") == true }
|
||||
?.let {
|
||||
return MessageMedia(it.fileId, MessageMedia.Type.VIDEO)
|
||||
}
|
||||
|
||||
return null
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.pischule.memevizor.video
|
||||
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.createTempFile
|
||||
import kotlin.io.path.deleteIfExists
|
||||
import kotlin.io.path.readBytes
|
||||
@@ -14,62 +15,32 @@ private val logger = KotlinLogging.logger {}
|
||||
class VideoTranscoderService {
|
||||
|
||||
fun transcode(inputVideo: ByteArray): ByteArray {
|
||||
logger.info { "Started transcoding a file" }
|
||||
logger.info { "Started transcoding a video" }
|
||||
|
||||
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)
|
||||
val copyVideo = hasDesiredVideoEncoding(inputFile)
|
||||
val copyAudio = hasDesiredAudioEncoding(inputFile)
|
||||
|
||||
val processOutput: String
|
||||
val timeTaken = measureTime {
|
||||
processOutput =
|
||||
launchCommand(
|
||||
transcodeCommand(
|
||||
inputFile = inputFile,
|
||||
outputFile = outputFile,
|
||||
copyVideo = copyVideo,
|
||||
copyAudio = copyAudio,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
logger.atInfo {
|
||||
message = "Finished transcoding a file"
|
||||
message = "Finished transcoding a video"
|
||||
payload =
|
||||
mapOf(
|
||||
"processOutput" to processOutput,
|
||||
@@ -82,4 +53,106 @@ class VideoTranscoderService {
|
||||
outputFile.deleteIfExists()
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchCommand(command: List<String>): String {
|
||||
val process = ProcessBuilder(command).redirectErrorStream(true).start()
|
||||
val exitCode = process.waitFor()
|
||||
val processOutput = process.inputStream.bufferedReader().use { it.readText() }
|
||||
if (exitCode != 0) {
|
||||
throw VideoTranscodingException(exitCode, processOutput)
|
||||
}
|
||||
return processOutput.trim()
|
||||
}
|
||||
|
||||
private fun hasDesiredVideoEncoding(inputFile: Path): Boolean {
|
||||
val videoCodec = launchCommand(getVideoCodecCommand(inputFile))
|
||||
return videoCodec == "av1"
|
||||
}
|
||||
|
||||
private fun hasDesiredAudioEncoding(inputFile: Path): Boolean {
|
||||
val audioCodec = launchCommand(getAudioCodecCommand(inputFile))
|
||||
return audioCodec == "opus" || audioCodec == ""
|
||||
}
|
||||
|
||||
private fun getVideoCodecCommand(inputFile: Path) =
|
||||
listOf(
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=codec_name",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
"$inputFile",
|
||||
)
|
||||
|
||||
private fun getAudioCodecCommand(inputFile: Path) =
|
||||
listOf(
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"a:0",
|
||||
"-show_entries",
|
||||
"stream=codec_name",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
"$inputFile",
|
||||
)
|
||||
|
||||
private fun transcodeCommand(
|
||||
inputFile: Path,
|
||||
outputFile: Path,
|
||||
copyVideo: Boolean,
|
||||
copyAudio: Boolean,
|
||||
): List<String> = buildList {
|
||||
add("ffmpeg")
|
||||
add("-nostdin")
|
||||
add("-nostats")
|
||||
add("-hide_banner")
|
||||
|
||||
// input
|
||||
add("-i")
|
||||
add("$inputFile")
|
||||
|
||||
// video
|
||||
add("-map")
|
||||
add("0:v")
|
||||
add("-c:v")
|
||||
if (copyVideo) {
|
||||
add("copy")
|
||||
} else {
|
||||
add("libsvtav1")
|
||||
add("-preset")
|
||||
add("6")
|
||||
add("-crf")
|
||||
add("35")
|
||||
add("-svtav1-params")
|
||||
add("film-grain=10")
|
||||
}
|
||||
|
||||
// audio
|
||||
add("-map")
|
||||
add("0:a?")
|
||||
add("-c:a")
|
||||
if (copyAudio) {
|
||||
add("copy")
|
||||
} else {
|
||||
add("libopus")
|
||||
add("-b:a")
|
||||
add("96k")
|
||||
add("-vbr")
|
||||
add("on")
|
||||
add("-compression_level")
|
||||
add("10")
|
||||
}
|
||||
|
||||
// output
|
||||
add("-f")
|
||||
add("webm")
|
||||
add("-y")
|
||||
add("$outputFile")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
spring.application.name=memevizor
|
||||
logging.structured.format.console=ecs
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
}
|
||||
|
||||
#qr-container {
|
||||
font-size: 0;
|
||||
position: fixed;
|
||||
bottom: var(--qr-margin);
|
||||
right: var(--qr-margin);
|
||||
@@ -142,6 +143,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
function toggleQrVisibility() {
|
||||
const qr = document.getElementById("qr-code");
|
||||
const isVisible = qr.style.display === '';
|
||||
qr.style.display = isVisible ? 'none' : '';
|
||||
}
|
||||
|
||||
// --- Event Listeners ---
|
||||
document.addEventListener('DOMContentLoaded', refreshMedia); // Initial fetch
|
||||
setInterval(refreshMedia, refreshIntervalMs); // Periodic refresh
|
||||
@@ -152,6 +159,10 @@
|
||||
event.preventDefault(); // Prevent page scroll
|
||||
refreshMedia();
|
||||
}
|
||||
|
||||
if (event.key === 'q') {
|
||||
toggleQrVisibility()
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user