From 83755fdc934ed31b86b8c6641d7eff6e75a45d69 Mon Sep 17 00:00:00 2001 From: Matthieu <24-artectrex@users.noreply.shinice.net> Date: Sat, 22 Oct 2022 22:07:03 +0200 Subject: [PATCH] Stabilization UI and scaffolding --- .../app/postCreation/PostCreationActivity.kt | 175 +++--- .../app/postCreation/PostCreationViewModel.kt | 7 +- .../photoEdit/VideoEditActivity.kt | 46 +- .../app/settings/ThemeColorPreference.kt | 7 +- app/src/main/res/drawable/video_stable.xml | 5 + .../main/res/layout/activity_video_edit.xml | 84 ++- app/src/main/res/values/strings.xml | 3 + build.gradle | 2 +- gradle/verification-metadata.xml | 552 ++++++++++++++++++ 9 files changed, 778 insertions(+), 103 deletions(-) create mode 100644 app/src/main/res/drawable/video_stable.xml diff --git a/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationActivity.kt b/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationActivity.kt index 4a9c3a9d..357ae155 100644 --- a/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationActivity.kt +++ b/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationActivity.kt @@ -127,15 +127,17 @@ class PostCreationActivity : BaseThemedWithoutBarActivity() { uiState.newEncodingJobPosition?.let { position -> uiState.newEncodingJobMuted?.let { muted -> - uiState.newEncodingJobVideoStart?.let { videoStart -> - uiState.newEncodingJobVideoEnd?.let { videoEnd -> + uiState.newEncodingJobVideoStart.let { videoStart -> + uiState.newEncodingJobVideoEnd.let { videoEnd -> uiState.newEncodingJobSpeedIndex?.let { speedIndex -> uiState.newEncodingJobVideoCrop?.let { crop -> - startEncoding(position, muted, - videoStart, videoEnd, - speedIndex, crop - ) - model.encodingStarted() + uiState.newEncodingJobStabilize?.let { stabilize -> + startEncoding(position, muted, + videoStart, videoEnd, + speedIndex, crop, stabilize, + ) + model.encodingStarted() + } } } } @@ -336,7 +338,8 @@ class PostCreationActivity : BaseThemedWithoutBarActivity() { videoStart: Float?, videoEnd: Float?, speedIndex: Int, - crop: VideoEditActivity.RelativeCropPosition + crop: VideoEditActivity.RelativeCropPosition, + stabilize: Float ) { val originalUri = model.getPhotoData().value!![position].imageUri @@ -355,91 +358,107 @@ class PostCreationActivity : BaseThemedWithoutBarActivity() { val mediaInformation: MediaInformation? = FFprobeKit.getMediaInformation(ffmpegCompliantUri(inputUri)).mediaInformation val totalVideoDuration = mediaInformation?.duration?.toFloatOrNull() - val speed = VideoEditActivity.speedChoices[speedIndex] + fun secondPass(){ + val speed = VideoEditActivity.speedChoices[speedIndex] - val mutedString = if(muted || speedIndex != 1) "-an" else null - val startString: List = if(videoStart != null) listOf("-ss", "${videoStart/speed.toFloat()}") else listOf(null, null) + val mutedString = if(muted || speedIndex != 1) "-an" else null + val startString: List = if(videoStart != null) listOf("-ss", "${videoStart/speed.toFloat()}") else listOf(null, null) - val endString: List = if(videoEnd != null) listOf("-to", "${videoEnd/speed.toFloat() - (videoStart ?: 0f)/speed.toFloat()}") else listOf(null, null) + val endString: List = if(videoEnd != null) listOf("-to", "${videoEnd/speed.toFloat() - (videoStart ?: 0f)/speed.toFloat()}") else listOf(null, null) - // iw and ih are variables for the original width and height values, FFmpeg will know them - val cropString = if(crop.notCropped()) "" else "crop=${crop.relativeWidth}*iw:${crop.relativeHeight}*ih:${crop.relativeX}*iw:${crop.relativeY}*ih" - val separator = if(speedIndex != 1 && !crop.notCropped()) "," else "" - val speedString = if(speedIndex != 1) "setpts=PTS/${speed}" else "" + // iw and ih are variables for the original width and height values, FFmpeg will know them + val cropString = if(crop.notCropped()) "" else "crop=${crop.relativeWidth}*iw:${crop.relativeHeight}*ih:${crop.relativeX}*iw:${crop.relativeY}*ih" + val separator = if(speedIndex != 1 && !crop.notCropped()) "," else "" + val speedString = if(speedIndex != 1) "setpts=PTS/${speed}" else "" - val speedAndCropString: List = if(speedIndex!= 1 || !crop.notCropped()) - listOf("-filter:v", speedString + separator + cropString) + val speedAndCropString: List = if(speedIndex!= 1 || !crop.notCropped()) + listOf("-filter:v", speedString + separator + cropString) // Stream copy is not compatible with filter, but when not filtering we can copy the stream without re-encoding else listOf("-c", "copy") - // This should be set when re-encoding is required (otherwise it defaults to mpeg which then doesn't play) - val encodePreset: List = if(speedIndex != 1 && !crop.notCropped()) listOf("-c:v", "libx264", "-preset", "ultrafast") else listOf(null, null, null, null) + // This should be set when re-encoding is required (otherwise it defaults to mpeg which then doesn't play) + val encodePreset: List = if(speedIndex != 1 && !crop.notCropped()) listOf("-c:v", "libx264", "-preset", "ultrafast") else listOf(null, null, null, null) - val session: FFmpegSession = - FFmpegKit.executeWithArgumentsAsync(listOfNotNull( - startString[0], startString[1], - "-i", ffmpegCompliantUri, - speedAndCropString[0], speedAndCropString[1], - endString[0], endString[1], - mutedString, "-y", - encodePreset[0], encodePreset[1], encodePreset[2], encodePreset[3], - outputVideoPath, - ).toTypedArray(), - //val session: FFmpegSession = FFmpegKit.executeAsync("$startString -i $inputSafePath $endString -c:v libvpx-vp9 -c:a copy -an -y $outputVideoPath", - { session -> - val returnCode = session.returnCode - if (ReturnCode.isSuccess(returnCode)) { - fun successResult() { - // Hide progress indicator in carousel - binding.carousel.updateProgress(null, position, false) - val (imageSize, _) = outputVideoPath.toUri().let { - model.setUriAtPosition(it, position) - model.getSizeAndVideoValidate(it, position) + val session: FFmpegSession = + FFmpegKit.executeWithArgumentsAsync(listOfNotNull( + startString[0], startString[1], + "-i", ffmpegCompliantUri, + speedAndCropString[0], speedAndCropString[1], + endString[0], endString[1], + mutedString, "-y", + encodePreset[0], encodePreset[1], encodePreset[2], encodePreset[3], + outputVideoPath, + ).toTypedArray(), + //val session: FFmpegSession = FFmpegKit.executeAsync("$startString -i $inputSafePath $endString -c:v libvpx-vp9 -c:a copy -an -y $outputVideoPath", + { session -> + val returnCode = session.returnCode + if (ReturnCode.isSuccess(returnCode)) { + fun successResult() { + // Hide progress indicator in carousel + binding.carousel.updateProgress(null, position, false) + val (imageSize, _) = outputVideoPath.toUri().let { + model.setUriAtPosition(it, position) + model.getSizeAndVideoValidate(it, position) + } + model.setVideoEncodeAtPosition(position, null) + model.setSizeAtPosition(imageSize, position) + } + + val post = resultHandler.post { + successResult() + } + if(!post) { + Log.e(TAG, "Failed to post changes, trying to recover in 100ms") + resultHandler.postDelayed({successResult()}, 100) + } + Log.d(TAG, "Encode completed successfully in ${session.duration} milliseconds") + } else { + resultHandler.post { + binding.carousel.updateProgress(null, position, error = true) + model.setVideoEncodeAtPosition(position, null) + } + Log.e(TAG, "Encode failed with state ${session.state} and rc $returnCode.${session.failStackTrace}") } - model.setVideoEncodeAtPosition(position, null) - model.setSizeAtPosition(imageSize, position) - } + }, + { log -> Log.d("PostCreationActivityEncoding", log.message) } + ) { statistics: Statistics? -> - val post = resultHandler.post { - successResult() - } - if(!post) { - Log.e(TAG, "Failed to post changes, trying to recover in 100ms") - resultHandler.postDelayed({successResult()}, 100) - } - Log.d(TAG, "Encode completed successfully in ${session.duration} milliseconds") - } else { - resultHandler.post { - binding.carousel.updateProgress(null, position, error = true) - model.setVideoEncodeAtPosition(position, null) - } - Log.e(TAG, "Encode failed with state ${session.state} and rc $returnCode.${session.failStackTrace}") - } - }, - { log -> Log.d("PostCreationActivityEncoding", log.message) } - ) { statistics: Statistics? -> + val timeInMilliseconds: Int? = statistics?.time + timeInMilliseconds?.let { + if (timeInMilliseconds > 0) { + val completePercentage = totalVideoDuration?.let { + val speedupDurationModifier = VideoEditActivity.speedChoices[speedIndex].toFloat() - val timeInMilliseconds: Int? = statistics?.time - timeInMilliseconds?.let { - if (timeInMilliseconds > 0) { - val completePercentage = totalVideoDuration?.let { - val speedupDurationModifier = VideoEditActivity.speedChoices[speedIndex].toFloat() - - val newTotalDuration = (it - (videoStart ?: 0f) - (it - (videoEnd ?: it)))/speedupDurationModifier - timeInMilliseconds / (10*newTotalDuration) - } - resultHandler.post { - completePercentage?.let { - val rounded: Int = it.roundToInt() - model.setVideoEncodeAtPosition(position, rounded) - binding.carousel.updateProgress(rounded, position, false) + val newTotalDuration = (it - (videoStart ?: 0f) - (it - (videoEnd ?: it)))/speedupDurationModifier + timeInMilliseconds / (10*newTotalDuration) + } + resultHandler.post { + completePercentage?.let { + val rounded: Int = it.roundToInt() + model.setVideoEncodeAtPosition(position, rounded) + binding.carousel.updateProgress(rounded, position, false) + } + } + Log.d(TAG, "Encoding video: %$completePercentage.") } } - Log.d(TAG, "Encoding video: %$completePercentage.") } - } + model.registerNewFFmpegSession(position, session.sessionId) } - model.registerNewFFmpegSession(position, session.sessionId) + + fun stabilizationFirstPass(){ +//TODO FFmpeg + secondPass() + } + + if(stabilize > 0.01f) { + // Stabilization was requested: we need an additional first pass to get stabilization data + stabilizationFirstPass() + } else { + // Immediately call the second pass, no stabilization needed + secondPass() + } + } private fun edit(position: Int) { diff --git a/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationViewModel.kt b/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationViewModel.kt index 02850774..ec7064af 100644 --- a/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationViewModel.kt +++ b/app/src/main/java/org/pixeldroid/app/postCreation/PostCreationViewModel.kt @@ -63,7 +63,7 @@ data class PostCreationActivityUiState( val newEncodingJobVideoStart: Float? = null, val newEncodingJobVideoEnd: Float? = null, val newEncodingJobVideoCrop: RelativeCropPosition? = null, - + val newEncodingJobStabilize: Float? = null, ) class PostCreationViewModel(application: Application, clipdata: ClipData? = null, val instance: InstanceDatabaseEntity? = null) : AndroidViewModel(application) { @@ -377,6 +377,8 @@ class PostCreationViewModel(application: Application, clipdata: ClipData? = null val videoCrop: RelativeCropPosition = data.getSerializableExtra(VideoEditActivity.VIDEO_CROP) as RelativeCropPosition + val videoStabilize: Float = data.getFloatExtra(VideoEditActivity.VIDEO_STABILIZE, 1f) + videoEncodeProgress = 0 sessionMap[position]?.let { FFmpegKit.cancel(it) } _uiState.update { currentUiState -> @@ -386,7 +388,8 @@ class PostCreationViewModel(application: Application, clipdata: ClipData? = null newEncodingJobSpeedIndex = speedIndex, newEncodingJobVideoStart = videoStart, newEncodingJobVideoEnd = videoEnd, - newEncodingJobVideoCrop = videoCrop + newEncodingJobVideoCrop = videoCrop, + newEncodingJobStabilize = videoStabilize ) } } diff --git a/app/src/main/java/org/pixeldroid/app/postCreation/photoEdit/VideoEditActivity.kt b/app/src/main/java/org/pixeldroid/app/postCreation/photoEdit/VideoEditActivity.kt index 674c3c70..bd82a195 100644 --- a/app/src/main/java/org/pixeldroid/app/postCreation/photoEdit/VideoEditActivity.kt +++ b/app/src/main/java/org/pixeldroid/app/postCreation/photoEdit/VideoEditActivity.kt @@ -32,6 +32,7 @@ import com.arthenica.ffmpegkit.MediaInformation import com.arthenica.ffmpegkit.ReturnCode import com.bumptech.glide.Glide import com.google.android.material.slider.RangeSlider +import com.google.android.material.slider.Slider import org.pixeldroid.app.R import org.pixeldroid.app.databinding.ActivityVideoEditBinding import org.pixeldroid.app.postCreation.PostCreationActivity @@ -42,7 +43,6 @@ import java.io.File import java.io.Serializable import kotlin.math.absoluteValue - class VideoEditActivity : BaseThemedWithBarActivity() { data class RelativeCropPosition( @@ -68,6 +68,25 @@ class VideoEditActivity : BaseThemedWithBarActivity() { private var cropRelativeDimensions: RelativeCropPosition = RelativeCropPosition() + private var stabilization: Float = 0f + set(value){ + field = value + if(value > 0.01f && value <= 100f){ + // Stabilization requested, show UI + binding.stabilisationSaved.isVisible = true + val typedValue = TypedValue() + val color: Int = if (binding.stabilizer.context.theme + .resolveAttribute(R.attr.colorOnPrimaryContainer, typedValue, true) + ) typedValue.data else Color.TRANSPARENT + + binding.stabilizer.drawable.setTint(color) + } + else { + binding.stabilisationSaved.isVisible = false + binding.stabilizer.drawable.setTintList(null) + } + } + private var speed: Int = 1 set(value) { field = value @@ -232,6 +251,21 @@ class VideoEditActivity : BaseThemedWithBarActivity() { }.show() } + binding.stabilizer.setOnClickListener { + AlertDialog.Builder(this).apply { + setIcon(R.drawable.video_stable) + setTitle(R.string.stabilize_video_intensity) + val slider = Slider(context).apply { + valueFrom = 0f + valueTo = 100f + value = stabilization + } + setView(slider) + setNegativeButton(android.R.string.cancel) { _, _ -> } + setPositiveButton(android.R.string.ok) { _, _ -> stabilization = slider.value} + }.show() + } + val thumbInterval: Float? = duration?.div(7) @@ -306,9 +340,14 @@ class VideoEditActivity : BaseThemedWithBarActivity() { if(show) binding.cropSavedCard.visibility = View.GONE else if(!cropRelativeDimensions.notCropped()) binding.cropSavedCard.visibility = View.VISIBLE + binding.stabilisationSaved.visibility = + if(!show && stabilization > 0.01f && stabilization <= 100f) View.VISIBLE + else View.GONE + binding.muter.visibility = visibilityOfOthers binding.speeder.visibility = visibilityOfOthers binding.cropper.visibility = visibilityOfOthers + binding.stabilizer.visibility = visibilityOfOthers binding.videoRangeSeekBar.visibility = visibilityOfOthers binding.videoView.visibility = visibilityOfOthers binding.thumbnail1.visibility = visibilityOfOthers @@ -327,6 +366,7 @@ class VideoEditActivity : BaseThemedWithBarActivity() { } private fun returnWithValues() { + //TODO Check if some of these should be null to indicate no changes in that category? Ex start/end val intent = Intent(this, PostCreationActivity::class.java) .apply { putExtra(PhotoEditActivity.PICTURE_POSITION, videoPosition) @@ -336,6 +376,7 @@ class VideoEditActivity : BaseThemedWithBarActivity() { putExtra(VIDEO_START, binding.videoRangeSeekBar.values.first()) putExtra(VIDEO_END, binding.videoRangeSeekBar.values[2]) putExtra(VIDEO_CROP, cropRelativeDimensions) + putExtra(VIDEO_STABILIZE, stabilization) addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) } @@ -350,7 +391,9 @@ class VideoEditActivity : BaseThemedWithBarActivity() { binding.cropImageView.resetCropRect() cropRelativeDimensions = RelativeCropPosition() binding.cropper.drawable.setTintList(null) + binding.stabilizer.drawable.setTintList(null) binding.cropSavedCard.visibility = View.GONE + stabilization = 0f } override fun onDestroy() { @@ -414,6 +457,7 @@ class VideoEditActivity : BaseThemedWithBarActivity() { const val VIDEO_START = "VideoEditVideoStartTag" const val VIDEO_END = "VideoEditVideoEndTag" const val VIDEO_CROP = "VideoEditVideoCropTag" + const val VIDEO_STABILIZE = "VideoEditVideoStabilizeTag" const val MODIFIED = "VideoEditModifiedTag" } } \ No newline at end of file diff --git a/app/src/main/java/org/pixeldroid/app/settings/ThemeColorPreference.kt b/app/src/main/java/org/pixeldroid/app/settings/ThemeColorPreference.kt index 18a1ce44..74e5dd24 100644 --- a/app/src/main/java/org/pixeldroid/app/settings/ThemeColorPreference.kt +++ b/app/src/main/java/org/pixeldroid/app/settings/ThemeColorPreference.kt @@ -177,7 +177,7 @@ class ColorPickerView(context: Context?, attrs: AttributeSet? = null) : FrameLay binding.theme4.setOnClickListener { color = 3 } } - private fun changeConstraint(button2: View) { + private fun moveChoiceIndicator(button2: View) { binding.chosenTheme.isVisible = true val params = binding.chosenTheme.layoutParams as ConstraintLayout.LayoutParams params.endToEnd = button2.id @@ -185,8 +185,7 @@ class ColorPickerView(context: Context?, attrs: AttributeSet? = null) : FrameLay binding.chosenTheme.layoutParams = params binding.chosenTheme.requestLayout() } - /** Returns the color selected by the user */ - /** Sets the original color swatch and the current color to the specified value. */ + /** Color selected by the user */ var color: Int = 0 set(value) { field = value @@ -196,7 +195,7 @@ class ColorPickerView(context: Context?, attrs: AttributeSet? = null) : FrameLay 2 -> binding.theme3 3 -> binding.theme4 else -> null - }?.let { changeConstraint(it) } + }?.let { moveChoiceIndicator(it) } // Check switch if set to dynamic binding.dynamicColorSwitch.isChecked = value == -1 diff --git a/app/src/main/res/drawable/video_stable.xml b/app/src/main/res/drawable/video_stable.xml new file mode 100644 index 00000000..7456637a --- /dev/null +++ b/app/src/main/res/drawable/video_stable.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/activity_video_edit.xml b/app/src/main/res/layout/activity_video_edit.xml index cd018b28..7988bfa2 100644 --- a/app/src/main/res/layout/activity_video_edit.xml +++ b/app/src/main/res/layout/activity_video_edit.xml @@ -42,7 +42,6 @@ android:contentDescription="@string/save_crop" app:icon="@drawable/ic_crop_black_24dp"/> - + app:layout_constraintStart_toStartOf="parent"/> - - + app:layout_constraintStart_toEndOf="@+id/muter"/> + + + + + + + + + + + + + + + Error while adding images "Thumbnail of image in this notification's post" Preview of a post + Stabilize video + Change intensity of stabilization + Stabilization saved %d reply %d replies diff --git a/build.gradle b/build.gradle index c9fe23a3..78010cad 100644 --- a/build.gradle +++ b/build.gradle @@ -7,7 +7,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:8.0.0-alpha03' + classpath 'com.android.tools.build:gradle:8.0.0-alpha05' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // NOTE: Do not place your application dependencies here; they belong diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 392c7177..e470ce24 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -504,6 +504,14 @@ + + + + + + + + @@ -552,6 +560,14 @@ + + + + + + + + @@ -592,6 +608,14 @@ + + + + + + + + @@ -640,6 +664,14 @@ + + + + + + + + @@ -683,6 +715,14 @@ + + + + + + + + @@ -726,6 +766,14 @@ + + + + + + + + @@ -769,6 +817,14 @@ + + + + + + + + @@ -2300,6 +2356,14 @@ + + + + + + + + @@ -2348,6 +2412,14 @@ + + + + + + + + @@ -2396,6 +2468,14 @@ + + + + + + + + @@ -2524,6 +2604,14 @@ + + + + + + + + @@ -2572,6 +2660,14 @@ + + + + + + + + @@ -2668,6 +2764,14 @@ + + + + + + + + @@ -2716,6 +2820,14 @@ + + + + + + + + @@ -2764,6 +2876,14 @@ + + + + + + + + @@ -2812,6 +2932,14 @@ + + + + + + + + @@ -2860,6 +2988,14 @@ + + + + + + + + @@ -2908,6 +3044,14 @@ + + + + + + + + @@ -2956,6 +3100,14 @@ + + + + + + + + @@ -3004,6 +3156,14 @@ + + + + + + + + @@ -3044,6 +3204,14 @@ + + + + + + + + @@ -3100,6 +3268,14 @@ + + + + + + + + @@ -3148,6 +3324,14 @@ + + + + + + + + @@ -3196,6 +3380,14 @@ + + + + + + + + @@ -3244,6 +3436,14 @@ + + + + + + + + @@ -3292,6 +3492,14 @@ + + + + + + + + @@ -3340,6 +3548,14 @@ + + + + + + + + @@ -3388,6 +3604,14 @@ + + + + + + + + @@ -3460,6 +3684,14 @@ + + + + + + + + @@ -3508,6 +3740,14 @@ + + + + + + + + @@ -3524,6 +3764,14 @@ + + + + + + + + @@ -3572,6 +3820,14 @@ + + + + + + + + @@ -3644,6 +3900,14 @@ + + + + + + + + @@ -3740,6 +4004,14 @@ + + + + + + + + @@ -3852,6 +4124,14 @@ + + + + + + + + @@ -3900,6 +4180,14 @@ + + + + + + + + @@ -3948,6 +4236,14 @@ + + + + + + + + @@ -3996,6 +4292,14 @@ + + + + + + + + @@ -4044,6 +4348,14 @@ + + + + + + + + @@ -4092,6 +4404,14 @@ + + + + + + + + @@ -4140,6 +4460,14 @@ + + + + + + + + @@ -4188,6 +4516,14 @@ + + + + + + + + @@ -4868,6 +5204,14 @@ + + + + + + + + @@ -4897,6 +5241,11 @@ + + + + + @@ -4905,6 +5254,11 @@ + + + + + @@ -4925,6 +5279,11 @@ + + + + + @@ -5063,6 +5422,11 @@ + + + + + @@ -5071,6 +5435,14 @@ + + + + + + + + @@ -5079,11 +5451,24 @@ + + + + + + + + + + + + + @@ -5631,6 +6016,14 @@ + + + + + + + + @@ -5639,6 +6032,14 @@ + + + + + + + + @@ -5647,6 +6048,14 @@ + + + + + + + + @@ -5655,6 +6064,14 @@ + + + + + + + + @@ -5663,6 +6080,14 @@ + + + + + + + + @@ -5671,6 +6096,14 @@ + + + + + + + + @@ -5679,6 +6112,14 @@ + + + + + + + + @@ -5687,6 +6128,14 @@ + + + + + + + + @@ -5695,6 +6144,14 @@ + + + + + + + + @@ -5703,6 +6160,14 @@ + + + + + + + + @@ -5711,6 +6176,14 @@ + + + + + + + + @@ -5719,6 +6192,14 @@ + + + + + + + + @@ -5727,6 +6208,14 @@ + + + + + + + + @@ -5735,6 +6224,14 @@ + + + + + + + + @@ -5743,11 +6240,24 @@ + + + + + + + + + + + + + @@ -5756,6 +6266,27 @@ + + + + + + + + + + + + + + + + + + + + + @@ -5764,6 +6295,14 @@ + + + + + + + + @@ -6127,6 +6666,11 @@ + + + + + @@ -6150,6 +6694,14 @@ + + + + + + + +