@pictor-sdk/video-android 0.1.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 (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -0
  3. package/android/CMakeLists.txt +30 -0
  4. package/android/build.gradle +119 -0
  5. package/android/src/main/AndroidManifest.xml +1 -0
  6. package/android/src/main/cpp/pictor_overlaystage.cpp +372 -0
  7. package/android/src/main/cpp/pictor_publish.cpp +87 -0
  8. package/android/src/main/cpp/pictor_videosurface.cpp +735 -0
  9. package/android/src/main/java/dev/pictor/video/AacPriming.kt +261 -0
  10. package/android/src/main/java/dev/pictor/video/AudioMixer.kt +1112 -0
  11. package/android/src/main/java/dev/pictor/video/AudioProbe.kt +162 -0
  12. package/android/src/main/java/dev/pictor/video/CadenceShaderProgram.kt +337 -0
  13. package/android/src/main/java/dev/pictor/video/CodecLedger.kt +126 -0
  14. package/android/src/main/java/dev/pictor/video/CompositionSpike.kt +978 -0
  15. package/android/src/main/java/dev/pictor/video/Diagnostics.kt +54 -0
  16. package/android/src/main/java/dev/pictor/video/EncoderGate.kt +476 -0
  17. package/android/src/main/java/dev/pictor/video/ExportGate.kt +1974 -0
  18. package/android/src/main/java/dev/pictor/video/ExportHeartbeat.kt +72 -0
  19. package/android/src/main/java/dev/pictor/video/ExportProgress.kt +103 -0
  20. package/android/src/main/java/dev/pictor/video/FrameClock.kt +591 -0
  21. package/android/src/main/java/dev/pictor/video/FramePending.kt +181 -0
  22. package/android/src/main/java/dev/pictor/video/GateAudio.kt +456 -0
  23. package/android/src/main/java/dev/pictor/video/GateDestination.kt +445 -0
  24. package/android/src/main/java/dev/pictor/video/GateFrames.kt +662 -0
  25. package/android/src/main/java/dev/pictor/video/GateMux.kt +185 -0
  26. package/android/src/main/java/dev/pictor/video/ManagedSources.kt +333 -0
  27. package/android/src/main/java/dev/pictor/video/Media3Player.kt +934 -0
  28. package/android/src/main/java/dev/pictor/video/MediaStoreSave.kt +419 -0
  29. package/android/src/main/java/dev/pictor/video/OverlayResources.kt +169 -0
  30. package/android/src/main/java/dev/pictor/video/PictorVideoModule.kt +1612 -0
  31. package/android/src/main/java/dev/pictor/video/PictorVideoPackage.kt +26 -0
  32. package/android/src/main/java/dev/pictor/video/PlaybackAnchor.kt +161 -0
  33. package/android/src/main/java/dev/pictor/video/PreviewAudioPlayer.kt +291 -0
  34. package/android/src/main/java/dev/pictor/video/PreviewConfig.kt +96 -0
  35. package/android/src/main/java/dev/pictor/video/PreviewMix.kt +396 -0
  36. package/android/src/main/java/dev/pictor/video/PreviewSync.kt +374 -0
  37. package/android/src/main/java/dev/pictor/video/PreviewTrace.kt +478 -0
  38. package/android/src/main/java/dev/pictor/video/PrimingTrim.kt +337 -0
  39. package/android/src/main/java/dev/pictor/video/RepeatingEncoderFactory.kt +97 -0
  40. package/android/src/main/java/dev/pictor/video/SceneProgram.kt +169 -0
  41. package/android/src/main/java/dev/pictor/video/SceneProgramNative.kt +299 -0
  42. package/android/src/main/java/dev/pictor/video/SkiaPassThroughEffect.kt +695 -0
  43. package/android/src/main/java/dev/pictor/video/SurfaceAttachment.kt +110 -0
  44. package/android/src/main/java/dev/pictor/video/ThumbnailDecoder.kt +171 -0
  45. package/android/src/main/java/dev/pictor/video/VideoSurface.kt +394 -0
  46. package/app.plugin.js +43 -0
  47. package/dist/index.d.mts +3622 -0
  48. package/dist/index.d.ts +3622 -0
  49. package/dist/index.js +3737 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/index.mjs +3625 -0
  52. package/dist/index.mjs.map +1 -0
  53. package/package.json +82 -0
  54. package/plugin/gradleProperties.d.ts +19 -0
  55. package/plugin/gradleProperties.js +64 -0
  56. package/react-native.config.js +13 -0
  57. package/src/NativePictorVideo.ts +574 -0
  58. package/src/audioProbe.ts +72 -0
  59. package/src/compositionSpike.ts +305 -0
  60. package/src/convergence.ts +112 -0
  61. package/src/encoderGate.ts +66 -0
  62. package/src/exportDestination.ts +149 -0
  63. package/src/exportJob.ts +232 -0
  64. package/src/exportProgress.ts +391 -0
  65. package/src/exportProgressPump.ts +190 -0
  66. package/src/exporter.ts +655 -0
  67. package/src/index.ts +276 -0
  68. package/src/managedSources.ts +252 -0
  69. package/src/media3.ts +412 -0
  70. package/src/mediaStore.ts +177 -0
  71. package/src/memoryGuard.ts +118 -0
  72. package/src/mixSources.ts +82 -0
  73. package/src/overlayStaging.ts +216 -0
  74. package/src/playbackLifecycle.ts +137 -0
  75. package/src/player.ts +39 -0
  76. package/src/playlistSession.ts +125 -0
  77. package/src/previewAudioDiagnostics.ts +67 -0
  78. package/src/previewAudioSession.ts +168 -0
  79. package/src/previewCadence.ts +950 -0
  80. package/src/previewMix.ts +212 -0
  81. package/src/previewTrace.ts +348 -0
  82. package/src/spikeExcursions.ts +405 -0
  83. package/src/submissionScoring.ts +680 -0
  84. package/src/surfaceOwner.ts +264 -0
  85. package/src/surfaceRecovery.ts +121 -0
  86. package/src/syncTrace.ts +189 -0
  87. package/src/thumbnailProvider.ts +163 -0
  88. package/src/tracePhases.ts +217 -0
  89. package/src/useCompositionPlayback.ts +327 -0
  90. package/src/useMedia3Playback.ts +492 -0
  91. package/src/usePreviewAudio.ts +305 -0
  92. package/src/useVideoSurface.ts +282 -0
@@ -0,0 +1,337 @@
1
+ package dev.pictor.video
2
+
3
+ import android.content.Context
4
+ import android.media.MediaCodec
5
+ import android.media.MediaExtractor
6
+ import android.media.MediaFormat
7
+ import android.media.MediaMuxer
8
+ import java.io.File
9
+ import java.nio.ByteBuffer
10
+ import org.json.JSONObject
11
+
12
+ /**
13
+ * Removing an encoder's priming, in one place, for everything that publishes
14
+ * audio.
15
+ *
16
+ * ## Why it is shared
17
+ *
18
+ * An AAC encoder emits priming samples before any content: they are delay, not
19
+ * sound. The export already removed them at the final mux, and preview did not
20
+ * — so a preview played about 43 ms behind its picture while an export of the
21
+ * same project was aligned. Two paths publishing audio, one of them correcting.
22
+ *
23
+ * Rather than teach preview the same trick, the trick moved here. There is now
24
+ * one definition of "remove the priming", and a third caller cannot be added
25
+ * without using it.
26
+ *
27
+ * ## Every unit is kept, and the start moves instead
28
+ *
29
+ * Dropping the priming access units was the obvious correction and it destroys
30
+ * content. Measured, on one deterministic source with a marker at sample zero:
31
+ *
32
+ * | | frames | peak over first 60 ms | markers |
33
+ * |---|---|---|---|
34
+ * | full decode | 242688 | 0.309 | 42, 1042, 2542, 4942 |
35
+ * | decode minus 2045 frames, in memory | 240643 | 0.309 | 0, 1000, 2500, 4900 |
36
+ * | two access units dropped | 240640 | **0** | 999, 2499, 4899 |
37
+ * | timestamps shifted, units kept | 240643 | 0.309 | 0, 1000, 2500, 4900 |
38
+ *
39
+ * The unit-dropping result differs from the ideal by three samples and loses
40
+ * the marker completely, so it was never about the samples: an AAC decoder
41
+ * reconstructs each frame from an overlapping transform of its predecessor, and
42
+ * a unit that never reaches it takes the first real frame down with it.
43
+ *
44
+ * So nothing is dropped. Every unit is written and the first ones present
45
+ * *before* zero, which is how AAC priming is conventionally signalled. The
46
+ * shift is sample-exact — there is no grid to round to when nothing is being
47
+ * removed, which also retires the residual the old approach had to report.
48
+ *
49
+ * `MediaMuxer` turns the negative start into container metadata: the written
50
+ * file reports `encoder-delay: 2045`, and `MediaExtractor` reads it back. A
51
+ * decoder that starts at zero has already seen the priming and has its context;
52
+ * one that honours the delay skips exactly it and nothing else.
53
+ *
54
+ * ## Measured, never assumed
55
+ *
56
+ * The frame count comes from [AacPriming.measure] for the encoder configuration
57
+ * actually in the file. Hard-coding 2048 — or 43 ms — would be right for one
58
+ * encoder on one device and quietly wrong elsewhere.
59
+ *
60
+ * ## Payload bytes are carried, not re-encoded
61
+ *
62
+ * Every retained access unit is copied byte for byte. Only its timestamp
63
+ * changes, and only by the shift the dropped units account for.
64
+ */
65
+ internal object PrimingTrim {
66
+
67
+ /** AAC frames per access unit. Not configurable; it is what AAC is. */
68
+ private const val FRAME_SIZE = 1024
69
+
70
+ /** What a trim did, for reporting and for assertions. */
71
+ class Plan(
72
+ val primingFrames: Int,
73
+ val unitsDropped: Int,
74
+ val residualFrames: Int,
75
+ val shiftUs: Long,
76
+ ) {
77
+ fun into(out: JSONObject): JSONObject =
78
+ out.put("primingFrames", primingFrames)
79
+ .put("primingUnitsDropped", unitsDropped)
80
+ .put("primingResidualFrames", residualFrames)
81
+ .put("primingShiftUs", shiftUs)
82
+ }
83
+
84
+ /**
85
+ * How far to move the start, given a measured delay and the track's rate.
86
+ *
87
+ * Sample-exact. `unitsDropped` is always zero and stays in the report so a
88
+ * reader can see that nothing was discarded — it is the field that used to
89
+ * be two.
90
+ */
91
+ fun plan(primingFrames: Int, sampleRate: Int): Plan {
92
+ val rate = if (sampleRate > 0) sampleRate else AudioMixer.SAMPLE_RATE
93
+ return Plan(
94
+ primingFrames = primingFrames,
95
+ unitsDropped = 0,
96
+ residualFrames = 0,
97
+ shiftUs = primingFrames.toLong() * 1_000_000L / rate,
98
+ )
99
+ }
100
+
101
+ /**
102
+ * The delay this file's encoder configuration produces.
103
+ *
104
+ * Read from the track rather than passed in, so a caller cannot supply a
105
+ * confident number about a different file.
106
+ */
107
+ fun measureFor(context: Context, uri: String): Int {
108
+ val extractor = MediaExtractor()
109
+ try {
110
+ if (uri.startsWith("/")) extractor.setDataSource(uri)
111
+ else extractor.setDataSource(context, android.net.Uri.parse(uri), null)
112
+ for (i in 0 until extractor.trackCount) {
113
+ val f = extractor.getTrackFormat(i)
114
+ if (!(f.getString(MediaFormat.KEY_MIME) ?: "").startsWith("audio/")) continue
115
+ val rate = f.getInteger(MediaFormat.KEY_SAMPLE_RATE)
116
+ val channels = f.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
117
+ val bitrate =
118
+ if (f.containsKey(MediaFormat.KEY_BIT_RATE)) f.getInteger(MediaFormat.KEY_BIT_RATE)
119
+ else 128_000
120
+ return AacPriming.measure(rate, channels, bitrate).delayFrames
121
+ }
122
+ } catch (e: Exception) {
123
+ // A file that cannot be opened has no priming to remove; the caller
124
+ // will fail on it for its own reasons.
125
+ } finally {
126
+ runCatching { extractor.release() }
127
+ }
128
+ return 0
129
+ }
130
+
131
+ /**
132
+ * Copy one track's samples, dropping priming units and rebasing the rest.
133
+ *
134
+ * Returns the number written, or `-1` if it stopped because it was asked to
135
+ * — a negative is not a count, it is the reason.
136
+ *
137
+ * `limitUs` drops samples starting at or after the compiled end: an encoder
138
+ * emits whole frames, so the last one runs past the timeline and would make
139
+ * the container longer than the project.
140
+ */
141
+ fun copyTrack(
142
+ extractor: MediaExtractor,
143
+ muxer: MediaMuxer,
144
+ track: Int,
145
+ limitUs: Long,
146
+ plan: Plan,
147
+ beatEpoch: Long,
148
+ keepGoing: () -> Boolean,
149
+ beatTag: String = "muxBatch",
150
+ ): Int {
151
+ val buffer = ByteBuffer.allocate(4 * 1024 * 1024)
152
+ val info = MediaCodec.BufferInfo()
153
+ var count = 0
154
+ var skipped = 0
155
+ while (true) {
156
+ if (!keepGoing()) return -1
157
+ buffer.clear()
158
+ val size = extractor.readSampleData(buffer, 0)
159
+ if (size < 0) break
160
+ /*
161
+ Timestamps are carried across unchanged until the rebase below.
162
+
163
+ Clamping a negative one to zero would move priming samples into the
164
+ audible timeline and shift everything after them; the muxer is
165
+ given exactly what the source said, less the shift.
166
+ */
167
+ var pts = extractor.sampleTime
168
+ if (skipped < plan.unitsDropped) {
169
+ // Priming units are discarded whole; nothing decodable is lost
170
+ // because nothing in them is content.
171
+ skipped += 1
172
+ if (!extractor.advance()) break
173
+ continue
174
+ }
175
+ /*
176
+ Rebased, then limited.
177
+
178
+ The tail limit is a statement about the finished timeline, so it
179
+ has to be applied to the timestamps the file will actually carry —
180
+ comparing against the pre-shift value would trim a different amount
181
+ of audio than the caller asked for.
182
+ */
183
+ pts -= plan.shiftUs
184
+ if (limitUs > 0 && pts >= limitUs) break
185
+ info.offset = 0
186
+ info.size = size
187
+ info.presentationTimeUs = pts
188
+ info.flags = extractor.sampleFlags
189
+ muxer.writeSampleData(track, buffer, info)
190
+ count += 1
191
+ // A batch rather than every sample: enough to prove the copy is
192
+ // alive without making the beat itself the work.
193
+ if (count % 8 == 0) ExportHeartbeat.beat(beatTag, beatEpoch)
194
+ if (!extractor.advance()) break
195
+ }
196
+ return count
197
+ }
198
+
199
+ /**
200
+ * Rewrite an audio-only file keeping every access unit, and move the
201
+ * presentation start instead.
202
+ *
203
+ * ## Why the units have to stay
204
+ *
205
+ * Measured: dropping the two priming units removes the marker at timeline
206
+ * zero completely — decoded peak over the first 60 ms falls to zero —
207
+ * while removing exactly the same delay from a full decode, in memory,
208
+ * preserves it. The two results differ by three samples, so it is not the
209
+ * samples. An AAC decoder reconstructs each frame from an overlapping
210
+ * transform of its predecessor, so a unit that never reaches it takes the
211
+ * first real frame down with it.
212
+ *
213
+ * So every unit is written, and the first ones simply present *before* zero.
214
+ * A decoder that starts at zero has already seen them and has its overlap
215
+ * context; a decoder that honours the negative span skips exactly the delay
216
+ * and nothing else.
217
+ *
218
+ * Payload bytes are copied unchanged, as in [copyTrack]. Only timestamps
219
+ * move, and only by the measured delay.
220
+ */
221
+ fun shiftAudioFile(
222
+ context: Context,
223
+ source: File,
224
+ into: File,
225
+ /**
226
+ * The compiled end, or 0 to keep everything.
227
+ *
228
+ * The same tail policy the export mux applies: a unit starting at or
229
+ * after the timeline's end is entirely past the last frame and is
230
+ * dropped, while one that merely *extends* past the end is kept. An
231
+ * encoder emits whole frames, so the alternative — cutting at the exact
232
+ * sample — would need a re-encode, and the leftover is at most one
233
+ * frame of padding rather than the 643 frames a missing limit leaves.
234
+ */
235
+ limitUs: Long = 0,
236
+ keepGoing: () -> Boolean = { true },
237
+ ): JSONObject {
238
+ val out = JSONObject()
239
+ val extractor = MediaExtractor()
240
+ var muxer: MediaMuxer? = null
241
+ try {
242
+ extractor.setDataSource(source.absolutePath)
243
+ var track = -1
244
+ for (i in 0 until extractor.trackCount) {
245
+ val mime = extractor.getTrackFormat(i).getString(MediaFormat.KEY_MIME) ?: continue
246
+ if (mime.startsWith("audio/")) { track = i; break }
247
+ }
248
+ if (track < 0) return out.put("ok", false).put("error", "no audio track")
249
+
250
+ val format = extractor.getTrackFormat(track)
251
+ val rate =
252
+ if (format.containsKey(MediaFormat.KEY_SAMPLE_RATE))
253
+ format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
254
+ else AudioMixer.SAMPLE_RATE
255
+ val primingFrames = measureFor(context, source.absolutePath)
256
+ // Sample-exact, not unit-quantised: nothing is being dropped, so
257
+ // there is no grid to round to.
258
+ val shiftUs = primingFrames.toLong() * 1_000_000L / rate
259
+
260
+ extractor.selectTrack(track)
261
+ muxer = MediaMuxer(into.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
262
+ val outTrack = muxer.addTrack(format)
263
+ muxer.start()
264
+
265
+ val buffer = ByteBuffer.allocate(4 * 1024 * 1024)
266
+ val info = MediaCodec.BufferInfo()
267
+ var count = 0
268
+ var negatives = 0
269
+ var dropped = 0
270
+ var firstPts = Long.MIN_VALUE
271
+ while (true) {
272
+ if (!keepGoing()) return out.put("ok", false).put("cancelled", true)
273
+ buffer.clear()
274
+ val size = extractor.readSampleData(buffer, 0)
275
+ if (size < 0) break
276
+ val pts = extractor.sampleTime - shiftUs
277
+ // Limited on the shifted timestamp, because that is what the
278
+ // file will carry; comparing the pre-shift value would trim a
279
+ // different amount than the caller asked for.
280
+ if (limitUs > 0 && pts >= limitUs) { dropped += 1; break }
281
+ if (firstPts == Long.MIN_VALUE) firstPts = pts
282
+ if (pts < 0) negatives += 1
283
+ info.offset = 0
284
+ info.size = size
285
+ info.presentationTimeUs = pts
286
+ info.flags = extractor.sampleFlags
287
+ muxer.writeSampleData(track, buffer, info)
288
+ count += 1
289
+ if (!extractor.advance()) break
290
+ }
291
+
292
+ return out.put("ok", true)
293
+ .put("samples", count)
294
+ .put("primingFrames", primingFrames)
295
+ .put("shiftUs", shiftUs)
296
+ .put("negativePtsUnits", negatives)
297
+ .put("firstPtsUs", firstPts)
298
+ .put("tailUnitsDropped", dropped)
299
+ .put("limitUs", limitUs)
300
+ } catch (e: Exception) {
301
+ return out.put("ok", false).put("error", e.javaClass.simpleName + ": " + e.message)
302
+ } finally {
303
+ runCatching { muxer?.stop() }
304
+ runCatching { muxer?.release() }
305
+ runCatching { extractor.release() }
306
+ }
307
+ }
308
+
309
+ /** What a written container actually says about its start. */
310
+ fun inspect(file: File): JSONObject {
311
+ val out = JSONObject()
312
+ val extractor = MediaExtractor()
313
+ try {
314
+ extractor.setDataSource(file.absolutePath)
315
+ for (i in 0 until extractor.trackCount) {
316
+ val f = extractor.getTrackFormat(i)
317
+ if (!(f.getString(MediaFormat.KEY_MIME) ?: "").startsWith("audio/")) continue
318
+ out.put("mime", f.getString(MediaFormat.KEY_MIME))
319
+ if (f.containsKey(MediaFormat.KEY_DURATION)) {
320
+ out.put("durationUs", f.getLong(MediaFormat.KEY_DURATION))
321
+ }
322
+ for (key in listOf("encoder-delay", "encoder-padding", "exo-pts-offset-us")) {
323
+ if (f.containsKey(key)) out.put(key, f.getInteger(key))
324
+ }
325
+ extractor.selectTrack(i)
326
+ out.put("firstSampleTimeUs", extractor.sampleTime)
327
+ break
328
+ }
329
+ } catch (e: Exception) {
330
+ out.put("error", e.javaClass.simpleName)
331
+ } finally {
332
+ runCatching { extractor.release() }
333
+ }
334
+ return out
335
+ }
336
+
337
+ }
@@ -0,0 +1,97 @@
1
+ package dev.pictor.video
2
+
3
+ import android.content.Context
4
+ import android.media.MediaCodecInfo
5
+ import android.media.MediaCodecList
6
+ import android.media.MediaFormat
7
+ import android.media.metrics.LogSessionId
8
+ import android.os.Build
9
+ import androidx.annotation.RequiresApi
10
+ import androidx.media3.common.Format
11
+ import androidx.media3.common.MimeTypes
12
+ import androidx.media3.common.util.UnstableApi
13
+ import androidx.media3.transformer.Codec
14
+ import androidx.media3.transformer.DefaultCodec
15
+ import androidx.media3.transformer.DefaultEncoderFactory
16
+ import androidx.media3.transformer.ExportException
17
+
18
+ /**
19
+ * An encoder told to repeat its last frame if nothing new arrives.
20
+ *
21
+ * `KEY_REPEAT_PREVIOUS_FRAME_AFTER` is the only frame-*repetition* mechanism
22
+ * Media3 can reach without a custom shader program, and slow motion needs
23
+ * repetition: `FrameDropEffect` can thin a stream toward a target rate but
24
+ * cannot thicken one. This exists to find out whether that mechanism is a
25
+ * production answer or only a plausible-looking one.
26
+ *
27
+ * **It is expected to fail a specific test, and the test was chosen first.** The
28
+ * encoder sits *downstream* of the GL pipeline, so a frame it repeats was never
29
+ * drawn by the scene interpreter. If the repeated frames do not pass through the
30
+ * interpreter then overlays, transforms and opacity cannot be applied to them,
31
+ * and the mechanism is rejected however correct the timestamps look. Measuring
32
+ * scene executions against output frames is what tells those apart.
33
+ *
34
+ * Gate-only. Nothing here is reachable from a public exporter.
35
+ */
36
+ @UnstableApi
37
+ @RequiresApi(Build.VERSION_CODES.S)
38
+ internal class RepeatingEncoderFactory(
39
+ private val context: Context,
40
+ /** Repeat the previous frame after this long with no new one. */
41
+ private val repeatAfterUs: Long,
42
+ private val targetFps: Int,
43
+ ) : Codec.EncoderFactory {
44
+
45
+ private val fallback = DefaultEncoderFactory.Builder(context).build()
46
+
47
+ override fun createForAudioEncoding(format: Format, logSessionId: LogSessionId?): Codec =
48
+ fallback.createForAudioEncoding(format, logSessionId)
49
+
50
+ override fun createForVideoEncoding(format: Format, logSessionId: LogSessionId?): Codec {
51
+ val mime = MimeTypes.VIDEO_H264
52
+ val name = encoderFor(mime)
53
+ ?: throw ExportException.createForCodec(
54
+ IllegalStateException("no encoder for $mime"),
55
+ ExportException.ERROR_CODE_ENCODING_FAILED,
56
+ ExportException.CodecInfo(
57
+ /* configurationFormat= */ format.toString(),
58
+ /* isVideo= */ true,
59
+ /* isDecoder= */ false,
60
+ /* name= */ null,
61
+ ),
62
+ )
63
+
64
+ val mediaFormat = MediaFormat.createVideoFormat(mime, format.width, format.height)
65
+ mediaFormat.setInteger(
66
+ MediaFormat.KEY_COLOR_FORMAT,
67
+ MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface,
68
+ )
69
+ // A bitrate the encoder will accept for this size; the gate compares
70
+ // cadence, not quality, and a missing bitrate is a configure failure.
71
+ val bitrate = if (format.averageBitrate != Format.NO_VALUE) format.averageBitrate else 4_000_000
72
+ mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, bitrate)
73
+ mediaFormat.setInteger(MediaFormat.KEY_FRAME_RATE, targetFps)
74
+ mediaFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1)
75
+ if (repeatAfterUs > 0) {
76
+ mediaFormat.setLong(MediaFormat.KEY_REPEAT_PREVIOUS_FRAME_AFTER, repeatAfterUs)
77
+ }
78
+
79
+ return DefaultCodec(
80
+ context,
81
+ format,
82
+ mediaFormat,
83
+ name,
84
+ /* isDecoder= */ false,
85
+ /* outputSurface= */ null,
86
+ )
87
+ }
88
+
89
+ private fun encoderFor(mime: String): String? {
90
+ val list = MediaCodecList(MediaCodecList.REGULAR_CODECS)
91
+ for (info in list.codecInfos) {
92
+ if (!info.isEncoder) continue
93
+ if (info.supportedTypes.any { it.equals(mime, ignoreCase = true) }) return info.name
94
+ }
95
+ return null
96
+ }
97
+ }
@@ -0,0 +1,169 @@
1
+ package dev.pictor.video
2
+
3
+ import org.json.JSONArray
4
+ import org.json.JSONObject
5
+
6
+ /**
7
+ * A resolved scene, validated, in the form the GL interpreter executes.
8
+ *
9
+ * **Native never receives a project.** It receives a scene that
10
+ * `resolveVideoScene` already produced — ids, indices and numbers — and its only
11
+ * job is to refuse anything it cannot faithfully execute and then execute the
12
+ * rest. There is no timeline arithmetic here, no window mapping, no
13
+ * placement decision: those exist once, in TypeScript, and this would be the
14
+ * second copy if it had them.
15
+ *
16
+ * ## Refuse, never skip
17
+ *
18
+ * Every unknown command, unsupported version or malformed field fails the whole
19
+ * run before Transformer starts. A skipped command is a frame that is quietly
20
+ * missing something the timeline said was there, encoded permanently — and
21
+ * unlike preview, nothing downstream will ever notice.
22
+ */
23
+ internal class SceneProgram private constructor(
24
+ val timeUs: Long,
25
+ val projectWidth: Int,
26
+ val projectHeight: Int,
27
+ val clearColor: Int,
28
+ val clipId: String,
29
+ val windowIndex: Int,
30
+ ) {
31
+ /** What went wrong, or null if nothing did. */
32
+ class Invalid(val reason: String)
33
+
34
+ companion object {
35
+ /** The only schema this build executes. */
36
+ const val SUPPORTED_VERSION = 2
37
+
38
+ /** Commands this spike implements. Anything else is refused, not skipped. */
39
+ private val SUPPORTED_OPS = setOf("clear", "video")
40
+
41
+ /**
42
+ * Parse and validate, or say why not.
43
+ *
44
+ * Everything is checked *before* Transformer is started: a run that
45
+ * fails halfway has already written a partial file and taken an encoder
46
+ * with it, and none of these failures need to get that far.
47
+ */
48
+ fun parse(json: String): Pair<SceneProgram?, Invalid?> {
49
+ val root = try {
50
+ JSONObject(json)
51
+ } catch (e: Exception) {
52
+ return null to Invalid("malformed JSON: ${e.message}")
53
+ }
54
+
55
+ val version = root.optInt("version", -1)
56
+ if (version != SUPPORTED_VERSION) {
57
+ return null to Invalid("unsupported scene version $version, expected $SUPPORTED_VERSION")
58
+ }
59
+
60
+ if (!root.has("timeUs") || !root.has("projectSize") || !root.has("commands")) {
61
+ return null to Invalid("scene is missing timeUs, projectSize or commands")
62
+ }
63
+ val timeUs = root.optLong("timeUs", -1)
64
+ if (timeUs < 0) return null to Invalid("timeUs is not a non-negative integer")
65
+
66
+ val size = root.optJSONObject("projectSize")
67
+ ?: return null to Invalid("projectSize is not an object")
68
+ val width = size.optDouble("width", Double.NaN)
69
+ val height = size.optDouble("height", Double.NaN)
70
+ if (!finite(width) || !finite(height) || width <= 0 || height <= 0) {
71
+ return null to Invalid("projectSize is not a positive finite size")
72
+ }
73
+
74
+ val commands: JSONArray = root.optJSONArray("commands")
75
+ ?: return null to Invalid("commands is not an array")
76
+ if (commands.length() == 0) return null to Invalid("commands is empty")
77
+
78
+ var clearColor: Int? = null
79
+ var clipId: String? = null
80
+ var windowIndex = -1
81
+
82
+ for (i in 0 until commands.length()) {
83
+ val command = commands.optJSONObject(i)
84
+ ?: return null to Invalid("command $i is not an object")
85
+ val op = command.optString("op", "")
86
+ if (op !in SUPPORTED_OPS) {
87
+ // Includes `overlay`, which this spike does not implement.
88
+ // Refusing is the point: a silently dropped overlay is a
89
+ // wrong frame nothing later can detect.
90
+ return null to Invalid("command $i has unsupported op '$op'")
91
+ }
92
+ when (op) {
93
+ "clear" -> {
94
+ // Order is part of the representation, not a convention:
95
+ // the resolver puts `clear` first and an interpreter that
96
+ // accepted it elsewhere would be deciding paint order.
97
+ if (i != 0) return null to Invalid("clear must be the first command, found at $i")
98
+ val color = parseColor(command.optString("color", ""))
99
+ ?: return null to Invalid("clear has an unparseable color")
100
+ clearColor = color
101
+ }
102
+ "video" -> {
103
+ if (clearColor == null) return null to Invalid("video command before any clear")
104
+ if (clipId != null) return null to Invalid("more than one video command")
105
+ val id = command.optString("clipId", "")
106
+ if (id.isEmpty()) return null to Invalid("video command has no clipId")
107
+ val window = command.optInt("windowIndex", -1)
108
+ if (window < 0) return null to Invalid("video command has no windowIndex")
109
+ // This gate exports a single source, so window 0 is the
110
+ // only identity the media can satisfy. A scene naming
111
+ // another one is describing a timeline this run does not
112
+ // have, and running it would encode the right pixels
113
+ // under the wrong claim.
114
+ if (window != 0) {
115
+ return null to Invalid("windowIndex $window has no window in a single-source run")
116
+ }
117
+ val matrix = command.optJSONArray("matrix")
118
+ ?: return null to Invalid("video command has no matrix")
119
+ if (matrix.length() != 6) return null to Invalid("matrix is not six numbers")
120
+ for (k in 0 until 6) {
121
+ if (!finite(matrix.optDouble(k, Double.NaN))) {
122
+ return null to Invalid("matrix[$k] is not finite")
123
+ }
124
+ }
125
+ // This spike executes identity video only. A transform is
126
+ // a real command it cannot honour, so it is refused rather
127
+ // than approximated.
128
+ if (!isIdentity(matrix)) {
129
+ return null to Invalid("only the identity matrix is supported in this spike")
130
+ }
131
+ val opacity = command.optDouble("opacity", Double.NaN)
132
+ if (!finite(opacity)) return null to Invalid("opacity is not finite")
133
+ if (opacity != 1.0) {
134
+ return null to Invalid("only full opacity is supported in this spike")
135
+ }
136
+ clipId = id
137
+ windowIndex = window
138
+ }
139
+ }
140
+ }
141
+
142
+ if (clearColor == null) return null to Invalid("no clear command")
143
+ if (clipId == null) return null to Invalid("no video command")
144
+
145
+ return SceneProgram(
146
+ timeUs = timeUs,
147
+ projectWidth = width.toInt(),
148
+ projectHeight = height.toInt(),
149
+ clearColor = clearColor,
150
+ clipId = clipId,
151
+ windowIndex = windowIndex,
152
+ ) to null
153
+ }
154
+
155
+ private fun finite(value: Double) = !value.isNaN() && !value.isInfinite()
156
+
157
+ private fun isIdentity(m: JSONArray): Boolean =
158
+ m.optDouble(0) == 1.0 && m.optDouble(1) == 0.0 && m.optDouble(2) == 0.0 &&
159
+ m.optDouble(3) == 1.0 && m.optDouble(4) == 0.0 && m.optDouble(5) == 0.0
160
+
161
+ /** `#aarrggbb` or `#rrggbb` to packed ARGB. */
162
+ private fun parseColor(hex: String): Int? {
163
+ val raw = hex.removePrefix("#")
164
+ if (raw.length != 6 && raw.length != 8) return null
165
+ val value = raw.toLongOrNull(16) ?: return null
166
+ return if (raw.length == 8) value.toInt() else (0xff000000L or value).toInt()
167
+ }
168
+ }
169
+ }