@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.
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/android/CMakeLists.txt +30 -0
- package/android/build.gradle +119 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/cpp/pictor_overlaystage.cpp +372 -0
- package/android/src/main/cpp/pictor_publish.cpp +87 -0
- package/android/src/main/cpp/pictor_videosurface.cpp +735 -0
- package/android/src/main/java/dev/pictor/video/AacPriming.kt +261 -0
- package/android/src/main/java/dev/pictor/video/AudioMixer.kt +1112 -0
- package/android/src/main/java/dev/pictor/video/AudioProbe.kt +162 -0
- package/android/src/main/java/dev/pictor/video/CadenceShaderProgram.kt +337 -0
- package/android/src/main/java/dev/pictor/video/CodecLedger.kt +126 -0
- package/android/src/main/java/dev/pictor/video/CompositionSpike.kt +978 -0
- package/android/src/main/java/dev/pictor/video/Diagnostics.kt +54 -0
- package/android/src/main/java/dev/pictor/video/EncoderGate.kt +476 -0
- package/android/src/main/java/dev/pictor/video/ExportGate.kt +1974 -0
- package/android/src/main/java/dev/pictor/video/ExportHeartbeat.kt +72 -0
- package/android/src/main/java/dev/pictor/video/ExportProgress.kt +103 -0
- package/android/src/main/java/dev/pictor/video/FrameClock.kt +591 -0
- package/android/src/main/java/dev/pictor/video/FramePending.kt +181 -0
- package/android/src/main/java/dev/pictor/video/GateAudio.kt +456 -0
- package/android/src/main/java/dev/pictor/video/GateDestination.kt +445 -0
- package/android/src/main/java/dev/pictor/video/GateFrames.kt +662 -0
- package/android/src/main/java/dev/pictor/video/GateMux.kt +185 -0
- package/android/src/main/java/dev/pictor/video/ManagedSources.kt +333 -0
- package/android/src/main/java/dev/pictor/video/Media3Player.kt +934 -0
- package/android/src/main/java/dev/pictor/video/MediaStoreSave.kt +419 -0
- package/android/src/main/java/dev/pictor/video/OverlayResources.kt +169 -0
- package/android/src/main/java/dev/pictor/video/PictorVideoModule.kt +1612 -0
- package/android/src/main/java/dev/pictor/video/PictorVideoPackage.kt +26 -0
- package/android/src/main/java/dev/pictor/video/PlaybackAnchor.kt +161 -0
- package/android/src/main/java/dev/pictor/video/PreviewAudioPlayer.kt +291 -0
- package/android/src/main/java/dev/pictor/video/PreviewConfig.kt +96 -0
- package/android/src/main/java/dev/pictor/video/PreviewMix.kt +396 -0
- package/android/src/main/java/dev/pictor/video/PreviewSync.kt +374 -0
- package/android/src/main/java/dev/pictor/video/PreviewTrace.kt +478 -0
- package/android/src/main/java/dev/pictor/video/PrimingTrim.kt +337 -0
- package/android/src/main/java/dev/pictor/video/RepeatingEncoderFactory.kt +97 -0
- package/android/src/main/java/dev/pictor/video/SceneProgram.kt +169 -0
- package/android/src/main/java/dev/pictor/video/SceneProgramNative.kt +299 -0
- package/android/src/main/java/dev/pictor/video/SkiaPassThroughEffect.kt +695 -0
- package/android/src/main/java/dev/pictor/video/SurfaceAttachment.kt +110 -0
- package/android/src/main/java/dev/pictor/video/ThumbnailDecoder.kt +171 -0
- package/android/src/main/java/dev/pictor/video/VideoSurface.kt +394 -0
- package/app.plugin.js +43 -0
- package/dist/index.d.mts +3622 -0
- package/dist/index.d.ts +3622 -0
- package/dist/index.js +3737 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +3625 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +82 -0
- package/plugin/gradleProperties.d.ts +19 -0
- package/plugin/gradleProperties.js +64 -0
- package/react-native.config.js +13 -0
- package/src/NativePictorVideo.ts +574 -0
- package/src/audioProbe.ts +72 -0
- package/src/compositionSpike.ts +305 -0
- package/src/convergence.ts +112 -0
- package/src/encoderGate.ts +66 -0
- package/src/exportDestination.ts +149 -0
- package/src/exportJob.ts +232 -0
- package/src/exportProgress.ts +391 -0
- package/src/exportProgressPump.ts +190 -0
- package/src/exporter.ts +655 -0
- package/src/index.ts +276 -0
- package/src/managedSources.ts +252 -0
- package/src/media3.ts +412 -0
- package/src/mediaStore.ts +177 -0
- package/src/memoryGuard.ts +118 -0
- package/src/mixSources.ts +82 -0
- package/src/overlayStaging.ts +216 -0
- package/src/playbackLifecycle.ts +137 -0
- package/src/player.ts +39 -0
- package/src/playlistSession.ts +125 -0
- package/src/previewAudioDiagnostics.ts +67 -0
- package/src/previewAudioSession.ts +168 -0
- package/src/previewCadence.ts +950 -0
- package/src/previewMix.ts +212 -0
- package/src/previewTrace.ts +348 -0
- package/src/spikeExcursions.ts +405 -0
- package/src/submissionScoring.ts +680 -0
- package/src/surfaceOwner.ts +264 -0
- package/src/surfaceRecovery.ts +121 -0
- package/src/syncTrace.ts +189 -0
- package/src/thumbnailProvider.ts +163 -0
- package/src/tracePhases.ts +217 -0
- package/src/useCompositionPlayback.ts +327 -0
- package/src/useMedia3Playback.ts +492 -0
- package/src/usePreviewAudio.ts +305 -0
- 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
|
+
}
|