Last active
February 3, 2026 20:20
-
-
Save dacr/96450a3133218767e833cd490d180dcf to your computer and use it in GitHub Desktop.
Make videos from photos coming from several cameras, one video by camera and by day - WORK IN PROGRESS / published by https://github.com/dacr/code-examples-manager #4fc4bbfd-778c-43c1-b13d-8f8f8c837024/910688c5eb52a9060daea98148a50a9843420354
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // summary : Make videos from photos coming from several cameras, one video by camera and by day - WORK IN PROGRESS | |
| // keywords : scala, photos, videos, zio, ziostream, TBC | |
| // publish : gist | |
| // authors : David Crosson | |
| // license : Apache License Version 2.0 (https://www.apache.org/licenses/LICENSE-2.0.txt) | |
| // id : 4fc4bbfd-778c-43c1-b13d-8f8f8c837024 | |
| // created-on : 2022-09-02T07:26:13+02:00 | |
| //// attachments: 20220902-102506-00-raspcam1-evt.jpg, 20220902-102507-00-raspcam1-evt.jpg, 20220902-102507-01-raspcam1-evt.jpg | |
| // managed-by : https://github.com/dacr/code-examples-manager | |
| // attached-files | |
| // run-with : scala-cli $file | |
| /* | |
| Chosen solution : | |
| - use ffmpeg | |
| - build ordered daily file list | |
| - execute ffmpeg -r 2 -crf 25 -f concat -i 20220901-files-list.txt 20220901.mp4 | |
| */ | |
| /* | |
| Coonfiguration through those environment variables : | |
| - PHOTOS_TO_VIDEOS_PATH | |
| - PHOTOS_TO_VIDEOS_DELETE | |
| - PHOTOS_TO_VIDEOS_FILE_MASK | |
| - PHOTOS_TO_VIDEOS_TIMESTAMP_FORMAT | |
| - PHOTOS_TO_VIDEOS_OUTPUT | |
| */ | |
| // --------------------- | |
| //> using scala "3.4.2" | |
| //> using dep "dev.zio::zio:2.0.3" | |
| //> using dep "dev.zio::zio-streams:2.0.3" | |
| //> using dep "dev.zio::zio-nio:2.0.0" | |
| //> using dep "dev.zio::zio-process:0.7.1" | |
| // --------------------- | |
| import zio.* | |
| import zio.stream.* | |
| import zio.nio.file.* | |
| import java.time.format.DateTimeFormatter | |
| import java.time.LocalDateTime | |
| import scala.util.matching.Regex | |
| import zio.process.* | |
| object Photos extends ZIOAppDefault { | |
| case class Config( | |
| directory: String, | |
| delete: Boolean, | |
| fileMask: Regex, | |
| timestampFormat: DateTimeFormatter, | |
| outputVideoFile: String | |
| ) { | |
| val path = Path(directory) | |
| } | |
| case class PhotoGroup(origin: String, timestampGrouping: String) | |
| case class Photo( | |
| timestamp: LocalDateTime, // timestamp of the photo | |
| origin: String, // photo origin (typically camera identifier) | |
| file: String // full filename | |
| ) { | |
| val path = Path(file) | |
| } | |
| def parseTimestamp(timestamp: String): ZIO[Config, Throwable, LocalDateTime] = | |
| for { | |
| config <- ZIO.service[Config] | |
| parsed <- ZIO.attempt(config.timestampFormat.parse(timestamp)) | |
| dateTime <- ZIO.attempt(parsed.query(LocalDateTime.from)) | |
| } yield dateTime | |
| def extractTimestampGroup(dateTime: LocalDateTime): String = { | |
| DateTimeFormatter.ofPattern("yyyy-MM-dd").format(dateTime) | |
| } | |
| def makeVideo(group: PhotoGroup, photos: Chunk[Photo]) = | |
| for { | |
| config <- ZIO.service[Config] | |
| inputPhotoListPath <- Files.createTempFileScoped(suffix = ".txt", prefix = Some(s"${group.origin}-${group.timestampGrouping}-")) | |
| // inputPhotoListPath = Path(s"${group.origin}-${group.timestampGrouping}.txt") | |
| _ <- Files.writeLines(inputPhotoListPath, photos.map(p => s"file '${p.path}''")) | |
| outputFilename = config.outputVideoFile.replace("{{group}}", group.origin).replace("{{timestamp}}", group.timestampGrouping) | |
| outputPath = Path(config.directory, outputFilename) | |
| command = Command("/usr/bin/ffmpeg", "-hide_banner", "-y", "-f", "concat", "-safe", "0", "-i", inputPhotoListPath.toString, "-r", "2", "-crf", "25", outputPath.toString) | |
| results <- command.lines | |
| today <- Clock.localDateTime | |
| todayTimeStampGrouping = extractTimestampGroup(today) | |
| _ <- if (group.timestampGrouping != todayTimeStampGrouping && config.delete) | |
| ZIO.foreach(photos)(photo => Files.delete(photo.path)) | |
| else ZIO.succeed(Nil) | |
| _ <- ZIO.log(s"Command : ${command.command.mkString(" ")}\nExecution results : ${results.mkString("\n")}") | |
| } yield () | |
| def makeVideos(groupedPhotos: Map[PhotoGroup, Chunk[Photo]]) = | |
| ZIO.foreachDiscard(groupedPhotos)((group, photos) => makeVideo(group, photos)) | |
| val inventory = | |
| for { | |
| config <- ZIO.service[Config] | |
| _ <- ZIO.log(s"scanning ${config.path}") | |
| mask = config.fileMask | |
| photos <- Files | |
| .list(config.path) | |
| .map(_.toString) | |
| .filterNot(_.contains("Trash")) | |
| // .collectZIO { case file @ mask(timestamp) => parseTimestamp(timestamp).map(ts => Photo(ts, group, file)) } // https://github.com/zio/zio/issues/7301 | |
| .mapZIO { | |
| case file @ mask(timestamp, group) => parseTimestamp(timestamp).map(ts => Some(Photo(ts, group, file))) | |
| case _ => ZIO.succeed(None) | |
| } | |
| .collect { case Some(photo) => photo } | |
| .tap(f => ZIO.log(s"found $f")) | |
| .runCollect | |
| grouped = photos | |
| .groupBy(photo => PhotoGroup(photo.origin, extractTimestampGroup(photo.timestamp))) | |
| .map { case (group, photos) => group -> photos.sortBy(_.timestamp) } | |
| _ <- makeVideos(grouped) | |
| } yield () | |
| def run = for { | |
| path <- System.envOrElse("PHOTOS_TO_VIDEOS_PATH", ".") | |
| delete <- System.envOrElse("PHOTOS_TO_VIDEOS_DELETE", "true").mapAttempt(f => f.toBoolean) | |
| mask <- System.envOrElse("PHOTOS_TO_VIDEOS_FILE_MASK", """.*/(\d{8}-\d{6})-\d{2}-(.*)-evt.jpg""").mapAttempt(m => m.r) | |
| format <- System.envOrElse("PHOTOS_TO_VIDEOS_TIMESTAMP_FORMAT", "yyyyMMdd-HHmmss").mapAttempt(tf => DateTimeFormatter.ofPattern(tf)) | |
| output <- System.envOrElse("PHOTOS_TO_VIDEOS_OUTPUT", "{{group}}-{{timestamp}}.mp4") | |
| config = Config(path, delete, mask, format, output) | |
| _ <- inventory.provideSomeLayer(ZLayer.succeed(config)) | |
| } yield () | |
| } | |
| Photos.main(Array.empty) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment