feat: メディアアップロード処理を追加

This commit is contained in:
usbharu 2024-06-16 15:45:41 +09:00
parent a93131b5dc
commit 43ef619eee
17 changed files with 481 additions and 6 deletions

View File

@ -24,12 +24,13 @@ import dev.usbharu.hideout.core.domain.shared.id.IdGenerateService
import dev.usbharu.hideout.core.external.media.MediaProcessor
import dev.usbharu.hideout.core.external.mediastore.MediaStore
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.stereotype.Service
import dev.usbharu.hideout.core.domain.model.media.Media as MediaModel
@Service
class UploadMediaApplicationService(
private val mediaProcessor: MediaProcessor,
@Qualifier("delegate") private val mediaProcessor: MediaProcessor,
private val mediaStore: MediaStore,
private val mediaRepository: MediaRepository,
private val idGenerateService: IdGenerateService,
@ -42,7 +43,7 @@ class UploadMediaApplicationService(
}
override suspend fun internalExecute(command: UploadMedia, executor: CommandExecutor): Media {
val process = mediaProcessor.process(command.path)
val process = mediaProcessor.process(command.path, command.name, null)
val id = idGenerateService.generateId()
val thumbnailUri = if (process.thumbnailPath != null) {
mediaStore.upload(process.thumbnailPath, "thumbnail-$id")
@ -59,7 +60,7 @@ class UploadMediaApplicationService(
thumbnailUri,
process.fileType,
process.mimeType,
MediaBlurHash(process.blurHash),
process.blurHash?.let { MediaBlurHash(it) },
command.description?.let { MediaDescription(it) }
)

View File

@ -0,0 +1,17 @@
package dev.usbharu.hideout.core.config
import org.bytedeco.ffmpeg.global.avcodec
import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties("hideout.media.video.ffmpeg")
data class FFmpegVideoConfig(
val frameRate: Int = 60,
val maxWidth: Int = 1920,
val maxHeight: Int = 1080,
val format: String = "mp4",
val videoCodec: Int = avcodec.AV_CODEC_ID_H264,
val audioCodec: Int = avcodec.AV_CODEC_ID_AAC,
val videoQuality: Double = 1.0,
val videoOption: List<String> = listOf("preset", "ultrafast"),
val maxBitrate: Int = 1300000,
)

View File

@ -0,0 +1,10 @@
package dev.usbharu.hideout.core.config
import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties("hideout.media.image.imageio")
data class ImageIOImageConfig(
val thumbnailsWidth: Int = 1000,
val thumbnailsHeight: Int = 1000,
val format: String = "jpeg"
)

View File

@ -0,0 +1,17 @@
package dev.usbharu.hideout.core.config
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.boot.context.properties.ConfigurationProperties
/**
* メディアの保存にローカルファイルシステムを使用する際のコンフィグ
*
* @property path フォゾンする場所へのパス /から始めると絶対パスとなります
* @property publicUrl 公開用URL 省略可能 指定するとHideoutがファイルを配信しなくなります
*/
@ConfigurationProperties("hideout.storage.local")
@ConditionalOnProperty("hideout.storage.type", havingValue = "local", matchIfMissing = true)
data class LocalStorageConfig(
val path: String = "files",
val publicUrl: String?
)

View File

@ -0,0 +1,34 @@
package dev.usbharu.hideout.core.config
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials
import software.amazon.awssdk.regions.Region
import software.amazon.awssdk.services.s3.S3Client
import java.net.URI
@ConfigurationProperties("hideout.storage.s3")
@ConditionalOnProperty("hideout.storage.type", havingValue = "s3")
data class S3StorageConfig(
val endpoint: String,
val publicUrl: String,
val bucket: String,
val region: String,
val accessKey: String,
val secretKey: String
)
@Configuration
class AwsConfig {
@Bean
@ConditionalOnProperty("hideout.storage.type", havingValue = "s3")
fun s3Client(awsConfig: S3StorageConfig): S3Client {
return S3Client.builder()
.endpointOverride(URI.create(awsConfig.endpoint))
.region(Region.of(awsConfig.region))
.credentialsProvider { AwsBasicCredentials.create(awsConfig.accessKey, awsConfig.secretKey) }
.build()
}
}

View File

@ -0,0 +1,22 @@
package dev.usbharu.hideout.core.external.media
import dev.usbharu.hideout.core.domain.model.media.MimeType
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.stereotype.Component
import java.nio.file.Path
@Component
@Qualifier("delegate")
class DelegateMediaProcessor(
private val fileTypeDeterminer: FileTypeDeterminer,
private val mediaProcessors: List<MediaProcessor>
) : MediaProcessor {
override fun isSupported(mimeType: MimeType): Boolean {
return true
}
override suspend fun process(path: Path, filename: String, mimeType: MimeType?): ProcessedMedia {
val fileType = fileTypeDeterminer.fileType(path, filename)
return mediaProcessors.first { it.isSupported(fileType) }.process(path, filename, fileType)
}
}

View File

@ -0,0 +1,8 @@
package dev.usbharu.hideout.core.external.media
import dev.usbharu.hideout.core.domain.model.media.MimeType
import java.nio.file.Path
interface FileTypeDeterminer {
fun fileType(path: Path, filename: String): MimeType
}

View File

@ -16,8 +16,10 @@
package dev.usbharu.hideout.core.external.media
import dev.usbharu.hideout.core.domain.model.media.MimeType
import java.nio.file.Path
interface MediaProcessor {
suspend fun process(path: Path): ProcessedMedia
fun isSupported(mimeType: MimeType): Boolean
suspend fun process(path: Path, filename: String, mimeType: MimeType?): ProcessedMedia
}

View File

@ -25,5 +25,5 @@ data class ProcessedMedia(
val thumbnailPath: Path?,
val fileType: FileType,
val mimeType: MimeType,
val blurHash: String,
val blurHash: String?,
)

View File

@ -0,0 +1,51 @@
package dev.usbharu.hideout.core.external.media
import dev.usbharu.hideout.core.domain.model.media.FileType
import dev.usbharu.hideout.core.domain.model.media.MimeType
import org.apache.tika.Tika
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import java.nio.file.Path
@Component
class TikaFileTypeDeterminer : FileTypeDeterminer {
override fun fileType(path: Path, filename: String): MimeType {
logger.info("START Detect file type name: {}", filename)
val tika = Tika()
val detect = try {
tika.detect(path)
} catch (e: IllegalStateException) {
logger.warn("FAILED Detect file type", e)
"application/octet-stream"
}
val type = detect.substringBefore("/")
val fileType = when (type) {
"image" -> {
FileType.Image
}
"video" -> {
FileType.Video
}
"audio" -> {
FileType.Audio
}
else -> {
FileType.Unknown
}
}
val mimeType = MimeType(type, detect.substringAfter("/"), fileType)
logger.info("SUCCESS Detect file type name: {},MimeType: {}", filename, mimeType)
return mimeType
}
companion object {
private val logger = LoggerFactory.getLogger(TikaFileTypeDeterminer::class.java)
}
}

View File

@ -0,0 +1,51 @@
package dev.usbharu.hideout.core.infrastructure.awss3
import dev.usbharu.hideout.core.config.S3StorageConfig
import dev.usbharu.hideout.core.external.mediastore.MediaStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.stereotype.Component
import software.amazon.awssdk.core.sync.RequestBody
import software.amazon.awssdk.services.s3.S3Client
import software.amazon.awssdk.services.s3.model.PutObjectRequest
import java.net.URI
import java.nio.file.Path
@Component
@ConditionalOnProperty("hideout.storage.type", havingValue = "s3")
class AWSS3MediaStore(
private val s3StorageConfig: S3StorageConfig,
private val s3Client: S3Client
) : MediaStore {
override suspend fun upload(path: Path, id: String): URI {
logger.info("MEDIA upload. {}", id)
val fileUploadRequest = PutObjectRequest.builder()
.bucket(s3StorageConfig.bucket)
.key(id)
.build()
logger.info("MEDIA upload. bucket: {} key: {}", s3StorageConfig.bucket, id)
withContext(Dispatchers.IO) {
s3Client.putObject(fileUploadRequest, RequestBody.fromFile(path))
}
val successSavedMedia = URI.create("${s3StorageConfig.publicUrl}/${s3StorageConfig.bucket}/${id}")
logger.info("SUCCESS Media upload. {}", id)
logger.debug(
"name: {} url: {}",
id,
successSavedMedia,
)
return successSavedMedia
}
companion object {
private val logger = LoggerFactory.getLogger(AWSS3MediaStore::class.java)
}
}

View File

@ -0,0 +1,47 @@
package dev.usbharu.hideout.core.infrastructure.localfilesystem
import dev.usbharu.hideout.core.config.ApplicationConfig
import dev.usbharu.hideout.core.config.LocalStorageConfig
import dev.usbharu.hideout.core.external.mediastore.MediaStore
import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.stereotype.Component
import java.net.URI
import java.nio.file.Path
import kotlin.io.path.copyTo
@Component
@ConditionalOnProperty("hideout.storage.type", havingValue = "local", matchIfMissing = true)
class LocalFileSystemMediaStore(
localStorageConfig: LocalStorageConfig,
applicationConfig: ApplicationConfig
) :
MediaStore {
private val publicUrl = localStorageConfig.publicUrl ?: "${applicationConfig.url}/files/"
override suspend fun upload(path: Path, id: String): URI {
logger.info("START Media upload. {}", id)
val fileSavePath = buildSavePath(path, id)
val fileSavePathString = fileSavePath.toAbsolutePath().toString()
logger.info("MEDIA save. path: {}", fileSavePathString)
@Suppress("TooGenericExceptionCaught") try {
path.copyTo(fileSavePath)
} catch (e: Exception) {
logger.warn("FAILED to Save the media.", e)
throw e
}
logger.info("SUCCESS Media upload. {}", id)
return URI.create(publicUrl).resolve(id)
}
private fun buildSavePath(savePath: Path, name: String): Path = savePath.resolve(name)
companion object {
private val logger = LoggerFactory.getLogger(LocalFileSystemMediaStore::class.java)
}
}

View File

@ -0,0 +1,7 @@
package dev.usbharu.hideout.core.infrastructure.media.common
import java.awt.image.BufferedImage
interface GenerateBlurhash {
fun generateBlurhash(bufferedImage: BufferedImage): String
}

View File

@ -0,0 +1,12 @@
package dev.usbharu.hideout.core.infrastructure.media.common
import io.trbl.blurhash.BlurHash
import org.springframework.stereotype.Component
import java.awt.image.BufferedImage
@Component
class GenerateBlurhashImpl : GenerateBlurhash {
override fun generateBlurhash(bufferedImage: BufferedImage): String {
return BlurHash.encode(bufferedImage)
}
}

View File

@ -0,0 +1,79 @@
package dev.usbharu.hideout.core.infrastructure.media.image
import dev.usbharu.hideout.core.config.ImageIOImageConfig
import dev.usbharu.hideout.core.domain.model.media.FileType
import dev.usbharu.hideout.core.domain.model.media.MimeType
import dev.usbharu.hideout.core.external.media.MediaProcessor
import dev.usbharu.hideout.core.external.media.ProcessedMedia
import dev.usbharu.hideout.core.infrastructure.media.common.GenerateBlurhash
import net.coobird.thumbnailator.Thumbnails
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.stereotype.Component
import java.awt.Color
import java.awt.image.BufferedImage
import java.nio.file.Files
import java.nio.file.Path
import java.util.*
import javax.imageio.ImageIO
import kotlin.io.path.inputStream
import kotlin.io.path.outputStream
@Component
@Qualifier("image")
class ImageIOImageProcessor(
private val imageIOImageConfig: ImageIOImageConfig,
private val blurhash: GenerateBlurhash
) : MediaProcessor {
override fun isSupported(mimeType: MimeType): Boolean {
return mimeType.fileType == FileType.Image || mimeType.type == "image"
}
override suspend fun process(path: Path, filename: String, mimeType: MimeType?): ProcessedMedia {
val read = ImageIO.read(path.inputStream())
val bufferedImage = BufferedImage(read.width, read.height, BufferedImage.TYPE_INT_RGB)
val graphics = bufferedImage.createGraphics()
graphics.drawImage(read, 0, 0, Color.BLACK, null)
val tempFileName = UUID.randomUUID().toString()
val tempFile = Files.createTempFile(tempFileName, "tmp")
val thumbnailPath = run {
val tempThumbnailFile = Files.createTempFile("thumbnail-$tempFileName", ".tmp")
tempThumbnailFile.outputStream().use {
val write = ImageIO.write(
Thumbnails.of(bufferedImage)
.size(imageIOImageConfig.thumbnailsWidth, imageIOImageConfig.thumbnailsHeight)
.imageType(BufferedImage.TYPE_INT_RGB)
.asBufferedImage(),
imageIOImageConfig.format,
it
)
tempThumbnailFile.takeIf { write }
}
}
tempFile.outputStream().use {
if (ImageIO.write(bufferedImage, imageIOImageConfig.format, it).not()) {
logger.warn("Failed to save a temporary file. type: {} ,path: {}", imageIOImageConfig.format, tempFile)
throw Exception("Failed to save a temporary file.")
}
}
return ProcessedMedia(
tempFile,
thumbnailPath,
FileType.Image,
MimeType("image", imageIOImageConfig.format, FileType.Image),
blurhash.generateBlurhash(bufferedImage)
)
}
companion object {
private val logger = LoggerFactory.getLogger(ImageIOImageProcessor::class.java)
}
}

View File

@ -0,0 +1,117 @@
package dev.usbharu.hideout.core.infrastructure.media.video
import dev.usbharu.hideout.core.config.FFmpegVideoConfig
import dev.usbharu.hideout.core.domain.model.media.FileType
import dev.usbharu.hideout.core.domain.model.media.MimeType
import dev.usbharu.hideout.core.external.media.MediaProcessor
import dev.usbharu.hideout.core.external.media.ProcessedMedia
import dev.usbharu.hideout.core.infrastructure.media.common.GenerateBlurhash
import org.bytedeco.javacv.FFmpegFrameFilter
import org.bytedeco.javacv.FFmpegFrameGrabber
import org.bytedeco.javacv.FFmpegFrameRecorder
import org.bytedeco.javacv.Java2DFrameConverter
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.stereotype.Component
import java.awt.image.BufferedImage
import java.nio.file.Files
import java.nio.file.Path
import javax.imageio.ImageIO
import kotlin.math.min
@Component
@Qualifier("video")
class FFmpegVideoProcessor(
private val fFmpegVideoConfig: FFmpegVideoConfig,
private val generateBlurhash: GenerateBlurhash
) : MediaProcessor {
override fun isSupported(mimeType: MimeType): Boolean {
return mimeType.fileType == FileType.Video || mimeType.type == "video"
}
override suspend fun process(path: Path, filename: String, mimeType: MimeType?): ProcessedMedia {
val tempFile = Files.createTempFile("hideout-movie-processor-", ".tmp")
val thumbnailFile = Files.createTempFile("hideout-movie-thumbnail-generate-", ".tmp")
logger.info("START Convert Movie Media {}", filename)
var bufferedImage: BufferedImage? = null
FFmpegFrameGrabber(path.toFile()).use { grabber ->
grabber.start()
val width = min(fFmpegVideoConfig.maxWidth, grabber.imageWidth)
val height = min(fFmpegVideoConfig.maxHeight, grabber.imageHeight)
val frameRate = fFmpegVideoConfig.frameRate
logger.debug("Movie Media Width {}, Height {}", width, height)
FFmpegFrameFilter(
"fps=fps=$frameRate",
"anull",
width,
height,
grabber.audioChannels
).use { filter ->
filter.sampleFormat = grabber.sampleFormat
filter.sampleRate = grabber.sampleRate
filter.pixelFormat = grabber.pixelFormat
filter.frameRate = grabber.frameRate
filter.start()
val videoBitRate = min(fFmpegVideoConfig.maxBitrate, (width * height * frameRate * 1 * 0.07).toInt())
logger.debug("Movie Media BitRate {}", videoBitRate)
FFmpegFrameRecorder(tempFile.toFile(), width, height, grabber.audioChannels).use {
it.sampleRate = grabber.sampleRate
it.format = fFmpegVideoConfig.format
it.videoCodec = fFmpegVideoConfig.videoCodec
it.audioCodec = fFmpegVideoConfig.audioCodec
it.audioChannels = grabber.audioChannels
it.videoQuality = fFmpegVideoConfig.videoQuality
it.frameRate = frameRate.toDouble()
it.setVideoOption("preset", "ultrafast")
it.timestamp = 0
it.gopSize = frameRate
it.videoBitrate = videoBitRate
it.start()
val frameConverter = Java2DFrameConverter()
while (true) {
val grab = grabber.grab() ?: break
if (bufferedImage == null) {
bufferedImage = frameConverter.convert(grab)
}
if (grab.image != null || grab.samples != null) {
filter.push(grab)
}
while (true) {
val frame = filter.pull() ?: break
it.record(frame)
}
}
if (bufferedImage != null) {
ImageIO.write(bufferedImage, "jpeg", thumbnailFile.toFile())
}
}
}
}
logger.info("SUCCESS Convert Movie Media {}", filename)
return ProcessedMedia(
tempFile,
thumbnailFile,
FileType.Video,
MimeType("video", fFmpegVideoConfig.format, FileType.Video),
bufferedImage?.let { generateBlurhash.generateBlurhash(it) }
)
}
companion object {
private val logger = LoggerFactory.getLogger(FFmpegVideoProcessor::class.java)
}
}

View File

@ -215,7 +215,7 @@ private fun toStatus(it: ResultRow) = Status(
followingCount = it[Actors.followingCount],
noindex = false,
moved = false,
suspendex = false,
suspended = false,
limited = false
),
content = it[Posts.text],