@imgly/editor-react-native 1.80.0 → 1.81.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/android/build.gradle +1 -1
  2. package/android/src/main/assets/ly.img.memories.style/content.json +50 -0
  3. package/android/src/main/assets/ly.img.memories.style/thumbnails/bubblegum.png +0 -0
  4. package/android/src/main/assets/ly.img.memories.style/thumbnails/hologram.png +0 -0
  5. package/android/src/main/assets/ly.img.memories.style/thumbnails/noir.png +0 -0
  6. package/android/src/main/assets/ly.img.memories.style/videos/bubblegum.mp4 +0 -0
  7. package/android/src/main/assets/ly.img.memories.style/videos/hologram.mp4 +0 -0
  8. package/android/src/main/kotlin/ly/img/editor/configuration/memories/MemoriesApp.kt +77 -0
  9. package/android/src/main/kotlin/ly/img/editor/configuration/memories/MemoriesConfiguration.kt +38 -0
  10. package/android/src/main/kotlin/ly/img/editor/configuration/memories/MemoriesEditor.kt +79 -0
  11. package/android/src/main/kotlin/ly/img/editor/configuration/memories/MemoriesViewModel.kt +469 -0
  12. package/android/src/main/kotlin/ly/img/editor/configuration/memories/callback/OnCreate.kt +69 -0
  13. package/android/src/main/kotlin/ly/img/editor/configuration/memories/callback/OnExport.kt +46 -0
  14. package/android/src/main/kotlin/ly/img/editor/configuration/memories/component/BottomPanel.kt +151 -0
  15. package/android/src/main/kotlin/ly/img/editor/configuration/memories/component/Dock.kt +145 -0
  16. package/android/src/main/kotlin/ly/img/editor/configuration/memories/component/ExportOverlay.kt +229 -0
  17. package/android/src/main/kotlin/ly/img/editor/configuration/memories/component/NavigationBar.kt +80 -0
  18. package/android/src/main/kotlin/ly/img/editor/configuration/memories/component/Overlay.kt +138 -0
  19. package/android/src/main/kotlin/ly/img/editor/configuration/memories/extension/EditorScopeExt.kt +111 -0
  20. package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/AddPhotoAlternate.kt +79 -0
  21. package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/ArrowForward.kt +45 -0
  22. package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/Audiotrack.kt +43 -0
  23. package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/Check.kt +39 -0
  24. package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/CheckCircle.kt +44 -0
  25. package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/Close.kt +43 -0
  26. package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/Delete.kt +54 -0
  27. package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/DeleteOutline.kt +53 -0
  28. package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/Fullscreen.kt +74 -0
  29. package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/Image.kt +51 -0
  30. package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/Info.kt +48 -0
  31. package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/Pause.kt +46 -0
  32. package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/PlayArrow.kt +38 -0
  33. package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/RepeatOff.kt +80 -0
  34. package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/RepeatOn.kt +59 -0
  35. package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/SkipPrevious.kt +46 -0
  36. package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/Sort.kt +55 -0
  37. package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/Style.kt +59 -0
  38. package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/Title.kt +43 -0
  39. package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/TouchTriple.kt +134 -0
  40. package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/VolumeDown.kt +48 -0
  41. package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/VolumeUp.kt +60 -0
  42. package/android/src/main/kotlin/ly/img/editor/configuration/memories/model/ExportStatus.kt +20 -0
  43. package/android/src/main/kotlin/ly/img/editor/configuration/memories/model/ImageItem.kt +8 -0
  44. package/android/src/main/kotlin/ly/img/editor/configuration/memories/model/TimelineImage.kt +18 -0
  45. package/android/src/main/kotlin/ly/img/editor/configuration/memories/scene/Events.kt +59 -0
  46. package/android/src/main/kotlin/ly/img/editor/configuration/memories/scene/Playback.kt +29 -0
  47. package/android/src/main/kotlin/ly/img/editor/configuration/memories/scene/SceneSetup.kt +79 -0
  48. package/android/src/main/kotlin/ly/img/editor/configuration/memories/scene/Timeline.kt +27 -0
  49. package/android/src/main/kotlin/ly/img/editor/configuration/memories/scene/Title.kt +162 -0
  50. package/android/src/main/kotlin/ly/img/editor/configuration/memories/scene/TrackEditor.kt +740 -0
  51. package/android/src/main/kotlin/ly/img/editor/configuration/memories/screen/ImageSelectionScreen.kt +387 -0
  52. package/android/src/main/kotlin/ly/img/editor/configuration/memories/screen/LoadingScreen.kt +258 -0
  53. package/android/src/main/kotlin/ly/img/editor/configuration/memories/sheet/FilterStylesSheet.kt +167 -0
  54. package/android/src/main/kotlin/ly/img/editor/configuration/memories/sheet/FilterStylesSheetType.kt +21 -0
  55. package/android/src/main/kotlin/ly/img/editor/configuration/memories/sheet/VolumeSheet.kt +104 -0
  56. package/android/src/main/kotlin/ly/img/editor/configuration/memories/style/StyleApplier.kt +220 -0
  57. package/android/src/main/kotlin/ly/img/editor/configuration/memories/style/StyleAssetSource.kt +43 -0
  58. package/android/src/main/kotlin/ly/img/editor/configuration/memories/style/VideoStyle.kt +118 -0
  59. package/android/src/main/kotlin/ly/img/editor/configuration/memories/util/Animations.kt +58 -0
  60. package/android/src/main/kotlin/ly/img/editor/configuration/memories/util/Constants.kt +20 -0
  61. package/android/src/main/kotlin/ly/img/editor/configuration/memories/util/ImageDate.kt +81 -0
  62. package/android/src/main/kotlin/ly/img/editor/configuration/memories/util/Utils.kt +27 -0
  63. package/android/src/main/kotlin/ly/img/editor/configuration/memories/widget/CenteredAddImageButton.kt +82 -0
  64. package/android/src/main/kotlin/ly/img/editor/configuration/memories/widget/DraggableImageItem.kt +145 -0
  65. package/android/src/main/kotlin/ly/img/editor/configuration/memories/widget/FullscreenVideoControls.kt +136 -0
  66. package/android/src/main/kotlin/ly/img/editor/configuration/memories/widget/ImageGrid.kt +122 -0
  67. package/android/src/main/kotlin/ly/img/editor/configuration/memories/widget/ImageGridOverlay.kt +149 -0
  68. package/android/src/main/kotlin/ly/img/editor/configuration/memories/widget/ImageTimelineStrip.kt +323 -0
  69. package/android/src/main/kotlin/ly/img/editor/configuration/memories/widget/ImageToolbar.kt +401 -0
  70. package/android/src/main/kotlin/ly/img/editor/configuration/memories/widget/ReorderableGridState.kt +183 -0
  71. package/android/src/main/kotlin/ly/img/editor/configuration/memories/widget/VideoControlPanel.kt +180 -0
  72. package/android/src/main/kotlin/ly/img/editor/configuration/memories/widget/VideoThumbnail.kt +110 -0
  73. package/android/src/main/kotlin/ly/img/editor/configuration/video/callback/OnCreate.kt +0 -7
  74. package/android/src/main/kotlin/ly/img/editor/configuration/video/component/InspectorBar.kt +2 -0
  75. package/ios/StarterKits/Video/callbacks/OnCreate+Video.swift +1 -1
  76. package/ios/StarterKits/Video/components/Dock+Video.swift +1 -0
  77. package/ios/StarterKits/Video/components/InspectorBar+Video.swift +4 -0
  78. package/package.json +1 -1
  79. package/src/NativeIMGLYEditor.ts +1 -1
