Skip to content

Commit 3f81b43

Browse files
committed
Merge branch 'tekniksprint-mark' into 'master'
Thumbnail improvements See merge request videocore/encore!146
2 parents 8a52da6 + 0576479 commit 3f81b43

File tree

13 files changed

+139
-195
lines changed

13 files changed

+139
-195
lines changed

src/main/kotlin/se/svt/oss/encore/model/output/Output.kt

-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ data class Output(
1313
val format: String = "mp4",
1414
val postProcessor: PostProcessor = PostProcessor { outputFolder -> listOf(outputFolder.resolve(output)) },
1515
val id: String,
16-
val seekable: Boolean = true
1716
)
1817

1918
fun interface PostProcessor {

src/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailEncode.kt

+45-34
Original file line numberDiff line numberDiff line change
@@ -8,76 +8,87 @@ import mu.KotlinLogging
88
import se.svt.oss.encore.config.EncodingProperties
99
import se.svt.oss.encore.model.EncoreJob
1010
import se.svt.oss.encore.model.input.DEFAULT_VIDEO_LABEL
11+
import se.svt.oss.encore.model.input.VideoIn
1112
import se.svt.oss.encore.model.input.videoInput
1213
import se.svt.oss.encore.model.mediafile.toParams
1314
import se.svt.oss.encore.model.output.Output
1415
import se.svt.oss.encore.model.output.VideoStreamEncode
15-
import se.svt.oss.mediaanalyzer.file.toFractionOrNull
16-
import kotlin.math.round
1716

1817
data class ThumbnailEncode(
1918
val percentages: List<Int> = listOf(25, 50, 75),
2019
val thumbnailWidth: Int = -2,
2120
val thumbnailHeight: Int = 1080,
2221
val quality: Int = 5,
2322
val suffix: String = "_thumb",
23+
val suffixZeroPad: Int = 2,
2424
val inputLabel: String = DEFAULT_VIDEO_LABEL,
25-
val optional: Boolean = false
25+
val optional: Boolean = false,
26+
val intervalSeconds: Double? = null
2627
) : OutputProducer {
2728

2829
private val log = KotlinLogging.logger { }
2930

3031
override fun getOutput(job: EncoreJob, encodingProperties: EncodingProperties): Output? {
3132
val videoInput = job.inputs.videoInput(inputLabel)
32-
val inputSeekTo = videoInput?.seekTo
33-
val videoStream = videoInput?.analyzedVideo?.highestBitrateVideoStream
3433
?: return logOrThrow("Can not produce thumbnail $suffix. No video input with label $inputLabel!")
35-
36-
val frameRate = videoStream.frameRate.toFractionOrNull()?.toDouble()
37-
?: if (job.duration != null || job.seekTo != null || job.thumbnailTime != null || inputSeekTo != null) {
38-
return logOrThrow("Can not produce thumbnail $suffix! No framerate detected in video input $inputLabel.")
39-
} else {
40-
0.0
41-
}
42-
43-
val numFrames = job.duration?.let { round(it * frameRate).toInt() } ?: ((videoStream.numFrames) - (inputSeekTo?.let { round(it * frameRate).toInt() } ?: 0))
44-
val skipFrames = job.seekTo?.let { round(it * frameRate).toInt() } ?: 0
45-
val frames = job.thumbnailTime?.let {
46-
listOf(round((it - (inputSeekTo ?: 0.0)) * frameRate).toInt())
47-
} ?: percentages.map {
48-
(it * numFrames) / 100 + skipFrames
34+
val thumbnailTime = job.thumbnailTime?.let { time ->
35+
videoInput.seekTo?.let { time - it } ?: time
36+
}
37+
val select = when {
38+
thumbnailTime != null -> selectTimes(listOf(thumbnailTime))
39+
intervalSeconds != null -> selectInterval(intervalSeconds, job.seekTo)
40+
outputDuration(videoInput, job) <= 0 -> return logOrThrow("Can not produce thumbnail $suffix. Could not detect duration.")
41+
percentages.isNotEmpty() -> selectTimes(percentagesToTimes(videoInput, job))
42+
else -> return logOrThrow("Can not produce thumbnail $suffix. No times selected.")
4943
}
5044

51-
log.debug { "Thumbnail encode inputs: thumbnailTime= ${job.thumbnailTime}, framerate=$frameRate, duration= ${job.duration}, numFrames = $numFrames, skipFrames = $skipFrames. Resulting frames = $frames" }
52-
53-
val filter = frames.joinToString(
54-
separator = "+",
55-
prefix = "select=",
56-
postfix = ",scale=$thumbnailWidth:$thumbnailHeight"
57-
) { "eq(n\\,$it)" }
58-
59-
val fileRegex = Regex("${job.baseName}$suffix\\d{2}\\.jpg")
45+
val filter = "$select,scale=w=$thumbnailWidth:h=$thumbnailHeight:out_range=jpeg"
6046
val params = linkedMapOf(
61-
"frames:v" to "${frames.size}",
62-
"vsync" to "vfr",
47+
"fps_mode" to "vfr",
6348
"q:v" to "$quality"
6449
)
6550

51+
val fileRegex = Regex("${job.baseName}$suffix\\d{$suffixZeroPad}\\.jpg")
52+
6653
return Output(
67-
id = "${suffix}02d.jpg",
54+
id = "${suffix}0${suffixZeroPad}d.jpg",
6855
video = VideoStreamEncode(
6956
params = params.toParams(),
7057
filter = filter,
7158
inputLabels = listOf(inputLabel)
7259
),
73-
output = "${job.baseName}$suffix%02d.jpg",
60+
output = "${job.baseName}$suffix%0${suffixZeroPad}d.jpg",
7461
postProcessor = { outputFolder ->
7562
outputFolder.listFiles().orEmpty().filter { it.name.matches(fileRegex) }
76-
},
77-
seekable = false
63+
}
7864
)
7965
}
8066

67+
private fun selectInterval(interval: Double, outputSeek: Double?): String {
68+
val select = outputSeek
69+
?.let { "gte(t\\,$it)*(isnan(prev_selected_t)+gt(floor((t-$it)/$interval)\\,floor((prev_selected_t-$it)/$interval)))" }
70+
?: "isnan(prev_selected_t)+gt(floor(t/$interval)\\,floor(prev_selected_t/$interval))"
71+
return "select=$select"
72+
}
73+
74+
private fun outputDuration(videoIn: VideoIn, job: EncoreJob): Double {
75+
val videoStream = videoIn.analyzedVideo.highestBitrateVideoStream
76+
var inputDuration = videoStream.duration
77+
videoIn.seekTo?.let { inputDuration -= it }
78+
job.seekTo?.let { inputDuration -= it }
79+
return job.duration ?: inputDuration
80+
}
81+
82+
private fun percentagesToTimes(videoIn: VideoIn, job: EncoreJob): List<Double> {
83+
val outputDuration = outputDuration(videoIn, job)
84+
return percentages
85+
.map { it * outputDuration / 100 }
86+
.map { t -> job.seekTo?.let { t + it } ?: t }
87+
}
88+
89+
private fun selectTimes(times: List<Double>) =
90+
"select=${times.joinToString("+") { "lt(prev_pts*TB\\,$it)*gte(pts*TB\\,$it)" }}"
91+
8192
private fun logOrThrow(message: String): Output? {
8293
if (optional) {
8394
log.info { message }

src/main/kotlin/se/svt/oss/encore/model/profile/ThumbnailMapEncode.kt

+28-28
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,11 @@ import se.svt.oss.encore.model.EncoreJob
1111
import se.svt.oss.encore.model.input.DEFAULT_VIDEO_LABEL
1212
import se.svt.oss.encore.model.input.analyzedVideo
1313
import se.svt.oss.encore.model.input.videoInput
14+
import se.svt.oss.encore.model.mediafile.toParams
1415
import se.svt.oss.encore.model.output.Output
1516
import se.svt.oss.encore.model.output.VideoStreamEncode
1617
import se.svt.oss.mediaanalyzer.file.stringValue
17-
import se.svt.oss.mediaanalyzer.file.toFractionOrNull
1818
import kotlin.io.path.createTempDirectory
19-
import kotlin.math.round
2019

2120
data class ThumbnailMapEncode(
2221
val tileWidth: Int = 160,
@@ -37,48 +36,49 @@ data class ThumbnailMapEncode(
3736
val videoStream = job.inputs.analyzedVideo(inputLabel)?.highestBitrateVideoStream
3837
?: return logOrThrow("No input with label $inputLabel!")
3938

40-
var numFrames = videoStream.numFrames
41-
val duration = job.duration
39+
var inputDuration = videoStream.duration
40+
inputSeekTo?.let { inputDuration -= it }
41+
job.seekTo?.let { inputDuration -= it }
42+
val outputDuration = job.duration ?: inputDuration
4243

43-
if (job.duration != null || job.seekTo != null || inputSeekTo != null) {
44-
val frameRate = videoStream.frameRate.toFractionOrNull()?.toDouble()
45-
?: return logOrThrow("Can not generate thumbnail map $suffix! No framerate detected in video input $inputLabel.")
46-
if (duration != null) {
47-
numFrames = round(duration * frameRate).toInt()
48-
} else {
49-
job.seekTo?.let { numFrames -= round(it * frameRate).toInt() }
50-
inputSeekTo?.let { numFrames -= round(it * frameRate).toInt() }
51-
}
44+
if (outputDuration <= 0) {
45+
return logOrThrow("Cannot create thumbnail map $suffix! Could not detect duration.")
5246
}
5347

54-
if (numFrames < cols * rows) {
55-
val message =
56-
"Video input $inputLabel did not contain enough frames to generate thumbnail map $suffix: $numFrames < $cols cols * $rows rows"
57-
return logOrThrow(message)
58-
}
48+
val interval = outputDuration / (cols * rows)
49+
val select = job.seekTo
50+
?.let { "gte(t\\,$it)*(isnan(prev_selected_t)+gt(floor((t-$it)/$interval)\\,floor((prev_selected_t-$it)/$interval)))" }
51+
?: "isnan(prev_selected_t)+gt(floor(t/$interval)\\,floor(prev_selected_t/$interval))"
52+
5953
val tempFolder = createTempDirectory(suffix).toFile()
6054
tempFolder.deleteOnExit()
61-
val pad =
62-
"aspect=${Fraction(tileWidth, tileHeight).stringValue()}:x=(ow-iw)/2:y=(oh-ih)/2" // pad to aspect ratio
63-
val nthFrame = numFrames / (cols * rows)
64-
var select = "not(mod(n\\,$nthFrame))"
65-
job.seekTo?.let { select += "*gte(t\\,$it)" }
55+
56+
val pad = "aspect=${Fraction(tileWidth, tileHeight).stringValue()}:x=(ow-iw)/2:y=(oh-ih)/2"
57+
58+
val scale = if (format == "jpg") {
59+
"-1:$tileHeight:out_range=jpeg"
60+
} else {
61+
"-1:$tileHeight"
62+
}
63+
val params = linkedMapOf(
64+
"q:v" to "5",
65+
"fps_mode" to "vfr"
66+
)
6667
return Output(
6768
id = "$suffix.$format",
6869
video = VideoStreamEncode(
69-
params = listOf("-q:v", "5"),
70-
filter = "select=$select,pad=$pad,scale=-1:$tileHeight",
70+
params = params.toParams(),
71+
filter = "select=$select,pad=$pad,scale=$scale",
7172
inputLabels = listOf(inputLabel)
7273
),
73-
output = tempFolder.resolve("${job.baseName}$suffix%03d.$format").toString(),
74-
seekable = false,
74+
output = tempFolder.resolve("${job.baseName}$suffix%04d.$format").toString(),
7575
postProcessor = { outputFolder ->
7676
try {
7777
val targetFile = outputFolder.resolve("${job.baseName}$suffix.$format")
7878
val process = ProcessBuilder(
7979
"ffmpeg",
8080
"-i",
81-
"${job.baseName}$suffix%03d.$format",
81+
"${job.baseName}$suffix%04d.$format",
8282
"-vf",
8383
"tile=${cols}x$rows",
8484
"-frames:v",

src/main/kotlin/se/svt/oss/encore/process/CommandBuilder.kt

+7-13
Original file line numberDiff line numberDiff line change
@@ -219,16 +219,16 @@ class CommandBuilder(
219219
return emptyList()
220220
}
221221
return listOf("-map", MapName.VIDEO.mapLabel(output.id)) +
222-
seekParams(output) +
222+
seekParams() +
223223
"-an" +
224-
durationParams(output) +
224+
durationParams() +
225225
output.video.firstPassParams +
226226
listOf("-f", output.format, "/dev/null")
227227
}
228228

229229
private fun secondPassParams(output: Output): List<String> {
230230
val mapV: List<String> =
231-
output.video?.let { listOf("-map", MapName.VIDEO.mapLabel(output.id)) + seekParams(output) }
231+
output.video?.let { listOf("-map", MapName.VIDEO.mapLabel(output.id)) + seekParams() }
232232
?: emptyList()
233233

234234
val preserveAudioLayout = output.audioStreams.any { it.preserveLayout }
@@ -243,7 +243,7 @@ class CommandBuilder(
243243
} else {
244244
MapName.AUDIO.mapLabel("${output.id}-$index")
245245
}
246-
listOf("-map", mapLabel) + seekParams(output)
246+
listOf("-map", mapLabel) + seekParams()
247247
}
248248

249249
val maps = mapV + mapA
@@ -261,23 +261,17 @@ class CommandBuilder(
261261
val metaDataParams = listOf("-metadata", "comment=Transcoded using Encore")
262262

263263
return maps +
264-
durationParams(output) +
264+
durationParams() +
265265
videoParams + audioParams +
266266
metaDataParams +
267267
File(outputFolder).resolve(output.output).toString()
268268
}
269269

270-
private fun seekParams(output: Output): List<String> = if (!output.seekable) {
271-
emptyList()
272-
} else {
270+
private fun seekParams(): List<String> =
273271
encoreJob.seekTo?.let { listOf("-ss", "$it") } ?: emptyList()
274-
}
275272

276-
private fun durationParams(output: Output): List<String> = if (!output.seekable) {
277-
emptyList()
278-
} else {
273+
private fun durationParams(): List<String> =
279274
encoreJob.duration?.let { listOf("-t", "$it") } ?: emptyList()
280-
}
281275

282276
private enum class MapName {
283277
VIDEO,

src/test/kotlin/se/svt/oss/encore/model/profile/AudioEncodeTest.kt

-1
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@ class AudioEncodeTest {
5555
)
5656
assertThat(output)
5757
.hasOutput("test_aac_stereo.mp4")
58-
.hasSeekable(true)
5958
.hasVideo(null)
6059
.hasId("_aac_stereo.mp4")
6160
.hasOnlyAudioStreams(

0 commit comments

Comments
 (0)