@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,695 @@
|
|
|
1
|
+
package dev.pictor.video
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.opengl.EGL14
|
|
5
|
+
import android.opengl.GLES20
|
|
6
|
+
import androidx.media3.common.VideoFrameProcessingException
|
|
7
|
+
import androidx.media3.common.util.GlProgram
|
|
8
|
+
import androidx.media3.common.util.Size
|
|
9
|
+
import androidx.media3.common.util.UnstableApi
|
|
10
|
+
import androidx.media3.effect.BaseGlShaderProgram
|
|
11
|
+
import androidx.media3.effect.GlEffect
|
|
12
|
+
import java.util.concurrent.atomic.AtomicLong
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The gate's effect: a pass-through that proves the boundary is usable.
|
|
16
|
+
*
|
|
17
|
+
* Transformer owns the decode, the timing, the encode and the mux. This sits in
|
|
18
|
+
* the middle of its video chain and must come out the other side with the
|
|
19
|
+
* picture unchanged — which is what makes it a *gate* rather than a feature: any
|
|
20
|
+
* difference beyond the codec's own noise is this code's fault, and there is
|
|
21
|
+
* nowhere else for it to come from.
|
|
22
|
+
*
|
|
23
|
+
* ## Borrowed, never owned
|
|
24
|
+
*
|
|
25
|
+
* The input texture and the output framebuffer belong to Transformer. Nothing
|
|
26
|
+
* here deletes or retains either beyond the callback: `drawFrame` reads the
|
|
27
|
+
* input texture id and writes to whatever framebuffer
|
|
28
|
+
* [BaseGlShaderProgram] has bound, and holds neither afterwards. The only
|
|
29
|
+
* objects this class owns are its own [GlProgram] and its own instrumentation,
|
|
30
|
+
* and those are released exactly once, on the thread that made them.
|
|
31
|
+
*
|
|
32
|
+
* ## GL state is restored
|
|
33
|
+
*
|
|
34
|
+
* Transformer's chain expects the state it left. Viewport, blend, the bound
|
|
35
|
+
* texture and the active unit are captured on entry and put back on exit — a
|
|
36
|
+
* chain that draws correctly and leaves blending enabled corrupts whatever runs
|
|
37
|
+
* after it, and the failure appears in someone else's effect.
|
|
38
|
+
*
|
|
39
|
+
* ## It executes a scene, when given one
|
|
40
|
+
*
|
|
41
|
+
* With a [SceneProgram] it performs the scene's `clear` and then its identity
|
|
42
|
+
* `video` command. Without one it is the bare blit that established the frozen
|
|
43
|
+
* codec control — the two must produce identical pixels for an identity scene,
|
|
44
|
+
* which is exactly the gate.
|
|
45
|
+
*
|
|
46
|
+
* ## Not yet Skia
|
|
47
|
+
*
|
|
48
|
+
* This is the first half of the V3.3 gate. It establishes that the hook works,
|
|
49
|
+
* that presentation timestamps carry through untouched, that the expected EGL
|
|
50
|
+
* context is current on the frame-processing thread, and that release happens
|
|
51
|
+
* once — before a second Skia context is introduced into the same thread.
|
|
52
|
+
* Skia's draw replaces the blit below; everything around it is what has to be
|
|
53
|
+
* true either way.
|
|
54
|
+
*/
|
|
55
|
+
@UnstableApi
|
|
56
|
+
internal class SkiaPassThroughEffect(
|
|
57
|
+
private val program: SceneProgram? = null,
|
|
58
|
+
/**
|
|
59
|
+
* Fail deliberately, to prove cleanup.
|
|
60
|
+
*
|
|
61
|
+
* `-1` never fails. `0` fails after GL initialisation and before the first
|
|
62
|
+
* frame — the window where a program exists and nothing has drawn, which is
|
|
63
|
+
* the leak a naive teardown misses. A positive number fails after that many
|
|
64
|
+
* frames, mid-run, with an encoder and a muxer already holding state.
|
|
65
|
+
*/
|
|
66
|
+
private val failAfterFrames: Int = -1,
|
|
67
|
+
/** A compiled program, selected per frame by presentation timestamp. */
|
|
68
|
+
private val sceneProgram: SceneProgramNative? = null,
|
|
69
|
+
/**
|
|
70
|
+
* Where this shard sits on the timeline, compiled in TypeScript.
|
|
71
|
+
*
|
|
72
|
+
* Native's whole contribution to placement is one addition. The offset is
|
|
73
|
+
* the accumulated length of everything before this clip, and accumulating it
|
|
74
|
+
* is exactly the arithmetic that must not exist twice.
|
|
75
|
+
*/
|
|
76
|
+
private val timelineOffsetUs: Long = 0,
|
|
77
|
+
/** Rasters, already loaded and checked before Transformer started. */
|
|
78
|
+
private val overlayResources: OverlayResources? = null,
|
|
79
|
+
/**
|
|
80
|
+
* Fail during overlay drawing, after N draws.
|
|
81
|
+
*
|
|
82
|
+
* `0` fails on the first draw — after the texture exists but before anything
|
|
83
|
+
* has used it, which is the window where an upload could leak. A positive
|
|
84
|
+
* number fails mid-sequence with textures bound and a partial frame drawn.
|
|
85
|
+
*/
|
|
86
|
+
private val overlayFailAfterDraws: Int = -1,
|
|
87
|
+
) : GlEffect {
|
|
88
|
+
|
|
89
|
+
val overlayUploads = AtomicLong(0)
|
|
90
|
+
val overlayDraws = AtomicLong(0)
|
|
91
|
+
val overlayTexturesFreed = AtomicLong(0)
|
|
92
|
+
private val shaderPrograms = AtomicLong(0)
|
|
93
|
+
private val frames = AtomicLong(0)
|
|
94
|
+
private val releases = AtomicLong(0)
|
|
95
|
+
private val timestamps = StringBuilder()
|
|
96
|
+
private val glErrorCodes = mutableSetOf<Int>()
|
|
97
|
+
/** The EGL context seen on the frame-processing thread, as a handle. */
|
|
98
|
+
@Volatile private var contextHandle: Long = 0
|
|
99
|
+
/** The thread the frame processor called us on. */
|
|
100
|
+
@Volatile private var threadId: Long = 0
|
|
101
|
+
@Volatile private var threadName: String = ""
|
|
102
|
+
@Volatile private var threadChanges: Int = 0
|
|
103
|
+
@Volatile private var contextChanges: Int = 0
|
|
104
|
+
/** Distinct segments the program selected, and when each was first entered. */
|
|
105
|
+
private val segmentsSeen = mutableSetOf<Int>()
|
|
106
|
+
private val transitions = mutableListOf<String>()
|
|
107
|
+
@Volatile private var shader: PassThroughProgram? = null
|
|
108
|
+
|
|
109
|
+
fun frameCount(): Long = frames.get()
|
|
110
|
+
|
|
111
|
+
fun releaseCount(): Long = releases.get()
|
|
112
|
+
|
|
113
|
+
/** Every distinct `glGetError` seen. Empty means the chain stayed clean. */
|
|
114
|
+
fun glErrors(): String = synchronized(glErrorCodes) { glErrorCodes.joinToString(",") }
|
|
115
|
+
|
|
116
|
+
/** Presentation timestamps, in order, as the effect received them. */
|
|
117
|
+
fun timestamps(): String = synchronized(timestamps) { timestamps.toString() }
|
|
118
|
+
|
|
119
|
+
/** Times the current EGL context changed under the effect. Must stay 0. */
|
|
120
|
+
fun contextChangeCount(): Int = contextChanges
|
|
121
|
+
|
|
122
|
+
/** GL programs created and deleted, and the thread each happened on. */
|
|
123
|
+
fun programTrace(): String = synchronized(segmentsSeen) {
|
|
124
|
+
"segments=" + segmentsSeen.sorted().joinToString("/") +
|
|
125
|
+
" enteredAt=" + transitions.joinToString(",")
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
fun lifecycle(): String =
|
|
129
|
+
"created=${glCreated.get()} deleted=${glDeleted.get()} " +
|
|
130
|
+
"createThread=$createThread deleteThread=$deleteThread"
|
|
131
|
+
|
|
132
|
+
private val glCreated = AtomicLong(0)
|
|
133
|
+
private val glDeleted = AtomicLong(0)
|
|
134
|
+
@Volatile private var createThread: Long = 0
|
|
135
|
+
@Volatile private var deleteThread: Long = 0
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Who Transformer ran the effect on, and in which GL context.
|
|
139
|
+
*
|
|
140
|
+
* The invocation-boundary question turns on this: if the composition lives
|
|
141
|
+
* on another thread with another EGL context, then handing it this frame is
|
|
142
|
+
* a cross-context transfer with a synchronisation problem, not a function
|
|
143
|
+
* call.
|
|
144
|
+
*/
|
|
145
|
+
fun boundary(): String =
|
|
146
|
+
"ctx=$contextHandle thread=$threadId/$threadName ctxChanges=$contextChanges threadChanges=$threadChanges"
|
|
147
|
+
|
|
148
|
+
override fun toGlShaderProgram(context: Context, useHdr: Boolean): BaseGlShaderProgram {
|
|
149
|
+
val made = PassThroughProgram(useHdr)
|
|
150
|
+
shader = made
|
|
151
|
+
shaderPrograms.incrementAndGet()
|
|
152
|
+
return made
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Release the effect's own instrumentation.
|
|
157
|
+
*
|
|
158
|
+
* The shader program is released by Transformer, on the frame-processing
|
|
159
|
+
* thread, through `BaseGlShaderProgram.release`. Releasing it from here as
|
|
160
|
+
* well would be a second release on the wrong thread, which is the failure
|
|
161
|
+
* this counter exists to catch.
|
|
162
|
+
*/
|
|
163
|
+
fun release() {
|
|
164
|
+
releases.incrementAndGet()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private inner class PassThroughProgram(useHdr: Boolean) : BaseGlShaderProgram(useHdr, 1) {
|
|
168
|
+
private var glProgram: GlProgram? = null
|
|
169
|
+
private var released = false
|
|
170
|
+
private var outputWidth = 0
|
|
171
|
+
private var outputHeight = 0
|
|
172
|
+
private var projectWidth = 0
|
|
173
|
+
private var projectHeight = 0
|
|
174
|
+
|
|
175
|
+
/** Reused, so a per-frame transform allocates nothing. */
|
|
176
|
+
private val matrixScratch = floatArrayOf(1f, 0f, 0f, 1f, 0f, 0f)
|
|
177
|
+
private var opacity = 1f
|
|
178
|
+
private var segmentIndex = -1
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* One texture per resource, uploaded on first use and reused after.
|
|
182
|
+
*
|
|
183
|
+
* Not per frame: a large raster re-uploaded 300 times is 300 transfers
|
|
184
|
+
* of bytes that never changed. Freed in [release], which Transformer
|
|
185
|
+
* calls on the thread that created them.
|
|
186
|
+
*/
|
|
187
|
+
private val overlayTextures = HashMap<String, Int>()
|
|
188
|
+
private var overlayProgram: GlProgram? = null
|
|
189
|
+
|
|
190
|
+
override fun configure(inputWidth: Int, inputHeight: Int): Size {
|
|
191
|
+
// Project size, from the scene. The conversion below needs the space
|
|
192
|
+
// the matrix was written in, not the one it is rendered into.
|
|
193
|
+
projectWidth = sceneProgram?.projectWidth ?: program?.projectWidth ?: inputWidth
|
|
194
|
+
projectHeight = sceneProgram?.projectHeight ?: program?.projectHeight ?: inputHeight
|
|
195
|
+
/*
|
|
196
|
+
The output is the **project's** size, not this input's.
|
|
197
|
+
|
|
198
|
+
Each clip is encoded in its own pass and the parts are then
|
|
199
|
+
concatenated by copying samples, which writes every part's payload
|
|
200
|
+
under the *first* part's track format. A shard that sized its
|
|
201
|
+
output to its own source therefore produced a different
|
|
202
|
+
elementary stream — measured on a 540x960 clip beside two
|
|
203
|
+
1080x1920 ones: `960x540, level 512, csd-0 22 bytes` copied under
|
|
204
|
+
`1920x1080, level 2048, csd-0 23 bytes`. A decoder configured from
|
|
205
|
+
the declared codec-specific data cannot correctly decode those
|
|
206
|
+
samples, and the finished file reports one format for everything
|
|
207
|
+
inside it, so nothing downstream can notice.
|
|
208
|
+
|
|
209
|
+
Every shard must therefore agree on one output format, and the
|
|
210
|
+
project is the only thing that defines it. Sampling still uses the
|
|
211
|
+
input texture at whatever size it arrived — the video quad spans
|
|
212
|
+
the output and reads the texture across its whole extent, so a
|
|
213
|
+
smaller source is scaled up here rather than encoded small and
|
|
214
|
+
reinterpreted later.
|
|
215
|
+
|
|
216
|
+
Scene matrices and overlay quads are unaffected: both are in
|
|
217
|
+
project space and converted to clip space, which is what makes
|
|
218
|
+
this change a format decision rather than a geometry one.
|
|
219
|
+
*/
|
|
220
|
+
outputWidth = projectWidth
|
|
221
|
+
outputHeight = projectHeight
|
|
222
|
+
// GL initialisation, eagerly, so "after init and before the first
|
|
223
|
+
// frame" is a window that actually exists to fail in.
|
|
224
|
+
if (glProgram == null) {
|
|
225
|
+
glProgram = makeProgram()
|
|
226
|
+
glCreated.incrementAndGet()
|
|
227
|
+
createThread = Thread.currentThread().id
|
|
228
|
+
}
|
|
229
|
+
return Size(outputWidth, outputHeight)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
override fun drawFrame(inputTexId: Int, presentationTimeUs: Long) {
|
|
233
|
+
if (failAfterFrames >= 0 && frames.get() >= failAfterFrames) {
|
|
234
|
+
throw VideoFrameProcessingException(
|
|
235
|
+
RuntimeException("injected failure at frame ${'$'}{frames.get()}"),
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
recordContext()
|
|
239
|
+
recordTimestamp(presentationTimeUs)
|
|
240
|
+
|
|
241
|
+
// Captured, then restored below. Transformer's chain expects the
|
|
242
|
+
// state it left.
|
|
243
|
+
val savedViewport = IntArray(4)
|
|
244
|
+
GLES20.glGetIntegerv(GLES20.GL_VIEWPORT, savedViewport, 0)
|
|
245
|
+
val savedActive = IntArray(1)
|
|
246
|
+
GLES20.glGetIntegerv(GLES20.GL_ACTIVE_TEXTURE, savedActive, 0)
|
|
247
|
+
val blendWasOn = GLES20.glIsEnabled(GLES20.GL_BLEND)
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
/*
|
|
251
|
+
The scene's `clear`, executed rather than assumed redundant.
|
|
252
|
+
|
|
253
|
+
An identity video covers the output, so the clear is invisible
|
|
254
|
+
here — and it is still performed, because whether coverage
|
|
255
|
+
holds is not this interpreter's judgement to make. When a
|
|
256
|
+
transform or a partial opacity arrives it will be the only
|
|
257
|
+
thing standing between a fade and the previous frame.
|
|
258
|
+
*/
|
|
259
|
+
/*
|
|
260
|
+
A program selects per frame; a single scene applies to all of
|
|
261
|
+
them. Selection is a binary search over the compiled intervals
|
|
262
|
+
— native learns which interval this timestamp falls in and
|
|
263
|
+
nothing else about the timeline.
|
|
264
|
+
*/
|
|
265
|
+
val compiled = sceneProgram
|
|
266
|
+
if (compiled != null) {
|
|
267
|
+
val index = compiled.segmentAt(timelineOffsetUs + presentationTimeUs)
|
|
268
|
+
if (index < 0) {
|
|
269
|
+
throw VideoFrameProcessingException(
|
|
270
|
+
RuntimeException("no segment covers " + presentationTimeUs),
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
/*
|
|
274
|
+
The segment's first timestamp is recorded; its window is
|
|
275
|
+
not checked against a decoder.
|
|
276
|
+
|
|
277
|
+
There is no per-frame window signal in this path.
|
|
278
|
+
`FrameClock.windowIndex` belongs to the **preview**
|
|
279
|
+
player, and Transformer's effect receives a presentation
|
|
280
|
+
timestamp and nothing else — comparing the two produced a
|
|
281
|
+
confident false failure at the seam, from a counter
|
|
282
|
+
another pipeline had left behind. Deriving the window from
|
|
283
|
+
the timestamp instead would be timeline arithmetic in
|
|
284
|
+
native, which is the thing this design exists to avoid.
|
|
285
|
+
|
|
286
|
+
What *is* observable is which segment each timestamp
|
|
287
|
+
selected, so the transitions are recorded and checked
|
|
288
|
+
against the program's own boundaries by the caller.
|
|
289
|
+
*/
|
|
290
|
+
synchronized(segmentsSeen) {
|
|
291
|
+
if (segmentsSeen.add(index)) {
|
|
292
|
+
transitions.add(index.toString() + "@" + (timelineOffsetUs + presentationTimeUs))
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
compiled.matrixAt(index, matrixScratch)
|
|
296
|
+
opacity = compiled.opacityAt(index)
|
|
297
|
+
segmentIndex = index
|
|
298
|
+
val c = compiled.clearColorAt(index)
|
|
299
|
+
GLES20.glClearColor(
|
|
300
|
+
((c shr 16) and 0xff) / 255f,
|
|
301
|
+
((c shr 8) and 0xff) / 255f,
|
|
302
|
+
(c and 0xff) / 255f,
|
|
303
|
+
((c ushr 24) and 0xff) / 255f,
|
|
304
|
+
)
|
|
305
|
+
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
|
|
306
|
+
}
|
|
307
|
+
val scene = program
|
|
308
|
+
if (scene != null) {
|
|
309
|
+
opacity = 1f
|
|
310
|
+
matrixScratch[0] = 1f
|
|
311
|
+
matrixScratch[1] = 0f
|
|
312
|
+
matrixScratch[2] = 0f
|
|
313
|
+
matrixScratch[3] = 1f
|
|
314
|
+
matrixScratch[4] = 0f
|
|
315
|
+
matrixScratch[5] = 0f
|
|
316
|
+
val c = scene.clearColor
|
|
317
|
+
GLES20.glClearColor(
|
|
318
|
+
((c shr 16) and 0xff) / 255f,
|
|
319
|
+
((c shr 8) and 0xff) / 255f,
|
|
320
|
+
(c and 0xff) / 255f,
|
|
321
|
+
((c ushr 24) and 0xff) / 255f,
|
|
322
|
+
)
|
|
323
|
+
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
|
|
324
|
+
}
|
|
325
|
+
val prog = glProgram ?: makeProgram().also { glProgram = it }
|
|
326
|
+
prog.use()
|
|
327
|
+
prog.setSamplerTexIdUniform("uTexSampler", inputTexId, 0)
|
|
328
|
+
prog.setFloatsUniform("uTransform", clipSpaceTransform())
|
|
329
|
+
prog.setFloatUniform("uOpacity", opacity)
|
|
330
|
+
prog.setFloatUniform("uUseAttrCoord", 0f)
|
|
331
|
+
prog.setFloatUniform("uPremultiplied", 0f)
|
|
332
|
+
prog.setBufferAttribute("aTexCoord", OVERLAY_TEX_COORDS, 2)
|
|
333
|
+
/*
|
|
334
|
+
Source-over, onto the clear the scene already drew.
|
|
335
|
+
|
|
336
|
+
The order is the rule: background first, then the transformed
|
|
337
|
+
video at the clip's opacity. A partly transparent clip reveals
|
|
338
|
+
that background — the file stays opaque and never becomes an
|
|
339
|
+
alpha video.
|
|
340
|
+
|
|
341
|
+
Blending is left off at full opacity. `1·src + 0·dst` is src, so
|
|
342
|
+
it would be a no-op arithmetically, and skipping it keeps the
|
|
343
|
+
established max-delta-0 floor a property of the same code path
|
|
344
|
+
rather than of a rounding mode.
|
|
345
|
+
*/
|
|
346
|
+
val blending = opacity < 1f
|
|
347
|
+
if (blending) {
|
|
348
|
+
GLES20.glEnable(GLES20.GL_BLEND)
|
|
349
|
+
GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA)
|
|
350
|
+
} else {
|
|
351
|
+
GLES20.glDisable(GLES20.GL_BLEND)
|
|
352
|
+
}
|
|
353
|
+
prog.bindAttributesAndUniforms()
|
|
354
|
+
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
|
|
355
|
+
drawOverlays()
|
|
356
|
+
} catch (e: Exception) {
|
|
357
|
+
throw VideoFrameProcessingException(e)
|
|
358
|
+
} finally {
|
|
359
|
+
if (blendWasOn) GLES20.glEnable(GLES20.GL_BLEND) else GLES20.glDisable(GLES20.GL_BLEND)
|
|
360
|
+
GLES20.glActiveTexture(savedActive[0])
|
|
361
|
+
GLES20.glViewport(savedViewport[0], savedViewport[1], savedViewport[2], savedViewport[3])
|
|
362
|
+
recordGlError()
|
|
363
|
+
}
|
|
364
|
+
frames.incrementAndGet()
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
override fun release() {
|
|
368
|
+
// Once, on the thread Transformer calls it on — which is the thread
|
|
369
|
+
// the program was made on.
|
|
370
|
+
if (released) return
|
|
371
|
+
released = true
|
|
372
|
+
if (overlayTextures.isNotEmpty()) {
|
|
373
|
+
val ids = overlayTextures.values.toIntArray()
|
|
374
|
+
runCatching { GLES20.glDeleteTextures(ids.size, ids, 0) }
|
|
375
|
+
overlayTexturesFreed.addAndGet(ids.size.toLong())
|
|
376
|
+
overlayTextures.clear()
|
|
377
|
+
}
|
|
378
|
+
runCatching { overlayProgram?.delete() }
|
|
379
|
+
overlayProgram = null
|
|
380
|
+
runCatching { glProgram?.delete() }
|
|
381
|
+
if (glProgram != null) {
|
|
382
|
+
glDeleted.incrementAndGet()
|
|
383
|
+
deleteThread = Thread.currentThread().id
|
|
384
|
+
}
|
|
385
|
+
glProgram = null
|
|
386
|
+
super.release()
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The scene matrix, converted to clip space. The only conversion applied.
|
|
391
|
+
*
|
|
392
|
+
* `resolveVideoScene` emits an affine over normalised project
|
|
393
|
+
* coordinates, and preview applies it in output pixels as
|
|
394
|
+
* `translate(tx·W, ty·H) · translate(W/2, H/2) · L · translate(-W/2, -H/2)`.
|
|
395
|
+
* Clip space is square and pixels are not, so the off-diagonal terms
|
|
396
|
+
* carry the aspect ratio — without it a rotation would shear on a
|
|
397
|
+
* non-square output, and would still look plausible on a square one.
|
|
398
|
+
*
|
|
399
|
+
* The matrix already carries the box's anchor, so it is applied and
|
|
400
|
+
* **not centred again** — the surplus `(I − L)·centre` is what displaced
|
|
401
|
+
* a 0.55 scale by a half-extent and pushed a flip off the canvas.
|
|
402
|
+
*
|
|
403
|
+
* The terms below depend only on the **project** size. Output size
|
|
404
|
+
* cancels out of the conversion, which is the arithmetic saying the two
|
|
405
|
+
* spaces agree: a scene means the same thing whatever it is rendered
|
|
406
|
+
* into.
|
|
407
|
+
*
|
|
408
|
+
* Nothing else is applied here. The source's own orientation was settled
|
|
409
|
+
* upstream by Media3's frame processor, which is what keeps it innermost
|
|
410
|
+
* and this matrix outermost, exactly as preview orders them.
|
|
411
|
+
*
|
|
412
|
+
* Column-major, as GLSL wants it.
|
|
413
|
+
*/
|
|
414
|
+
private fun clipSpaceTransform(): FloatArray {
|
|
415
|
+
val m = matrixScratch
|
|
416
|
+
val w = projectWidth.toFloat()
|
|
417
|
+
val h = projectHeight.toFloat()
|
|
418
|
+
if (w <= 0f || h <= 0f) return IDENTITY_MAT3
|
|
419
|
+
val a = m[0]
|
|
420
|
+
val b = m[1]
|
|
421
|
+
val c = m[2]
|
|
422
|
+
val d = m[3]
|
|
423
|
+
val tx = m[4]
|
|
424
|
+
val ty = m[5]
|
|
425
|
+
/*
|
|
426
|
+
Clip space is [-1, 1] with y up; project space is [0, size] with y
|
|
427
|
+
down. Both the scale and the origin shift, so the constant term is
|
|
428
|
+
not simply the translation: it also carries what the linear part
|
|
429
|
+
does to the corner the two spaces disagree about.
|
|
430
|
+
|
|
431
|
+
An earlier version used `2·tx/Wp` alone, which is the constant only
|
|
432
|
+
when the matrix is expressed as an offset from the centre. Under the
|
|
433
|
+
canonical convention `tx` is an absolute project coordinate, and
|
|
434
|
+
that shortcut put a 0.25 scale at 405..540 instead of 202..337 —
|
|
435
|
+
exactly twice the offset, the same failure the convention was
|
|
436
|
+
corrected to remove.
|
|
437
|
+
*/
|
|
438
|
+
val ax = c * (h / w)
|
|
439
|
+
val by = b * (w / h)
|
|
440
|
+
val cx = a + ax + 2f * tx / w - 1f
|
|
441
|
+
val cy = 1f - by - d - 2f * ty / h
|
|
442
|
+
// x' = a·x − ax·y + cx
|
|
443
|
+
// y' = −by·x + d·y + cy
|
|
444
|
+
return floatArrayOf(
|
|
445
|
+
a, -by, 0f,
|
|
446
|
+
-ax, d, 0f,
|
|
447
|
+
cx, cy, 1f,
|
|
448
|
+
)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Overlays, after the video and in the resolver's z order.
|
|
453
|
+
*
|
|
454
|
+
* Placement mirrors preview exactly: the scene matrix positions the
|
|
455
|
+
* overlay's **centre**, and the measured half-extent that arrived with
|
|
456
|
+
* the resource turns that into a box. The four corners are computed in
|
|
457
|
+
* project units and converted to clip space directly — a quad needs no
|
|
458
|
+
* matrix conversion, and deriving one would be a second place for the
|
|
459
|
+
* aspect terms to disagree.
|
|
460
|
+
*/
|
|
461
|
+
/** Deduplicates the trace above: one line per fact, not per frame. */
|
|
462
|
+
private val overlayTrace = java.util.Collections.synchronizedSet(HashSet<String>())
|
|
463
|
+
|
|
464
|
+
private fun drawOverlays() {
|
|
465
|
+
val compiled = sceneProgram ?: return
|
|
466
|
+
val resources = overlayResources
|
|
467
|
+
if (segmentIndex < 0) return
|
|
468
|
+
/*
|
|
469
|
+
One line per distinct segment, and per segment-and-overlay.
|
|
470
|
+
|
|
471
|
+
An overlay that does not appear has three possible explanations —
|
|
472
|
+
the segment carried no command, the command was carried but the
|
|
473
|
+
resource was absent, or it was drawn to a quad nobody can see —
|
|
474
|
+
and from outside the encoder they are indistinguishable. This
|
|
475
|
+
separates them at the only place that knows.
|
|
476
|
+
*/
|
|
477
|
+
val draws = if (resources == null) emptyList() else compiled.overlaysAt(segmentIndex)
|
|
478
|
+
if (overlayTrace.add("seg$segmentIndex")) {
|
|
479
|
+
android.util.Log.i(
|
|
480
|
+
"PictorOverlay",
|
|
481
|
+
"segment=" + segmentIndex + " offsetUs=" + timelineOffsetUs +
|
|
482
|
+
" resources=" + (resources != null) +
|
|
483
|
+
" commands=" + (if (resources == null) -1 else compiled.overlaysAt(segmentIndex).size),
|
|
484
|
+
)
|
|
485
|
+
}
|
|
486
|
+
if (draws.isEmpty()) return
|
|
487
|
+
// Narrowed after the trace above, which is the only thing that
|
|
488
|
+
// needed to observe the null case.
|
|
489
|
+
val res = resources ?: return
|
|
490
|
+
|
|
491
|
+
val program = overlayProgram ?: GlProgram(VERTEX_SHADER, FRAGMENT_SHADER).also {
|
|
492
|
+
overlayProgram = it
|
|
493
|
+
}
|
|
494
|
+
val pw = projectWidth.toFloat()
|
|
495
|
+
val ph = projectHeight.toFloat()
|
|
496
|
+
GLES20.glEnable(GLES20.GL_BLEND)
|
|
497
|
+
for (draw in draws) {
|
|
498
|
+
val entry = res.entries[draw.resourceId] ?: throw VideoFrameProcessingException(
|
|
499
|
+
RuntimeException("overlay resource '" + draw.resourceId + "' was not preloaded"),
|
|
500
|
+
)
|
|
501
|
+
val texId = overlayTextures.getOrPut(draw.resourceId) { upload(entry) }
|
|
502
|
+
if (overlayFailAfterDraws >= 0 && overlayDraws.get() >= overlayFailAfterDraws) {
|
|
503
|
+
throw VideoFrameProcessingException(
|
|
504
|
+
RuntimeException("injected overlay failure after " + overlayDraws.get() + " draws"),
|
|
505
|
+
)
|
|
506
|
+
}
|
|
507
|
+
overlayDraws.incrementAndGet()
|
|
508
|
+
/*
|
|
509
|
+
The blend function follows what the producer declared.
|
|
510
|
+
|
|
511
|
+
Straight and premultiplied describe the same picture with
|
|
512
|
+
different stored numbers, and using one function on the other's
|
|
513
|
+
bytes darkens or haloes every antialiased edge — subtly enough
|
|
514
|
+
to look like a font-rendering difference rather than a bug.
|
|
515
|
+
*/
|
|
516
|
+
if (entry.premultiplied) {
|
|
517
|
+
GLES20.glBlendFunc(GLES20.GL_ONE, GLES20.GL_ONE_MINUS_SRC_ALPHA)
|
|
518
|
+
} else {
|
|
519
|
+
GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA)
|
|
520
|
+
}
|
|
521
|
+
val m = draw.matrix
|
|
522
|
+
// Anchored on the logical size, laid out over the visual bounds.
|
|
523
|
+
val halfW = entry.projectWidth / 2f
|
|
524
|
+
val halfH = entry.projectHeight / 2f
|
|
525
|
+
val x0 = entry.boundsX - halfW
|
|
526
|
+
val y0 = entry.boundsY - halfH
|
|
527
|
+
val x1 = x0 + entry.boundsWidth
|
|
528
|
+
val y1 = y0 + entry.boundsHeight
|
|
529
|
+
val local = floatArrayOf(x0, y0, x1, y0, x0, y1, x1, y1)
|
|
530
|
+
val quad = FloatArray(8)
|
|
531
|
+
for (k in 0 until 4) {
|
|
532
|
+
val lx = local[k * 2]
|
|
533
|
+
val ly = local[k * 2 + 1]
|
|
534
|
+
val px = m[0] * lx + m[2] * ly + m[4]
|
|
535
|
+
val py = m[1] * lx + m[3] * ly + m[5]
|
|
536
|
+
quad[k * 2] = 2f * px / pw - 1f
|
|
537
|
+
quad[k * 2 + 1] = 1f - 2f * py / ph
|
|
538
|
+
}
|
|
539
|
+
if (overlayTrace.add("seg" + segmentIndex + ":" + draw.resourceId)) {
|
|
540
|
+
android.util.Log.i(
|
|
541
|
+
"PictorOverlay",
|
|
542
|
+
"draw segment=" + segmentIndex + " id=" + draw.overlayId +
|
|
543
|
+
" quad=[" + quad[0] + "," + quad[1] + " " + quad[6] + "," + quad[7] + "]" +
|
|
544
|
+
" logical=" + entry.projectWidth + "x" + entry.projectHeight +
|
|
545
|
+
" bounds=" + entry.boundsX + "," + entry.boundsY + "," +
|
|
546
|
+
entry.boundsWidth + "x" + entry.boundsHeight +
|
|
547
|
+
" project=" + pw + "x" + ph,
|
|
548
|
+
)
|
|
549
|
+
}
|
|
550
|
+
program.use()
|
|
551
|
+
program.setBufferAttribute("aFramePosition", quad, 2)
|
|
552
|
+
program.setBufferAttribute("aTexCoord", OVERLAY_TEX_COORDS, 2)
|
|
553
|
+
program.setSamplerTexIdUniform("uTexSampler", texId, 0)
|
|
554
|
+
program.setFloatsUniform("uTransform", IDENTITY_MAT3)
|
|
555
|
+
program.setFloatUniform("uOpacity", draw.opacity)
|
|
556
|
+
program.setFloatUniform("uUseAttrCoord", 1f)
|
|
557
|
+
program.setFloatUniform("uPremultiplied", if (entry.premultiplied) 1f else 0f)
|
|
558
|
+
program.bindAttributesAndUniforms()
|
|
559
|
+
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
private fun upload(entry: OverlayResources.Entry): Int {
|
|
564
|
+
val ids = IntArray(1)
|
|
565
|
+
GLES20.glGenTextures(1, ids, 0)
|
|
566
|
+
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, ids[0])
|
|
567
|
+
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
|
|
568
|
+
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
|
|
569
|
+
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
|
|
570
|
+
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
|
|
571
|
+
val buffer = java.nio.ByteBuffer.allocateDirect(entry.pixels.size)
|
|
572
|
+
.order(java.nio.ByteOrder.nativeOrder())
|
|
573
|
+
buffer.put(entry.pixels)
|
|
574
|
+
buffer.position(0)
|
|
575
|
+
GLES20.glTexImage2D(
|
|
576
|
+
GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA,
|
|
577
|
+
entry.rasterWidth, entry.rasterHeight, 0,
|
|
578
|
+
GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, buffer,
|
|
579
|
+
)
|
|
580
|
+
overlayUploads.incrementAndGet()
|
|
581
|
+
return ids[0]
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
private fun makeProgram(): GlProgram {
|
|
585
|
+
val program = GlProgram(VERTEX_SHADER, FRAGMENT_SHADER)
|
|
586
|
+
program.setBufferAttribute(
|
|
587
|
+
"aFramePosition",
|
|
588
|
+
floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f),
|
|
589
|
+
2,
|
|
590
|
+
)
|
|
591
|
+
return program
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private fun recordContext() {
|
|
596
|
+
val current = Thread.currentThread()
|
|
597
|
+
if (threadId == 0L) {
|
|
598
|
+
threadId = current.id
|
|
599
|
+
threadName = current.name
|
|
600
|
+
} else if (threadId != current.id) {
|
|
601
|
+
threadChanges += 1
|
|
602
|
+
threadId = current.id
|
|
603
|
+
threadName = current.name
|
|
604
|
+
}
|
|
605
|
+
val handle = EGL14.eglGetCurrentContext().nativeHandle
|
|
606
|
+
if (contextHandle == 0L) {
|
|
607
|
+
contextHandle = handle
|
|
608
|
+
} else if (contextHandle != handle) {
|
|
609
|
+
// The frame processor is documented to run on one thread with one
|
|
610
|
+
// context. If that stops being true, every assumption about who owns
|
|
611
|
+
// what goes with it — so it is counted rather than assumed.
|
|
612
|
+
contextChanges += 1
|
|
613
|
+
contextHandle = handle
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
private fun recordTimestamp(us: Long) {
|
|
618
|
+
synchronized(timestamps) {
|
|
619
|
+
if (timestamps.isNotEmpty()) timestamps.append(',')
|
|
620
|
+
timestamps.append(us)
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
private fun recordGlError() {
|
|
625
|
+
var code = GLES20.glGetError()
|
|
626
|
+
while (code != GLES20.GL_NO_ERROR) {
|
|
627
|
+
synchronized(glErrorCodes) { glErrorCodes.add(code) }
|
|
628
|
+
code = GLES20.glGetError()
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
private companion object {
|
|
633
|
+
val IDENTITY_MAT3 = floatArrayOf(1f, 0f, 0f, 0f, 1f, 0f, 0f, 0f, 1f)
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* TL, TR, BL, BR — matching the order the overlay quad is built in.
|
|
637
|
+
*
|
|
638
|
+
* `v = 0` at the top: a raster's first row is its top row, and the
|
|
639
|
+
* corner it belongs with is the one at project `y = 0`. Getting this
|
|
640
|
+
* backwards gives a vertically mirrored overlay that is otherwise
|
|
641
|
+
* perfectly placed, which is why the gate's synthetic pattern has four
|
|
642
|
+
* differently coloured quadrants.
|
|
643
|
+
*/
|
|
644
|
+
val OVERLAY_TEX_COORDS = floatArrayOf(0f, 0f, 1f, 0f, 0f, 1f, 1f, 1f)
|
|
645
|
+
|
|
646
|
+
const val VERTEX_SHADER = """
|
|
647
|
+
attribute vec4 aFramePosition;
|
|
648
|
+
attribute vec2 aTexCoord;
|
|
649
|
+
uniform mat3 uTransform;
|
|
650
|
+
uniform highp float uUseAttrCoord;
|
|
651
|
+
uniform highp float uPremultiplied;
|
|
652
|
+
varying vec2 vTexCoord;
|
|
653
|
+
void main() {
|
|
654
|
+
// The texture coordinate stays with the *original* corner, so the
|
|
655
|
+
// quad moves and the sampling does not. Pixels the quad no longer
|
|
656
|
+
// covers keep the clear colour instead of stale texture.
|
|
657
|
+
vTexCoord = mix(aFramePosition.xy * 0.5 + 0.5, aTexCoord, uUseAttrCoord);
|
|
658
|
+
vec3 p = uTransform * vec3(aFramePosition.xy, 1.0);
|
|
659
|
+
gl_Position = vec4(p.xy, 0.0, 1.0);
|
|
660
|
+
}
|
|
661
|
+
"""
|
|
662
|
+
|
|
663
|
+
const val FRAGMENT_SHADER = """
|
|
664
|
+
precision mediump float;
|
|
665
|
+
uniform sampler2D uTexSampler;
|
|
666
|
+
uniform float uOpacity;
|
|
667
|
+
// Declared in both stages, and explicitly `highp` in both: a
|
|
668
|
+
// uniform is per-program, and a vertex `float` defaults to highp
|
|
669
|
+
// while a fragment one here would be mediump. The link then fails
|
|
670
|
+
// with "the precision does not match" rather than anything about
|
|
671
|
+
// the variable being undeclared.
|
|
672
|
+
uniform highp float uUseAttrCoord;
|
|
673
|
+
uniform highp float uPremultiplied;
|
|
674
|
+
varying vec2 vTexCoord;
|
|
675
|
+
void main() {
|
|
676
|
+
/*
|
|
677
|
+
Straight alpha, paired with SRC_ALPHA/ONE_MINUS_SRC_ALPHA.
|
|
678
|
+
|
|
679
|
+
Video carries no per-pixel alpha, so the clip's opacity is the
|
|
680
|
+
whole of it and the texture's own channel is ignored -- which
|
|
681
|
+
keeps that path bit-identical to before overlays existed. An
|
|
682
|
+
overlay raster is the opposite: its shape *is* its alpha, and
|
|
683
|
+
discarding it drew a solid rectangle where a soft one belonged.
|
|
684
|
+
`uUseAttrCoord` already distinguishes the two.
|
|
685
|
+
*/
|
|
686
|
+
vec4 c = texture2D(uTexSampler, vTexCoord);
|
|
687
|
+
float a = mix(uOpacity, c.a * uOpacity, uUseAttrCoord);
|
|
688
|
+
// Premultiplied colour must be scaled by the same opacity its
|
|
689
|
+
// alpha is, or a faded overlay brightens instead of fading.
|
|
690
|
+
vec3 rgb = mix(c.rgb, c.rgb * uOpacity, uPremultiplied);
|
|
691
|
+
gl_FragColor = vec4(rgb, a);
|
|
692
|
+
}
|
|
693
|
+
"""
|
|
694
|
+
}
|
|
695
|
+
}
|