@@ -0,0 +1,740 @@
1
+ package ly.img.editor.configuration.memories.scene
2
+
3
+ import android.util.Log
4
+ import ly.img.editor.configuration.memories.model.ImageItem
5
+ import ly.img.editor.configuration.memories.model.TimelineImage
6
+ import ly.img.editor.configuration.memories.util.AnimationPair
7
+ import ly.img.editor.configuration.memories.util.Animations
8
+ import ly.img.editor.configuration.memories.util.IMAGE_DURATION
9
+ import ly.img.editor.configuration.memories.util.OVERLAP_DURATION
10
+ import ly.img.editor.configuration.memories.util.TITLE_DURATION
11
+ import ly.img.engine.DesignBlock
12
+ import ly.img.engine.DesignBlockType
13
+ import ly.img.engine.Engine
14
+ import ly.img.engine.FillType
15
+ import ly.img.engine.ShapeType
16
+ import kotlin.math.abs
17
+ import kotlin.math.min
18
+
19
+ /**
20
+ * The tracks are the single source of truth for the slideshow. Each photo/clip lives alone on its
21
+ * own track, positioned in time by the track's time offset (see [slotStartTime]); tracks are
22
+ * appended to the page in slot order, so a later slide always renders **above** the earlier one and
23
+ * every crossfade reads as "the incoming clip fades in over the outgoing one".
24
+ *
25
+ * Read the current media back from the tracks with [readImagesFromTracks] / [readTimelineImages];
26
+ * stage an edit in-place with [applyDraftToTracks]. Block ids are never retained (the engine
27
+ * invalidates them) — everything is re-located by the role/index metadata written here.
28
+ */
29
+
30
+ private const val TAG = "MemoriesTracks"
31
+
32
+ // ---- Metadata keys -----------------------------------------------------------------------------
33
+
34
+ const val IMAGE_INDEX_KEY = "index" // slot order, also used by filter/animation code
35
+ private const val IMG_ID_KEY = "imgId" // stable UI id so the grid survives reordering
36
+ private const val CREATION_DATE_KEY = "creationDate"
37
+ private const val MEDIA_TYPE_KEY = "mediaType" // "image" (default) or "video"
38
+ private const val MEDIA_TYPE_VIDEO = "video"
39
+
40
+ const val SPACER_TYPE_KEY = "spacerType"
41
+ const val SPACER_BETWEEN = "between" // generic spacer (used by the burst-image intro)
42
+
43
+ // Track roles, tagged on the slideshow tracks so they can be re-located by metadata instead of
44
+ // retaining (engine-invalidated) block ids. See [tagSlideshowTracks] / [createMediaTrack] /
45
+ // [findTracks]. Each media clip gets its own track tagged [TRACK_ROLE_MEDIA].
46
+ private const val TRACK_ROLE_KEY = "trackRole"
47
+ private const val TRACK_ROLE_MEDIA = "media"
48
+ private const val TRACK_ROLE_TEXT = "text"
49
+ private const val TRACK_ROLE_BACKGROUND = "background"
50
+ private const val TRACK_ROLE_MATTE = "matte"
51
+
52
+ private const val GRAPHIC_TYPE = "//ly.img.ubq/graphic"
53
+ private const val TEXT_TYPE = "//ly.img.ubq/text"
54
+ private const val TRACK_TYPE = "//ly.img.ubq/track"
55
+ private const val IMAGE_FILL_URI = "fill/image/imageFileURI"
56
+ private const val VIDEO_FILL_URI = "fill/video/fileURI"
57
+
58
+ // ---- Timing ------------------------------------------------------------------------------------
59
+
60
+ /** Seconds the montage is pushed back so it starts after the title card (0 when there's no title). */
61
+ private fun titleOffset(hasTitle: Boolean): Double = if (hasTitle) TITLE_DURATION - 1.0 else 0.0
62
+
63
+ /**
64
+ * The crossfade overlap between two consecutive clips. Two photos crossfade, so they overlap by
65
+ * [OVERLAP_DURATION]; anything next to a video hard-cuts (no animation), so there is no overlap —
66
+ * the clips play back-to-back.
67
+ */
68
+ private fun overlapBetween(
69
+ a: ImageItem,
70
+ b: ImageItem,
71
+ ): Double = if (a.isVideo || b.isVideo) 0.0 else OVERLAP_DURATION
72
+
73
+ /**
74
+ * Absolute page time at which each slot appears. Each clip advances by its own on-timeline duration
75
+ * ([durations], one per slot — a photo is always [IMAGE_DURATION]; a short video is only as long as
76
+ * its source) minus the overlap with the previous clip, so photo→photo boundaries overlap for the
77
+ * crossfade while video-adjacent boundaries are back-to-back with no gap.
78
+ */
79
+ internal fun slotStartTimes(
80
+ media: List<ImageItem>,
81
+ durations: List<Double>,
82
+ hasTitle: Boolean,
83
+ ): List<Double> {
84
+ val starts = ArrayList<Double>(media.size)
85
+ var t = titleOffset(hasTitle)
86
+ for (i in media.indices) {
87
+ if (i > 0) t += durations.getOrElse(i - 1) { IMAGE_DURATION } - overlapBetween(media[i - 1], media[i])
88
+ starts.add(t)
89
+ }
90
+ return starts
91
+ }
92
+
93
+ // ---- Spacer (burst-image intro only) -----------------------------------------------------------
94
+
95
+ /**
96
+ * An empty, full-page graphic block used purely for timing (only the title's burst intro uses these
97
+ * now — the main slides are positioned by their track's time offset instead).
98
+ */
99
+ internal fun createSpacerBlock(
100
+ engine: Engine,
101
+ page: Int,
102
+ duration: Double,
103
+ type: String = SPACER_BETWEEN,
104
+ ): Int {
105
+ val spacer = engine.block.create(DesignBlockType.Graphic)
106
+ engine.block.setWidth(spacer, engine.block.getWidth(page))
107
+ engine.block.setHeight(spacer, engine.block.getHeight(page))
108
+ engine.block.setDuration(spacer, duration)
109
+ engine.block.setScopeEnabled(spacer, "editor/select", false)
110
+ engine.block.setMetadata(spacer, SPACER_TYPE_KEY, type)
111
+ return spacer
112
+ }
113
+
114
+ // ---- Block classification ----------------------------------------------------------------------
115
+
116
+ /** A media slot (image or video): a graphic block carrying [IMAGE_INDEX_KEY] and no spacer tag. */
117
+ private fun isImageBlock(
118
+ engine: Engine,
119
+ block: Int,
120
+ ): Boolean = try {
121
+ engine.block.getType(block) == GRAPHIC_TYPE &&
122
+ engine.block.hasMetadata(block, IMAGE_INDEX_KEY) &&
123
+ !engine.block.hasMetadata(block, SPACER_TYPE_KEY)
124
+ } catch (e: Exception) {
125
+ false
126
+ }
127
+
128
+ /** True if the block carries the video media-type tag. */
129
+ internal fun isVideoBlock(
130
+ engine: Engine,
131
+ block: Int,
132
+ ): Boolean = try {
133
+ engine.block.hasMetadata(block, MEDIA_TYPE_KEY) &&
134
+ engine.block.getMetadata(block, MEDIA_TYPE_KEY) == MEDIA_TYPE_VIDEO
135
+ } catch (e: Exception) {
136
+ false
137
+ }
138
+
139
+ /** The single media block that lives on a media [track], or null if it has none yet. */
140
+ private fun mediaBlockOf(
141
+ engine: Engine,
142
+ track: Int,
143
+ ): Int? = engine.block.getChildren(track).firstOrNull { isImageBlock(engine, it) }
144
+
145
+ /** The media clips across all media tracks, in slot order (matches z / render order). */
146
+ internal fun imageBlocksInOrder(
147
+ engine: Engine,
148
+ tracks: SlideshowTracks,
149
+ ): List<Int> = tracks.mediaTracks.mapNotNull { mediaBlockOf(engine, it) }
150
+
151
+ // ---- Track location by metadata ----------------------------------------------------------------
152
+
153
+ /**
154
+ * The named tracks that make up the slideshow, located by their [TRACK_ROLE_KEY] metadata.
155
+ * [mediaTracks] holds one track per slide, ordered by slot index (which equals their z order);
156
+ * [textTrack] is null when the slideshow has no title.
157
+ */
158
+ data class SlideshowTracks(
159
+ val mediaTracks: List<Int>,
160
+ val textTrack: Int?,
161
+ val backgroundTrack: Int? = null,
162
+ val matteTrack: Int? = null,
163
+ )
164
+
165
+ /**
166
+ * Tag the persistent single-purpose tracks with their role so [findTracks] can re-locate them.
167
+ * Media tracks are tagged individually in [createMediaTrack].
168
+ */
169
+ fun tagSlideshowTracks(
170
+ engine: Engine,
171
+ textTrack: DesignBlock,
172
+ backgroundTrack: DesignBlock,
173
+ matteTrack: DesignBlock,
174
+ ) {
175
+ engine.block.setMetadata(textTrack, TRACK_ROLE_KEY, TRACK_ROLE_TEXT)
176
+ engine.block.setMetadata(backgroundTrack, TRACK_ROLE_KEY, TRACK_ROLE_BACKGROUND)
177
+ engine.block.setMetadata(matteTrack, TRACK_ROLE_KEY, TRACK_ROLE_MATTE)
178
+ }
179
+
180
+ /**
181
+ * The persistent full-page backdrop block (the single child of the background track), or null when
182
+ * the background track or its block isn't present. The Styles application paints this block per
183
+ * style (hidden for Default, white for Noir, a looping video for Hologram/Bubblegum).
184
+ */
185
+ fun backgroundBlock(
186
+ engine: Engine,
187
+ page: Int,
188
+ ): Int? {
189
+ val track = findTracks(engine, page)?.backgroundTrack ?: return null
190
+ return engine.block.getChildren(track).firstOrNull()
191
+ }
192
+
193
+ /**
194
+ * The persistent black matte block (the single child of the matte track), sized to the media so a
195
+ * crossfade between clips fades to black instead of revealing the moving video backdrop below it.
196
+ * Null when the matte track or its block isn't present.
197
+ */
198
+ fun matteBlock(
199
+ engine: Engine,
200
+ page: Int,
201
+ ): Int? {
202
+ val track = findTracks(engine, page)?.matteTrack ?: return null
203
+ return engine.block.getChildren(track).firstOrNull()
204
+ }
205
+
206
+ /**
207
+ * The slot index tagged on a media track's clip, used to order the tracks. Missing/garbled metadata
208
+ * sorts last so a half-built track never jumps to the front.
209
+ */
210
+ private fun mediaTrackIndex(
211
+ engine: Engine,
212
+ track: Int,
213
+ ): Int {
214
+ val block = mediaBlockOf(engine, track) ?: return Int.MAX_VALUE
215
+ return runCatching { engine.block.getMetadata(block, IMAGE_INDEX_KEY).toInt() }.getOrDefault(Int.MAX_VALUE)
216
+ }
217
+
218
+ /**
219
+ * Locate the slideshow tracks on [page] by their [TRACK_ROLE_KEY] metadata, ignoring burst tracks
220
+ * (which carry no role). The media tracks are returned in slot order. Returns null if no media
221
+ * tracks are present yet (the page hasn't been set up); the text track is optional and absent when
222
+ * the slideshow has no title. Resolving the tracks fresh on each use avoids retaining block ids,
223
+ * which the engine can invalidate over time (e.g. on undo/redo or a scene reload).
224
+ */
225
+ fun findTracks(
226
+ engine: Engine,
227
+ page: Int,
228
+ ): SlideshowTracks? {
229
+ val mediaTracks = mutableListOf<Int>()
230
+ var textTrack: Int? = null
231
+ var backgroundTrack: Int? = null
232
+ var matteTrack: Int? = null
233
+ // Search the whole scene by type rather than walking page children: the video timeline nests
234
+ // tracks under intermediate blocks, so they aren't always direct children of the page.
235
+ for (t in engine.block.findByType(DesignBlockType.Track)) {
236
+ val role = try {
237
+ if (engine.block.hasMetadata(t, TRACK_ROLE_KEY)) engine.block.getMetadata(t, TRACK_ROLE_KEY) else null
238
+ } catch (e: Exception) {
239
+ null
240
+ }
241
+ when (role) {
242
+ TRACK_ROLE_MEDIA -> mediaTracks.add(t)
243
+ TRACK_ROLE_TEXT -> textTrack = t
244
+ TRACK_ROLE_BACKGROUND -> backgroundTrack = t
245
+ TRACK_ROLE_MATTE -> matteTrack = t
246
+ }
247
+ }
248
+ // Return the scene's tracks even when there is no media (e.g. after "Clear all") so media can
249
+ // still be re-added; only bail when the scene scaffold itself hasn't been created yet. Otherwise
250
+ // clearing every clip would leave findTracks returning null, and the media dock / next apply
251
+ // would bail early with no way back except restarting the editor.
252
+ if (mediaTracks.isEmpty() && textTrack == null && backgroundTrack == null && matteTrack == null) return null
253
+ mediaTracks.sortBy { mediaTrackIndex(engine, it) }
254
+ return SlideshowTracks(mediaTracks, textTrack, backgroundTrack, matteTrack)
255
+ }
256
+
257
+ // ---- Metadata read/write -----------------------------------------------------------------------
258
+
259
+ internal fun writeImageMetadata(
260
+ engine: Engine,
261
+ block: Int,
262
+ image: ImageItem,
263
+ index: Int,
264
+ ) {
265
+ engine.block.setMetadata(block, IMAGE_INDEX_KEY, "$index")
266
+ engine.block.setMetadata(block, IMG_ID_KEY, "${image.id}")
267
+ engine.block.setMetadata(block, MEDIA_TYPE_KEY, if (image.isVideo) MEDIA_TYPE_VIDEO else "image")
268
+ image.creationDate?.let { engine.block.setMetadata(block, CREATION_DATE_KEY, "$it") }
269
+ }
270
+
271
+ private fun readCreationDate(
272
+ engine: Engine,
273
+ block: Int,
274
+ ): Long? = if (engine.block.hasMetadata(block, CREATION_DATE_KEY)) {
275
+ engine.block.getMetadata(block, CREATION_DATE_KEY).toLongOrNull()
276
+ } else {
277
+ null
278
+ }
279
+
280
+ /** Read the media URL from a slot's fill (image or video) plus whether it is a video. */
281
+ private fun readMediaUrl(
282
+ engine: Engine,
283
+ block: Int,
284
+ ): Pair<String, Boolean>? {
285
+ val fill = try {
286
+ engine.block.getFill(block)
287
+ } catch (e: Exception) {
288
+ return null
289
+ }
290
+ runCatching { engine.block.getString(fill, IMAGE_FILL_URI) }.getOrNull()?.let { return it to false }
291
+ runCatching { engine.block.getString(fill, VIDEO_FILL_URI) }.getOrNull()?.let { return it to true }
292
+ return null
293
+ }
294
+
295
+ // ---- Reading the grid from the tracks (source of truth) ----------------------------------------
296
+
297
+ /** Rebuild the editable media list from the actual track blocks. */
298
+ fun readImagesFromTracks(
299
+ engine: Engine,
300
+ tracks: SlideshowTracks,
301
+ ): List<ImageItem> {
302
+ return imageBlocksInOrder(engine, tracks).mapIndexedNotNull { position, block ->
303
+ val (url, isVideo) = readMediaUrl(engine, block) ?: run {
304
+ Log.w(TAG, "Media slot $block at position $position has no readable fill; skipping")
305
+ return@mapIndexedNotNull null
306
+ }
307
+
308
+ val id = if (engine.block.hasMetadata(block, IMG_ID_KEY)) {
309
+ engine.block.getMetadata(block, IMG_ID_KEY).toIntOrNull() ?: (position + 1)
310
+ } else {
311
+ position + 1
312
+ }
313
+
314
+ ImageItem(
315
+ id = id,
316
+ url = url,
317
+ isVideo = isVideo,
318
+ creationDate = readCreationDate(engine, block),
319
+ )
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Compute each clip's window on the timeline from its track's time offset. Each media track holds a
325
+ * single clip of [IMAGE_DURATION], so the window is simply [offset, offset + IMAGE_DURATION].
326
+ * Returned in slot order.
327
+ */
328
+ fun readTimelineImages(
329
+ engine: Engine,
330
+ tracks: SlideshowTracks,
331
+ ): List<TimelineImage> = tracks.mediaTracks.mapNotNull { track ->
332
+ val block = mediaBlockOf(engine, track) ?: return@mapNotNull null
333
+ val (url, isVideo) = readMediaUrl(engine, block) ?: return@mapNotNull null
334
+ val start = runCatching { engine.block.getTimeOffset(track) }.getOrDefault(0.0)
335
+ val duration = runCatching { engine.block.getDuration(block) }.getOrDefault(IMAGE_DURATION)
336
+ TimelineImage(
337
+ url = url,
338
+ startTime = start,
339
+ endTime = start + duration,
340
+ // Each clip has a symmetric in/out crossfade, so it is fully opaque and the only one on
341
+ // screen at its midpoint.
342
+ fullyVisibleTime = start + duration / 2.0,
343
+ isVideo = isVideo,
344
+ )
345
+ }
346
+
347
+ /**
348
+ * If a title is present, returns the time at which the burst images have finished and the title is
349
+ * visible unobstructed (the latest burst-image end across the burst tracks). Returns null when
350
+ * there is no title.
351
+ */
352
+ fun readTitleClearTime(
353
+ engine: Engine,
354
+ page: Int,
355
+ tracks: SlideshowTracks,
356
+ ): Double? {
357
+ val textTrack = tracks.textTrack ?: return null
358
+ val hasTitle = engine.block.getChildren(textTrack).any {
359
+ runCatching { engine.block.getType(it) == TEXT_TYPE }.getOrDefault(false)
360
+ }
361
+ if (!hasTitle) return null
362
+
363
+ // Exclude every non-burst track: media, text, and the full-length backdrop/matte tracks. Without
364
+ // the last two, they are mistaken for burst tracks and their slideshow-length duration becomes
365
+ // maxBurstEnd, so tapping the title chip seeks to the very end instead of just past the intro.
366
+ val known = (tracks.mediaTracks + textTrack + listOfNotNull(tracks.backgroundTrack, tracks.matteTrack)).toSet()
367
+ var maxBurstEnd = 0.0
368
+ for (child in engine.block.getChildren(page)) {
369
+ if (child in known) continue
370
+ if (runCatching { engine.block.getType(child) }.getOrNull() != TRACK_TYPE) continue
371
+ // Burst track: a burst image is a graphic with no spacer tag and no slot index.
372
+ var t = 0.0
373
+ for (b in engine.block.getChildren(child)) {
374
+ val d = runCatching { engine.block.getDuration(b) }.getOrDefault(0.0)
375
+ t += d
376
+ val isBurstImage = runCatching {
377
+ engine.block.getType(b) == GRAPHIC_TYPE &&
378
+ !engine.block.hasMetadata(b, SPACER_TYPE_KEY) &&
379
+ !engine.block.hasMetadata(b, IMAGE_INDEX_KEY)
380
+ }.getOrDefault(false)
381
+ if (isBurstImage) maxBurstEnd = maxOf(maxBurstEnd, t)
382
+ }
383
+ }
384
+ return maxBurstEnd
385
+ }
386
+
387
+ // ---- Media tracks ------------------------------------------------------------------------------
388
+
389
+ /**
390
+ * Create a full-page media track appended on top of the current stack (so it renders above earlier
391
+ * slides) and positioned at [startTime] via its time offset. Callers add the single media block.
392
+ */
393
+ internal fun createMediaTrack(
394
+ engine: Engine,
395
+ page: Int,
396
+ startTime: Double,
397
+ ): Int {
398
+ val track = engine.block.create(DesignBlockType.Track)
399
+ engine.block.setMetadata(track, TRACK_ROLE_KEY, TRACK_ROLE_MEDIA)
400
+ engine.block.appendChild(page, track)
401
+ engine.block.fillParent(track)
402
+ positionMediaTrack(engine, track, startTime)
403
+ return track
404
+ }
405
+
406
+ /** Position a media track at [startTime] on the page timeline. */
407
+ internal fun positionMediaTrack(
408
+ engine: Engine,
409
+ track: Int,
410
+ startTime: Double,
411
+ ) {
412
+ engine.block.setTimeOffset(track, startTime)
413
+ }
414
+
415
+ /**
416
+ * Each media track's on-timeline clip length (the block's own duration), in slot order. A photo (and
417
+ * a video at least as long as the slot) is [IMAGE_DURATION]; a short video is only as long as its
418
+ * source (see [placeVideoBlock]). Falls back to [IMAGE_DURATION] for any track missing its clip.
419
+ */
420
+ private fun slotDurations(
421
+ engine: Engine,
422
+ mediaTracks: List<Int>,
423
+ ): List<Double> = mediaTracks.map { track ->
424
+ val block = mediaBlockOf(engine, track)
425
+ block?.let { runCatching { engine.block.getDuration(it) }.getOrNull() } ?: IMAGE_DURATION
426
+ }
427
+
428
+ /**
429
+ * Re-position every media track on [page] by the start time its slot occupies, derived from the
430
+ * clips' actual durations (so a short video takes up only its own length, with no gap after it) and
431
+ * the video-aware overlaps. Returns the per-slot durations so the caller can size the page/backdrops.
432
+ * Call after all clip blocks for [media] have been created/edited so their durations are final.
433
+ */
434
+ internal fun repositionTracks(
435
+ engine: Engine,
436
+ page: Int,
437
+ media: List<ImageItem>,
438
+ hasTitle: Boolean,
439
+ ): List<Double> {
440
+ val mediaTracks = findTracks(engine, page)?.mediaTracks ?: return emptyList()
441
+ val durations = slotDurations(engine, mediaTracks)
442
+ val starts = slotStartTimes(media, durations, hasTitle)
443
+ mediaTracks.forEachIndexed { k, track ->
444
+ positionMediaTrack(engine, track, starts.getOrElse(k) { 0.0 })
445
+ }
446
+ return durations
447
+ }
448
+
449
+ // ---- Animation ---------------------------------------------------------------------------------
450
+
451
+ /** True if the neighbour before slot [index] is a video (so this clip must hard-cut in). */
452
+ private fun hardCutIn(
453
+ media: List<ImageItem>,
454
+ index: Int,
455
+ ): Boolean = index > 0 && media[index - 1].isVideo
456
+
457
+ /** True if the neighbour after slot [index] is a video (so this clip must hard-cut out). */
458
+ private fun hardCutOut(
459
+ media: List<ImageItem>,
460
+ index: Int,
461
+ ): Boolean = index < media.size - 1 && media[index + 1].isVideo
462
+
463
+ /**
464
+ * Apply a random slide animation to an image [block], honouring hard-cut boundaries: a photo that
465
+ * sits next to a video drops its fade on that side ([suppressIn]/[suppressOut]) so the photo↔video
466
+ * cut is clean instead of a fade fighting the video's hard edge. Suppressing a side simply leaves it
467
+ * unset (no in/out animation = a hard cut), so callers must only use this on a freshly-created block
468
+ * or one that already has no animation on the suppressed side.
469
+ */
470
+ internal fun applySlideAnimation(
471
+ engine: Engine,
472
+ block: Int,
473
+ suppressIn: Boolean,
474
+ suppressOut: Boolean,
475
+ animationPairs: List<AnimationPair>,
476
+ ) {
477
+ if (animationPairs.isEmpty()) return
478
+ val pair = Animations.getRandomAnimationPair(animationPairs)
479
+
480
+ // Engine animations are single-owner (1:1 with a design block), so each slide gets its own fresh
481
+ // animation blocks — never a shared/pooled one. First drop any animation this block already owns
482
+ // (re-applying on repaint would otherwise leak it: setIn/OutAnimation don't destroy the old one).
483
+ clearSlideAnimations(engine, block)
484
+
485
+ if (!suppressIn) {
486
+ val inAnimation = pair.createIn(engine)
487
+ engine.block.setInAnimation(block, inAnimation)
488
+ // A Ken Burns zoom-out must be pre-scaled so its pan never reveals the canvas edges; other
489
+ // animations (e.g. blur) have no such property, so the read returns 0 and nothing happens.
490
+ val zoom = runCatching {
491
+ engine.block.getFloat(inAnimation, "animation/ken_burns/zoomIntensity")
492
+ }.getOrDefault(0f)
493
+ if (zoom < 0f) engine.block.scale(block, 1f + abs(zoom), anchorX = 0.5f, anchorY = 0.5f)
494
+ }
495
+ if (!suppressOut) {
496
+ val outAnimation = pair.createOut(engine)
497
+ engine.block.setOutAnimation(block, outAnimation)
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Destroy the in/out animation blocks [block] currently owns. The engine's setIn/OutAnimation do not
503
+ * auto-destroy the animation they replace, so re-applying a slide animation without this would leak an
504
+ * orphaned animation block on every edit. Destroying the animation also clears the block's reference
505
+ * to it, leaving a clean base for [applySlideAnimation] to set fresh (or leave a suppressed side bare).
506
+ */
507
+ private fun clearSlideAnimations(
508
+ engine: Engine,
509
+ block: Int,
510
+ ) {
511
+ listOf(
512
+ runCatching { engine.block.getInAnimation(block) }.getOrDefault(-1),
513
+ runCatching { engine.block.getOutAnimation(block) }.getOrDefault(-1),
514
+ ).forEach { animation ->
515
+ if (animation != -1 && runCatching { engine.block.isValid(animation) }.getOrDefault(false)) {
516
+ engine.block.destroy(animation)
517
+ }
518
+ }
519
+ }
520
+
521
+ // ---- Media block creation ----------------------------------------------------------------------
522
+
523
+ private fun createImageBlock(
524
+ engine: Engine,
525
+ page: Int,
526
+ image: ImageItem,
527
+ index: Int,
528
+ media: List<ImageItem>,
529
+ animationPairs: List<AnimationPair>,
530
+ ): Int {
531
+ val block = engine.block.create(DesignBlockType.Graphic)
532
+ engine.block.setScopeEnabled(block, "editor/select", false)
533
+ engine.block.setWidth(block, engine.block.getWidth(page))
534
+ engine.block.setHeight(block, engine.block.getHeight(page))
535
+
536
+ val fill = engine.block.createFill(FillType.Image)
537
+ val shape = engine.block.createShape(ShapeType.Rect)
538
+ engine.block.setFill(block, fill)
539
+ engine.block.setString(fill, IMAGE_FILL_URI, image.url)
540
+ engine.block.setShape(block, shape)
541
+ engine.block.setDuration(block, IMAGE_DURATION)
542
+
543
+ // Set animations before the index metadata is used elsewhere; a photo next to a video hard-cuts
544
+ // on that side so the fade never fights the video's hard edge.
545
+ applySlideAnimation(engine, block, hardCutIn(media, index), hardCutOut(media, index), animationPairs)
546
+
547
+ writeImageMetadata(engine, block, image, index)
548
+ return block
549
+ }
550
+
551
+ /**
552
+ * Place a video slot and trim it for the [IMAGE_DURATION] window: create the [FillType.Video] block,
553
+ * no animation (videos hard-cut — no fade/Ken Burns/zoom), metadata, then `forceLoadAVResource`
554
+ * (cheap, ~100ms) and trim by source length:
555
+ * - shorter than the slot → play the whole source once at its natural length and shrink the block
556
+ * to match, so the clip is only as long as the video (no looping, no blank tail); the layout
557
+ * starts the next clip right after,
558
+ * - longer than the slot → take the middle [IMAGE_DURATION] (centered on the clip so the window
559
+ * starts a little after the source start and ends before its end, landing on the action),
560
+ * - unknown duration → trim [IMAGE_DURATION] from the start and loop.
561
+ */
562
+ suspend fun placeVideoBlock(
563
+ engine: Engine,
564
+ page: Int,
565
+ image: ImageItem,
566
+ index: Int,
567
+ ): Int {
568
+ val block = engine.block.create(DesignBlockType.Graphic)
569
+ engine.block.setScopeEnabled(block, "editor/select", false)
570
+ engine.block.setWidth(block, engine.block.getWidth(page))
571
+ engine.block.setHeight(block, engine.block.getHeight(page))
572
+
573
+ val fill = engine.block.createFill(FillType.Video)
574
+ val shape = engine.block.createShape(ShapeType.Rect)
575
+ engine.block.setFill(block, fill)
576
+ engine.block.setString(fill, VIDEO_FILL_URI, image.url)
577
+ engine.block.setShape(block, shape)
578
+ engine.block.setDuration(block, IMAGE_DURATION)
579
+
580
+ // forceLoadAVResource is cheap (~100ms); the video needs it to render and to query its duration.
581
+ engine.block.forceLoadAVResource(fill)
582
+ val sourceDuration = runCatching { engine.block.getAVResourceTotalDuration(fill) }.getOrDefault(0.0)
583
+
584
+ when {
585
+ sourceDuration in 0.0..IMAGE_DURATION && sourceDuration > 0.0 -> {
586
+ // Shorter than the slot: play the source once at its natural length and shrink the block
587
+ // to match, so the clip occupies only as much of the timeline as the video is long — no
588
+ // looping and no blank tail. The slot layout picks up this shorter block duration.
589
+ engine.block.setTrimOffset(fill, 0.0)
590
+ engine.block.setTrimLength(fill, sourceDuration)
591
+ engine.block.setLooping(fill, false)
592
+ engine.block.setDuration(block, sourceDuration)
593
+ }
594
+
595
+ sourceDuration > IMAGE_DURATION -> {
596
+ // Longer than the slot: take the middle IMAGE_DURATION so the window is centered on the
597
+ // clip (starts after the source start, ends before its end) rather than the lead-in.
598
+ engine.block.setTrimOffset(fill, (sourceDuration - IMAGE_DURATION) / 2.0)
599
+ engine.block.setTrimLength(fill, IMAGE_DURATION)
600
+ engine.block.setLooping(fill, false)
601
+ }
602
+
603
+ else -> {
604
+ // Unknown duration: trim IMAGE_DURATION from the start and loop to be safe.
605
+ engine.block.setTrimOffset(fill, 0.0)
606
+ engine.block.setTrimLength(fill, IMAGE_DURATION)
607
+ engine.block.setLooping(fill, true)
608
+ }
609
+ }
610
+
611
+ // No animation on videos — they hard-cut in/out.
612
+ writeImageMetadata(engine, block, image, index)
613
+ return block
614
+ }
615
+
616
+ /** Create the media block for slot [index] on the right kind of block (image vs video). */
617
+ internal suspend fun createMediaBlock(
618
+ engine: Engine,
619
+ page: Int,
620
+ index: Int,
621
+ media: List<ImageItem>,
622
+ animationPairs: List<AnimationPair>,
623
+ ): Int {
624
+ val image = media[index]
625
+ return if (image.isVideo) {
626
+ placeVideoBlock(engine, page, image, index)
627
+ } else {
628
+ createImageBlock(engine, page, image, index, media, animationPairs)
629
+ }
630
+ }
631
+
632
+ // ---- Total duration ----------------------------------------------------------------------------
633
+
634
+ /**
635
+ * Total page duration for [media] laid out by [slotStartTimes] with the given per-slot [durations]
636
+ * (overlaps vary with video adjacency). The last clip ends its own duration after its start; a title
637
+ * keeps a 1s tail after the last clip.
638
+ */
639
+ fun calculateTotalDuration(
640
+ media: List<ImageItem>,
641
+ durations: List<Double>,
642
+ hasTitle: Boolean,
643
+ ): Double {
644
+ if (media.isEmpty() || durations.isEmpty()) return if (hasTitle) TITLE_DURATION else IMAGE_DURATION
645
+ val lastStart = slotStartTimes(media, durations, hasTitle).last()
646
+ return lastStart + durations.last() + if (hasTitle) 1.0 else 0.0
647
+ }
648
+
649
+ // ---- Applying a staged edit in-place -----------------------------------------------------------
650
+
651
+ /**
652
+ * Write [staged] back into the tracks in-place:
653
+ * - reuse an existing media track when both it and the staged item are the same media kind — an
654
+ * image slot is repainted (preserving per-block effects/filters) + re-animated; otherwise the
655
+ * slot's clip is rebuilt in the **same** track (keeping its z position) — e.g. image↔video, any
656
+ * video (trim can't just be repainted), or a photo that must gain/lose a hard-cut edge,
657
+ * - create a new track for each added item (appended on top → highest slot index → correct z),
658
+ * - destroy surplus tracks for deleted items,
659
+ * - then re-position every track by its slot's start time and recompute the page duration.
660
+ *
661
+ * Video clips are placed + loaded + trimmed here (forceLoad is cheap, so they render right away);
662
+ * suspends for the cheap per-video `forceLoadAVResource`.
663
+ */
664
+ suspend fun applyDraftToTracks(
665
+ engine: Engine,
666
+ page: Int,
667
+ tracks: SlideshowTracks,
668
+ staged: List<ImageItem>,
669
+ animationPairs: List<AnimationPair>,
670
+ hasTitle: Boolean,
671
+ ) {
672
+ val existing = tracks.mediaTracks
673
+ val m = existing.size
674
+ val n = staged.size
675
+ val common = min(m, n)
676
+
677
+ // Reuse or rebuild the clip on each retained track.
678
+ for (k in 0 until common) {
679
+ val track = existing[k]
680
+ val block = mediaBlockOf(engine, track)
681
+ val stagedIsVideo = staged[k].isVideo
682
+ val canRepaint = block != null &&
683
+ !stagedIsVideo &&
684
+ !isVideoBlock(engine, block) &&
685
+ !needsAnimationRebuild(engine, block, staged, k)
686
+ if (canRepaint && block != null) {
687
+ // image -> image: repaint in place (keeps effects/filters), re-pick the slide animation.
688
+ // The `block != null` above is redundant with canRepaint but lets the compiler smart-cast
689
+ // block to non-null inside this branch.
690
+ val fill = engine.block.getFill(block)
691
+ engine.block.setString(fill, IMAGE_FILL_URI, staged[k].url)
692
+ applySlideAnimation(engine, block, hardCutIn(staged, k), hardCutOut(staged, k), animationPairs)
693
+ writeImageMetadata(engine, block, staged[k], k)
694
+ } else {
695
+ // Rebuild the clip inside the same track so its z position (slot order) is preserved.
696
+ block?.let { engine.block.destroy(it) }
697
+ engine.block.appendChild(track, createMediaBlock(engine, page, k, staged, animationPairs))
698
+ }
699
+ }
700
+
701
+ // Append a new track per addition (k is the largest index so appending keeps z == slot order).
702
+ // Positioned at 0 for now; every track is repositioned below once all clip durations are final.
703
+ for (k in common until n) {
704
+ val track = createMediaTrack(engine, page, 0.0)
705
+ engine.block.appendChild(track, createMediaBlock(engine, page, k, staged, animationPairs))
706
+ }
707
+
708
+ // Destroy surplus tracks (and their clips) for deletions.
709
+ for (k in n until m) {
710
+ engine.block.destroy(existing[k])
711
+ }
712
+
713
+ // Re-locate the surviving tracks fresh (now re-indexed 0..n-1) and re-position each by its slot,
714
+ // using the clips' final durations (a short video only takes up its own length). Recomputing from
715
+ // `staged` means a reorder that moves a clip next to (or away from) a video gets both the right
716
+ // overlap and the right animation (via the reuse/rebuild above).
717
+ val durations = repositionTracks(engine, page, staged, hasTitle)
718
+ engine.block.setDuration(page, calculateTotalDuration(staged, durations, hasTitle))
719
+
720
+ // Checkpoint the post-edit state so this whole batch of track edits is undoable as one step.
721
+ // Safe now that features/removeForegroundTracksOnSceneLoad is forced false in onCreate — without
722
+ // that, restoring this snapshot would flatten the multi-track slideshow.
723
+ engine.editor.addUndoStep()
724
+ }
725
+
726
+ /**
727
+ * True if a repaint can't correctly set slot [k]'s animation because the clip currently carries an
728
+ * animation on a side that must now hard-cut (photo became adjacent to a video). Rebuilding the clip
729
+ * gives it a clean, animation-free base to which [applySlideAnimation] adds only the wanted sides.
730
+ */
731
+ private fun needsAnimationRebuild(
732
+ engine: Engine,
733
+ block: Int,
734
+ staged: List<ImageItem>,
735
+ k: Int,
736
+ ): Boolean {
737
+ val hasIn = runCatching { engine.block.getInAnimation(block) }.getOrDefault(-1) != -1
738
+ val hasOut = runCatching { engine.block.getOutAnimation(block) }.getOrDefault(-1) != -1
739
+ return (hardCutIn(staged, k) && hasIn) || (hardCutOut(staged, k) && hasOut)
740
+ }