diff --git a/src/main/scala/com/github/windymelt/zmm/ChromiumCli.scala b/src/main/scala/com/github/windymelt/zmm/ChromiumCli.scala index 0d8b3aa..25fde8c 100644 --- a/src/main/scala/com/github/windymelt/zmm/ChromiumCli.scala +++ b/src/main/scala/com/github/windymelt/zmm/ChromiumCli.scala @@ -2,6 +2,7 @@ package com.github.windymelt.zmm import cats.effect.IO import cats.effect.kernel.Resource +import cats.effect.std.Mutex import cats.implicits._ class ChromiumCli extends Cli with infrastructure.ChromeScreenShotComponent { @@ -9,17 +10,17 @@ class ChromiumCli extends Cli with infrastructure.ChromeScreenShotComponent { sys.env.get("CHROMIUM_CMD").getOrElse(config.getString("chromium.command")) def screenShotResource: IO[Resource[IO, ScreenShot]] = { - IO.println( - s"""[configuration] chromium command: ${chromiumCommand}""" - ) >> Resource - .eval( - new ChromeScreenShot( - chromiumCommand, - ChromeScreenShot.Quiet, - chromiumNoSandBox - ).pure[IO] + for { + _ <- IO.println( + s"""[configuration] chromium command: ${chromiumCommand}""" ) - .pure[IO] + mu <- Mutex[IO] + } yield mu.lock.map { _ => + new ChromeScreenShot( + chromiumCommand, + ChromeScreenShot.Quiet, + chromiumNoSandBox + ) + } } - } diff --git a/src/main/scala/com/github/windymelt/zmm/Cli.scala b/src/main/scala/com/github/windymelt/zmm/Cli.scala index a28dab6..859cd6d 100644 --- a/src/main/scala/com/github/windymelt/zmm/Cli.scala +++ b/src/main/scala/com/github/windymelt/zmm/Cli.scala @@ -1,14 +1,15 @@ package com.github.windymelt.zmm +import cats.effect.ExitCode import cats.effect.IO import cats.effect.IOApp -import cats.effect.ExitCode -import java.io.OutputStream -import org.http4s.syntax.header -import com.github.windymelt.zmm.domain.model.Context -import scala.concurrent.duration.FiniteDuration import cats.effect.std.Mutex +import com.github.windymelt.zmm.domain.model.Context import com.github.windymelt.zmm.domain.model.VoiceBackendConfig +import org.http4s.syntax.header + +import java.io.OutputStream +import scala.concurrent.duration.FiniteDuration trait Cli extends domain.repository.FFmpegComponent @@ -29,7 +30,10 @@ trait Cli sys.env.get("VOICEVOX_URI") getOrElse config.getString("voicevox.apiUri") def voiceVox: VoiceVox = new ConcreteVoiceVox(voiceVoxUri) def ffmpeg = - new ConcreteFFmpeg(config.getString("ffmpeg.command"), ConcreteFFmpeg.Quiet) + new ConcreteFFmpeg( + config.getString("ffmpeg.command"), + ConcreteFFmpeg.Quiet + ) // TODO: respect construct parameter val chromiumNoSandBox = sys.env .get("CHROMIUM_NOSANDBOX") .map(_ == "1") @@ -161,6 +165,32 @@ trait Cli zippedVideo <- backgroundIndicator("Zipping silent video and audio").use { _ => ffmpeg.zipVideoWithAudio(video, audio) } + composedVideo <- backgroundIndicator("Composing Video").surround { + // もし設定されていればビデオを合成する。BGMと同様、同じビデオであれば結合する。 + val videoWithDuration: Seq[(Option[os.Path], FiniteDuration)] = + sayCtxPairs + .map(p => p._2.video.map(os.pwd / os.RelPath(_))) + .zip(pathAndDurations.map(_._2)) + + val reductedVideoWithDuration = groupReduction(videoWithDuration) + + // 環境によっては上書きに失敗する?ので出力ファイルが存在する場合削除する + val outputFile = os.pwd / "output_composed.mp4" + os.remove(outputFile, checkExists = false) + + reductedVideoWithDuration.filter(_._1.isDefined).size match { + case 0 => + IO.delay { + os.move(zippedVideo, outputFile) + outputFile + } + case _ => + ffmpeg.composeVideoWithDuration( + zippedVideo, + reductedVideoWithDuration + ) + } + } _ <- backgroundIndicator("Applying BGM").use { _ => // BGMを合成する。BGMはコンテキストで割り当てる。sayCtxPairsでsayごとにコンテキストが確定するので、同じBGMであれば結合しつつ最終的なDurationを計算する。 // たとえば、BGMa 5sec BGMa 5sec BGMb 10sec であるときは、 BGMa 10sec BGMb 10secに簡約される。 @@ -178,11 +208,11 @@ trait Cli reductedBgmWithDuration.filter(_._1.isDefined).size match { case 0 => IO.pure( - os.move(zippedVideo, outputFile) + os.move(composedVideo, outputFile) ) // Dirty fix. TODO: fix here case _ => ffmpeg.zipVideoWithAudioWithDuration( - zippedVideo, + composedVideo, reductedBgmWithDuration ) } diff --git a/src/main/scala/com/github/windymelt/zmm/Main.scala b/src/main/scala/com/github/windymelt/zmm/Main.scala index cd04c31..cc96fdc 100644 --- a/src/main/scala/com/github/windymelt/zmm/Main.scala +++ b/src/main/scala/com/github/windymelt/zmm/Main.scala @@ -1,12 +1,13 @@ package com.github.windymelt.zmm +import cats.effect.ExitCode import cats.effect.IO import cats.effect.IOApp -import cats.effect.ExitCode -import java.io.OutputStream -import org.http4s.syntax.header import com.monovore.decline.Opts import com.monovore.decline.effect.CommandIOApp +import org.http4s.syntax.header + +import java.io.OutputStream object Main extends CommandIOApp( @@ -30,6 +31,7 @@ object Main } case TargetFile(file, screenShotBackend) => val cli = screenShotBackend match { + // TODO: ffmpeg verbosityをcli opsから設定可能にする case Some(ScreenShotBackend.Chrome) => new ChromiumCli() case Some(ScreenShotBackend.Firefox) => new FirefoxCli() case _ => defaultCli diff --git a/src/main/scala/com/github/windymelt/zmm/domain/model/Context.scala b/src/main/scala/com/github/windymelt/zmm/domain/model/Context.scala index f1b7387..747d2b9 100644 --- a/src/main/scala/com/github/windymelt/zmm/domain/model/Context.scala +++ b/src/main/scala/com/github/windymelt/zmm/domain/model/Context.scala @@ -40,7 +40,8 @@ final case class Context( Map.empty, // id -> (code, lang?) maths: Map[String, String] = Map.empty, // id -> LaTeX string sic: Option[String] = None, // 代替読みを設定できる(数式などで使う) - silentLength: Option[FiniteDuration] = None // by=silentな場合に停止する時間 + silentLength: Option[FiniteDuration] = None, // by=silentな場合に停止する時間 + video: Option[String] = None // 背景に合成する動画 // TODO: BGM, fontColor, etc. ) { def atv = additionalTemplateVariables // alias for template @@ -87,7 +88,8 @@ object Context { x.codes |+| y.codes, // Map の Monoid性を応用すると、同一idで書かれたコードは結合されるという好ましい特性が表われるのでこうしている。additionalTemplateVariablesに畳んでもいいかもしれない。現在のコードはadditionalTemplateVariablesに入れている maths = x.maths |+| y.maths, sic = y.sic orElse x.sic, - silentLength = y.silentLength <+> x.silentLength + silentLength = y.silentLength <+> x.silentLength, + video = y.video <+> x.video ) } def empty: Context = Context.empty @@ -130,7 +132,8 @@ object Context { sic = firstAttrTextOf(e, "sic"), silentLength = firstAttrTextOf(e, "silent-length").map(l => FiniteDuration.apply(Integer.parseInt(l), "second") - ) + ), + video = firstAttrTextOf(e, "video") ) } diff --git a/src/main/scala/com/github/windymelt/zmm/domain/repository/FFmpeg.scala b/src/main/scala/com/github/windymelt/zmm/domain/repository/FFmpeg.scala index 5a14b7f..e914037 100644 --- a/src/main/scala/com/github/windymelt/zmm/domain/repository/FFmpeg.scala +++ b/src/main/scala/com/github/windymelt/zmm/domain/repository/FFmpeg.scala @@ -18,6 +18,10 @@ trait FFmpegComponent { videoPath: os.Path, audioDurationPair: Seq[(Option[os.Path], FiniteDuration)] ): IO[os.Path] + def composeVideoWithDuration( + baseVideoPath: os.Path, + overlayVideoDurationPair: Seq[(Option[os.Path], FiniteDuration)] + ): IO[os.Path] def zipVideoWithAudio(videoPath: os.Path, audioPath: os.Path): IO[os.Path] def generateSilentWav(path: os.Path, length: FiniteDuration): IO[os.Path] } diff --git a/src/main/scala/com/github/windymelt/zmm/domain/repository/ScreenShot.scala b/src/main/scala/com/github/windymelt/zmm/domain/repository/ScreenShot.scala index a4539fa..6ae111e 100644 --- a/src/main/scala/com/github/windymelt/zmm/domain/repository/ScreenShot.scala +++ b/src/main/scala/com/github/windymelt/zmm/domain/repository/ScreenShot.scala @@ -12,5 +12,9 @@ trait ScreenShotComponent { windowWidth: Int = 1920, windowHeight: Int = 1080 ): IO[os.Path] + + /** ユーザの入力によってスクリーンショット実装が切り替わるので、それを内部で判別できるようにするための識別子。 + */ + val screenShotImplementation: String } } diff --git a/src/main/scala/com/github/windymelt/zmm/infrastructure/ChromeScreenShot.scala b/src/main/scala/com/github/windymelt/zmm/infrastructure/ChromeScreenShot.scala index 0c943a0..8da0845 100644 --- a/src/main/scala/com/github/windymelt/zmm/infrastructure/ChromeScreenShot.scala +++ b/src/main/scala/com/github/windymelt/zmm/infrastructure/ChromeScreenShot.scala @@ -2,8 +2,8 @@ package com.github.windymelt.zmm package infrastructure import cats.effect.IO -import cats.implicits._ import cats.effect.kernel.Resource +import cats.implicits._ trait ChromeScreenShotComponent { self: domain.repository.ScreenShotComponent => @@ -21,6 +21,7 @@ trait ChromeScreenShotComponent { verbosity: ChromeScreenShot.Verbosity, noSandBox: Boolean = false ) extends ScreenShot { + val screenShotImplementation = "chrome" val stdout = verbosity match { case ChromeScreenShot.Quiet => os.Pipe case ChromeScreenShot.Verbose => os.Inherit @@ -34,18 +35,22 @@ trait ChromeScreenShotComponent { case true => os.proc( chromeCommand, - "--headless", + "--headless=new", "--no-sandbox", + "--hide-scrollbars", s"--screenshot=${htmlFilePath}.png", s"--window-size=${windowWidth},${windowHeight}", + "--default-background-color=00000000", htmlFilePath ) case false => os.proc( chromeCommand, - "--headless", + "--headless=new", + "--hide-scrollbars", s"--screenshot=${htmlFilePath}.png", s"--window-size=${windowWidth},${windowHeight}", + "--default-background-color=00000000", htmlFilePath ) } diff --git a/src/main/scala/com/github/windymelt/zmm/infrastructure/FFmpeg.scala b/src/main/scala/com/github/windymelt/zmm/infrastructure/FFmpeg.scala index 63fcc5b..08e2ade1 100644 --- a/src/main/scala/com/github/windymelt/zmm/infrastructure/FFmpeg.scala +++ b/src/main/scala/com/github/windymelt/zmm/infrastructure/FFmpeg.scala @@ -2,6 +2,8 @@ package com.github.windymelt.zmm package infrastructure import cats.effect.IO +import os.Path + import scala.concurrent.duration.FiniteDuration trait FFmpegComponent { @@ -19,6 +21,7 @@ trait FFmpegComponent { ffmpegCommand: String, verbosity: ConcreteFFmpeg.Verbosity ) extends FFmpeg { + val FRAME_RATE_FPS = 30 // TODO: application.confなどに逃がす val stdout = verbosity match { case ConcreteFFmpeg.Quiet => os.Pipe case ConcreteFFmpeg.Verbose => os.Inherit @@ -46,6 +49,8 @@ trait FFmpegComponent { "fileList.txt", "-c", "copy", + "-ac", // ステレオ化する + "2", "artifacts/concatenated.wav" ) .call( @@ -102,6 +107,10 @@ trait FFmpegComponent { "./artifacts/cutFile.txt" ) } + // スクリーンショット実装がChrome(Chromium)の場合、透明度付きPNGが出力されることを期待して良いので、 + // PNGをレンダした結果と、PNGのアルファチャンネルを取り出した情報とを2つのストリームとしてMKV形式に格納する。 + // MKVは複数ストリームを格納できる動画コンテナなのでこのようなことが可能。 + // スクリーンショット実装がFirefoxの場合、背景を透過したスクリーンショットを撮影する方法がない・・・。 for { _ <- writeCutfile @@ -118,14 +127,21 @@ trait FFmpegComponent { "0", "-i", "artifacts/cutFile.txt", + "-filter_complex", + // FIXME: 現在Chromiumのバグでサイズがおかしくなっているのでscaleしている + "split[img][img2];[img2]alphaextract,scale=1920:1080[alpha];[img]scale=1920:1080[scaledimg]", "-pix_fmt", "yuv420p", "-c:v", "libx264", - "artifacts/scenes.mp4" + "-map", + "[scaledimg]", + "-map", + "[alpha]", + "artifacts/scenes.mkv" ).call(stdout = stdout, stderr = stdout, cwd = os.pwd) } - } yield os.pwd / os.RelPath("artifacts/scenes.mp4") + } yield os.pwd / os.RelPath("artifacts/scenes.mkv") } def zipVideoWithAudioWithDuration( @@ -186,6 +202,181 @@ trait FFmpegComponent { } yield os.pwd / "output_with_bgm.mp4" } + /** キャラクターや字幕などの前景の後ろに背景動画を合成し、MP4ファイルを返す。 + * + * @param overlayVideoPath + * 前景の動画が収められているMKVファイルのパス。 + * @param baseVideoDurationPair + * 背景動画と、それが再生されるべき長さ情報のペアによって構成されたSeq。 + * @return + * 前景と背景を合成したMP4ファイルのパス + */ + def composeVideoWithDuration( + overlayVideoPath: os.Path, + baseVideoDurationPair: Seq[(Option[os.Path], FiniteDuration)] + ): IO[os.Path] = { + import cats.implicits._ + + // 最初の背景動画が開始するまでの時間を計算する。 + val paddingDur: Double = + baseVideoDurationPair + .takeWhile(_._1.isEmpty) + .map(_._2.toUnit(concurrent.duration.SECONDS)) + .combineAll + + // 背景ビデオが開始する地点まで尺をつなぐためのダミー動画を生成する処理(ffmpegで直接やろうとすると複雑になりすぎる)。 + val genPadding: IO[Option[Path]] = + if (paddingDur.isEmpty) IO.pure(None) + else + IO.delay { + os.proc( + ffmpegCommand, + "-protocol_whitelist", + "file", + "-y", + "-t", + paddingDur, + "-filter_complex", + s"smptehdbars=s=1920x1080:d=$paddingDur, fps=$FRAME_RATE_FPS[v];anullsrc=channel_layout=stereo:sample_rate=24000[o]", + "-safe", + "0", + "-map", + "[v]", + "-map", + "[o]", + "artifacts/basePadding.mp4" + ).call(stdout = stdout, stderr = stdout, cwd = os.pwd) + Some(os.pwd / os.RelPath("artifacts/basePadding.mp4")) + } + + // 一度背景ビデオをDurationに従って結合する。そのためのcutfileを生成する処理。 + val writeCutfile: Option[os.Path] => IO[os.Path] = + (pad: Option[os.Path]) => { + val paddingContent = + pad.map(p => s"file $p\noutpoint $paddingDur\n").getOrElse("") + val cutFileContent = baseVideoDurationPair flatMap { + case (pOpt, dur) => + pOpt.map(p => + s"file ${p}\noutpoint ${dur.toUnit(concurrent.duration.SECONDS)}" + ) + } mkString ("\n") + self.writeStreamToFile( + fs2.Stream[IO, Byte]( + (paddingContent ++ cutFileContent).getBytes(): _* + ), + "./artifacts/baseVideoCutFile.txt" + ) >> IO.pure(os.Path("./artifacts/baseVideoCutFile.txt", os.pwd)) + } + + // デバッグモードが有効なとき、タイムコードを一緒に合成するためのフィルタ文字列。 + val timecode = verbosity match { + case ConcreteFFmpeg.Verbose => + s";[outv0]drawtext=fontsize=64:box=1:boxcolor=white@0.5:fontcolor=black:fontfile=Berkeley Mono:timecode='00\\:00\\:00\\:00':r=$FRAME_RATE_FPS:y=main_h-text_h:fontcolor=0xccFFFF[outv]" + case ConcreteFFmpeg.Quiet => "" + } + + // デバッグ時はタイムコードを合成したビデオストリームを使うための分岐。 + val outputVideoStream = verbosity match { + case ConcreteFFmpeg.Verbose => "[outv]" + case ConcreteFFmpeg.Quiet => "[outv0]" + } + + // 背景動画の尺は前景動画の尺と合わせる必要があるので、全体の尺をあらかじめ計算しておく。 + val wholeDurationSec: Double = baseVideoDurationPair + .map(_._2) + .combineAll + .toUnit(concurrent.duration.SECONDS) + + // 背景動画を結合するための処理。 + val combineBaseVideo: Path => IO[Path] = (cutFilePath: os.Path) => + IO.delay { + os.proc( + ffmpegCommand, + "-protocol_whitelist", + "file", + "-y", + "-f", + "concat", + "-safe", + "0", + "-i", + // "artifacts/baseVideoCutFile.txt", + cutFilePath, + "-vf", + s"framerate=$FRAME_RATE_FPS", // こちらは動画なのでfpsではなくframerateフィルタでやや丁寧に処理する + "artifacts/concatenatedBase.mp4" + ).call(stdout = stdout, stderr = stdout, cwd = os.pwd) + os.pwd / os.RelPath("artifacts/concatenatedBase.mp4") + } + + // 入力されるMKVファイルは第2ストリームにアルファチャンネル情報を格納してある。このアルファチャンネル情報を用いて背景動画に対する合成を行う処理。 + val alphaChannelStreamOverlay = (base: os.Path) => + IO.delay { + os.proc( + ffmpegCommand, + "-y", + "-i", + overlayVideoPath, + "-i", + base, + "-filter_complex", + // TODO: overlayVideoPathのFPSが25になっているので30に持ち上げる + // 必ずbase videoはoverlay video以上の長さである必要があるので、何もない画面にbase videoをoverlayすることで長さを揃えてから再度overlayする + s"""nullsrc=s=1920x1080:r=$FRAME_RATE_FPS:d=$wholeDurationSec[nullsrc]; + [0:a][1:a]amix=normalize=0[a]; + [nullsrc][1:v]overlay=x=0:y=0[paddedbase]; + [0:0][0:1]alphamerge[overlayv]; + [paddedbase][overlayv]overlay=x=0:y=0:eof_action=pass:shortest=0:repeatlast=1[outv0]${timecode} + """.stripMargin, + "-map", + outputVideoStream, + "-map", + "[a]", + "-shortest", + "-ac", + "2", + "output_composed.mp4" + ).call(stdout = stdout, stderr = stdout, cwd = os.pwd) + } + // TODO: Firefoxを使うときにこちらを起動する + val colorKeyOverlay = (base: os.Path) => + IO.delay { + os.proc( + ffmpegCommand, + "-y", + "-i", + overlayVideoPath, + "-i", + base, + "-filter_complex", + // 必ずbase videoはoverlay video以上の長さである必要があるので、何もない画面にbase videoをoverlayすることで長さを揃えてから再度overlayする + s"""nullsrc=s=1920x1080:r=$FRAME_RATE_FPS:d=$wholeDurationSec[nullsrc]; + [0:a][1:a]amix=normalize=0[a]; + [nullsrc][1:v]overlay=x=0:y=0[paddedbase]; + [0:v]colorkey=0xFF00FF:0.1:0.5[overlayv]; + [paddedbase][overlayv]overlay=x=0:y=0:eof_action=pass:shortest=0:repeatlast=1[outv0]${timecode} + """.stripMargin, + "-map", + outputVideoStream, + "-map", + "[a]", + "-shortest", + "-ac", + "2", + "output_composed.mp4" + ).call(stdout = stdout, stderr = stdout, cwd = os.pwd) + } + + // 定義した処理を合成する。 + for { + pad <- genPadding + cutFile <- writeCutfile(pad) + base <- combineBaseVideo(cutFile) + // _ <- colorKeyOverlay(base) + _ <- alphaChannelStreamOverlay(base) + } yield os.pwd / "output_composed.mp4" + } + def zipVideoWithAudio(video: os.Path, audio: os.Path): IO[os.Path] = for { _ <- IO.delay { os.proc( @@ -201,10 +392,16 @@ trait FFmpegComponent { "copy", "-c:a", "aac", - "output.mp4" + "-map", + "0:0", + "-map", + "0:1", + "-map", + "1:a", + "output.mkv" ).call(stdout = stdout, stderr = stdout, cwd = os.pwd) } - } yield os.pwd / "output.mp4" + } yield os.pwd / "output.mkv" def generateSilentWav(path: os.Path, length: FiniteDuration): IO[os.Path] = for { diff --git a/src/main/scala/com/github/windymelt/zmm/infrastructure/FirefoxScreenShotComponent.scala b/src/main/scala/com/github/windymelt/zmm/infrastructure/FirefoxScreenShotComponent.scala index 4c5cb59..2476c8b 100644 --- a/src/main/scala/com/github/windymelt/zmm/infrastructure/FirefoxScreenShotComponent.scala +++ b/src/main/scala/com/github/windymelt/zmm/infrastructure/FirefoxScreenShotComponent.scala @@ -2,10 +2,11 @@ package com.github.windymelt.zmm package infrastructure import cats.effect.IO -import cats.implicits._ +import cats.effect.kernel.Resource import cats.effect.std.Mutex +import cats.implicits._ + import scala.concurrent.duration.FiniteDuration -import cats.effect.kernel.Resource trait FirefoxScreenShotComponent { self: domain.repository.ScreenShotComponent => @@ -21,8 +22,8 @@ trait FirefoxScreenShotComponent { class FirefoxScreenShot( firefoxCommand: String, verbosity: FirefoxScreenShot.Verbosity - // mutex: Mutex[IO] // firefox outputs fixed "screenshot.png", so we cannot call it concurrently ) extends ScreenShot { + val screenShotImplementation = "firefox" val stdout = verbosity match { case FirefoxScreenShot.Quiet => os.Pipe case FirefoxScreenShot.Verbose => os.Inherit diff --git a/src/main/twirl/sample.scala.html b/src/main/twirl/sample.scala.html index 7b884de..af7fd8f 100644 --- a/src/main/twirl/sample.scala.html +++ b/src/main/twirl/sample.scala.html @@ -1,7 +1,7 @@ @(serif: String, ctx: com.github.windymelt.zmm.domain.model.Context) @font() = {@ctx.font.getOrElse("sans-serif")} - +