@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
package/src/exporter.ts
ADDED
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
import { createExportJobs } from './exportJob'
|
|
2
|
+
import { resolveMixSources } from './mixSources'
|
|
3
|
+
import { describeTarget, resolveDestination } from './exportDestination'
|
|
4
|
+
import { planProgressWeights } from './exportProgress'
|
|
5
|
+
import { createProgressPump } from './exportProgressPump'
|
|
6
|
+
import {
|
|
7
|
+
VideoExportCancelled,
|
|
8
|
+
VideoExportFailed,
|
|
9
|
+
VideoExportStalled,
|
|
10
|
+
compileAudioMixProgram,
|
|
11
|
+
compileVideoSceneProgram,
|
|
12
|
+
isPlayableWindow,
|
|
13
|
+
layout,
|
|
14
|
+
planClipShards,
|
|
15
|
+
projectDuration,
|
|
16
|
+
validateAudioMixProgram,
|
|
17
|
+
validateProject,
|
|
18
|
+
VIDEO_SCENE_PROGRAM_VERSION,
|
|
19
|
+
type ExportSourceContext,
|
|
20
|
+
type MediaSource,
|
|
21
|
+
type ResampleRequest,
|
|
22
|
+
type VideoExportProgress,
|
|
23
|
+
type VideoExportSpec,
|
|
24
|
+
type VideoExporter,
|
|
25
|
+
type VideoProject,
|
|
26
|
+
} from '@pictor-sdk/video-core'
|
|
27
|
+
import NativePictorVideoModule from './NativePictorVideo'
|
|
28
|
+
|
|
29
|
+
/** The turbo module, or a failure that says so rather than a null dereference. */
|
|
30
|
+
function nativeModule(): NonNullable<typeof NativePictorVideoModule> {
|
|
31
|
+
if (!NativePictorVideoModule) {
|
|
32
|
+
throw new VideoExportRejected(['the Pictor video native module is not installed'])
|
|
33
|
+
}
|
|
34
|
+
return NativePictorVideoModule
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The Media3 exporter.
|
|
39
|
+
*
|
|
40
|
+
* Honours `VideoExporter` in full: asynchronous, cancellable while it runs,
|
|
41
|
+
* monotonic progress, temporary and file destinations, one export at a time,
|
|
42
|
+
* typed refusal, cancellation, stall and failure, and deterministic cleanup on
|
|
43
|
+
* every one of those paths.
|
|
44
|
+
*
|
|
45
|
+
* Nothing constructs it for you. It is not on `VideoRuntime`, not behind the
|
|
46
|
+
* editor's Next button, and not reachable from `@pictor-sdk/video-ui` — a host
|
|
47
|
+
* builds it explicitly, which is what keeps the surface reviewable. Wiring it
|
|
48
|
+
* to a button is Slice 5's job, not this file's.
|
|
49
|
+
*
|
|
50
|
+
* ## The gate boundary
|
|
51
|
+
*
|
|
52
|
+
* Nothing here reaches a `exportGate*` method. Provenance hashing, fault
|
|
53
|
+
* injection, artificial holds and test beats live behind
|
|
54
|
+
* `exportGateEnableDiagnostics`, which this never calls, and which leaves them
|
|
55
|
+
* inert even when a payload asks for them.
|
|
56
|
+
*
|
|
57
|
+
* ## The whole project is interpreted here, in TypeScript
|
|
58
|
+
*
|
|
59
|
+
* `planClipShards` and `compileVideoSceneProgram` run once, sources are resolved
|
|
60
|
+
* once, overlays are rasterised once, and what crosses to native is a list of
|
|
61
|
+
* shards, a compiled program and a resource manifest — all JSON and file paths.
|
|
62
|
+
* Native never sees a `VideoProject`, a `VideoClip`, a trim, a speed or a
|
|
63
|
+
* transform field. It looks up segments by timestamp and draws what they say.
|
|
64
|
+
*
|
|
65
|
+
* ## It refuses rather than approximating
|
|
66
|
+
*
|
|
67
|
+
* Every clip kind, spec field and overlay state this slice does not implement is
|
|
68
|
+
* rejected by name before anything is encoded. A photo clip silently skipped, or
|
|
69
|
+
* a bitrate silently ignored, produces a file that is plausible and wrong — and
|
|
70
|
+
* a plausible wrong file is worse than no file, because nothing downstream can
|
|
71
|
+
* tell it apart from a correct one.
|
|
72
|
+
*
|
|
73
|
+
* @experimental
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* An overlay raster, described structurally.
|
|
78
|
+
*
|
|
79
|
+
* Deliberately not imported from `@pictor-sdk/video-skia`. The adapter has no
|
|
80
|
+
* opinion about how an overlay is rendered — that is the whole point of the
|
|
81
|
+
* boundary — and importing the renderer would drag Skia into a package whose
|
|
82
|
+
* job is to talk to Media3. What it needs is the shape of a descriptor, which
|
|
83
|
+
* is JSON.
|
|
84
|
+
*
|
|
85
|
+
* @experimental
|
|
86
|
+
*/
|
|
87
|
+
export interface OverlayResourceDescriptor {
|
|
88
|
+
overlayId: string
|
|
89
|
+
resourceId: string
|
|
90
|
+
size: { width: number; height: number }
|
|
91
|
+
rasterBounds: { x: number; y: number; width: number; height: number }
|
|
92
|
+
rasterScale: number
|
|
93
|
+
rasterWidth: number
|
|
94
|
+
rasterHeight: number
|
|
95
|
+
format: string
|
|
96
|
+
premultiplied: boolean
|
|
97
|
+
colorSpace: string
|
|
98
|
+
file: string
|
|
99
|
+
byteLength: number
|
|
100
|
+
checksum: number
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** What preparing a program's overlays produced. @experimental */
|
|
104
|
+
export interface OverlayPreparationResult {
|
|
105
|
+
resources: readonly OverlayResourceDescriptor[]
|
|
106
|
+
/** Still resolving. An export must not proceed and omit them. */
|
|
107
|
+
pending: readonly string[]
|
|
108
|
+
/** Cannot be exported at all. */
|
|
109
|
+
invalid: readonly { overlayId: string; kind: string; reason: string }[]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Everything the host must supply. @experimental */
|
|
113
|
+
export interface Media3ExporterOptions {
|
|
114
|
+
/**
|
|
115
|
+
* Turn an opaque `MediaSource` into something Android can open.
|
|
116
|
+
*
|
|
117
|
+
* The exporter never inspects a descriptor: `MediaSource` is `JsonValue` and
|
|
118
|
+
* only the host knows whether it is an asset id, a content URI or a row in a
|
|
119
|
+
* database. Returning `null` fails the export naming the clip or the span.
|
|
120
|
+
*
|
|
121
|
+
* `context` says which part of the project is being resolved. It used to be
|
|
122
|
+
* the clip, which left the audio path with nothing honest to pass: every
|
|
123
|
+
* span, project music included, was resolved with the *first video clip* as
|
|
124
|
+
* context. A resolver that keys on the clip returned that clip's media for
|
|
125
|
+
* the whole timeline's audio — a plausible file with the wrong sound in it.
|
|
126
|
+
*/
|
|
127
|
+
resolveSource(source: MediaSource, context: ExportSourceContext): string | null
|
|
128
|
+
/**
|
|
129
|
+
* Rasterise every overlay the compiled program names.
|
|
130
|
+
*
|
|
131
|
+
* The host wires its registry, its prepared resources and its raster sink
|
|
132
|
+
* behind this — typically `prepareProgramOverlayExports` from
|
|
133
|
+
* `@pictor-sdk/video-skia`. Injecting the whole step rather than its three inputs
|
|
134
|
+
* is what keeps this package free of a renderer dependency, and it is the same
|
|
135
|
+
* reason `resolveSource` is injected rather than a media library being.
|
|
136
|
+
*
|
|
137
|
+
* Omitted means "no overlays are exportable": a project that has any then
|
|
138
|
+
* fails naming each one, rather than exporting without them.
|
|
139
|
+
*/
|
|
140
|
+
prepareOverlays?(
|
|
141
|
+
program: { segments: readonly { commands: readonly unknown[] }[] },
|
|
142
|
+
projectSize: { width: number; height: number },
|
|
143
|
+
rasterScale: number,
|
|
144
|
+
): OverlayPreparationResult
|
|
145
|
+
/**
|
|
146
|
+
* Called after an export finishes, successfully or not.
|
|
147
|
+
*
|
|
148
|
+
* The sidecars belong to the host's raster sink, so the host removes them.
|
|
149
|
+
* Passing the list rather than the sink keeps this from having to know how
|
|
150
|
+
* they were stored.
|
|
151
|
+
*/
|
|
152
|
+
releaseResources?(files: readonly string[]): void
|
|
153
|
+
/**
|
|
154
|
+
* How long without any heartbeat counts as wedged.
|
|
155
|
+
*
|
|
156
|
+
* Defaults to {@link STALL_GRACE_MS}, which is **Pixel 7 evidence**: the worst
|
|
157
|
+
* legitimate silence measured across seven production-path cases on that
|
|
158
|
+
* device, with margin. It is not a claim about Android. A slower encoder, a
|
|
159
|
+
* thermally throttled phone or a codec that batches differently can produce a
|
|
160
|
+
* longer honest gap, and the number to change is this one — raising it costs
|
|
161
|
+
* only how late a genuine wedge is noticed, while leaving it too low kills
|
|
162
|
+
* healthy exports.
|
|
163
|
+
*/
|
|
164
|
+
stallGraceMs?: number
|
|
165
|
+
/**
|
|
166
|
+
* How often native counters are read, and progress delivered.
|
|
167
|
+
*
|
|
168
|
+
* Defaults to 125 ms — 8 Hz, inside the 5–10 Hz a person can actually read.
|
|
169
|
+
* Native counting is unthrottled regardless; this is only the bridge rate.
|
|
170
|
+
*/
|
|
171
|
+
progressPollMs?: number
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Why an export will not start. @experimental */
|
|
175
|
+
export class VideoExportRejected extends Error {
|
|
176
|
+
readonly reasons: readonly string[]
|
|
177
|
+
constructor(reasons: readonly string[]) {
|
|
178
|
+
super(`export rejected: ${reasons.join('; ')}`)
|
|
179
|
+
this.name = 'VideoExportRejected'
|
|
180
|
+
this.reasons = reasons
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The frame rate an export runs at when the spec does not say. */
|
|
185
|
+
const DEFAULT_FPS = 30
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* What this slice cannot encode, stated rather than skipped.
|
|
190
|
+
*
|
|
191
|
+
* Each of these has a milestone. Until then the export fails naming the clip or
|
|
192
|
+
* the field, because the alternative is a file that looks finished.
|
|
193
|
+
*/
|
|
194
|
+
function describeUnsupported(project: VideoProject, spec: VideoExportSpec): string[] {
|
|
195
|
+
const reasons: string[] = []
|
|
196
|
+
|
|
197
|
+
for (const clip of project.clips) {
|
|
198
|
+
if (clip.kind === 'photo') {
|
|
199
|
+
reasons.push(`clip '${clip.id}' is a photo, which this exporter does not encode yet`)
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (spec.format !== undefined && spec.format !== 'mp4') {
|
|
205
|
+
reasons.push(`format '${spec.format}' is not supported; this exporter writes mp4`)
|
|
206
|
+
}
|
|
207
|
+
if (spec.scale !== undefined && spec.scale !== 1) {
|
|
208
|
+
// Honoured or rejected, never quietly ignored.
|
|
209
|
+
reasons.push(`scale ${spec.scale} is not supported yet; only 1 is`)
|
|
210
|
+
}
|
|
211
|
+
if (spec.fps !== undefined && spec.fps !== DEFAULT_FPS) {
|
|
212
|
+
reasons.push(`fps ${spec.fps} is not supported yet; only ${DEFAULT_FPS} is`)
|
|
213
|
+
}
|
|
214
|
+
if (spec.bitrate !== undefined) {
|
|
215
|
+
reasons.push('bitrate is not supported yet; the encoder default is used')
|
|
216
|
+
}
|
|
217
|
+
return reasons
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Build an exporter.
|
|
222
|
+
*
|
|
223
|
+
* @experimental
|
|
224
|
+
*/
|
|
225
|
+
export function createMedia3VideoExporter(options: Media3ExporterOptions): VideoExporter {
|
|
226
|
+
/*
|
|
227
|
+
One job at a time, cancellable at every stage.
|
|
228
|
+
|
|
229
|
+
The registry owns identity, exclusivity and settling exactly once; the body
|
|
230
|
+
below only has to call `job.checkpoint` where stopping is safe. See
|
|
231
|
+
`exportJob.ts` for why cancellation cannot simply reject the promise.
|
|
232
|
+
*/
|
|
233
|
+
const jobs = createExportJobs({
|
|
234
|
+
cancelNative: (jobId) => {
|
|
235
|
+
nativeModule().exportCancelJob(jobId)
|
|
236
|
+
},
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
async export(
|
|
241
|
+
project: VideoProject,
|
|
242
|
+
spec: VideoExportSpec,
|
|
243
|
+
onProgress?: (progress: VideoExportProgress) => void,
|
|
244
|
+
) {
|
|
245
|
+
return jobs.run(async (job) => {
|
|
246
|
+
// First thing, so a cancel pressed before any work is observed before
|
|
247
|
+
// the compile, the overlay pass and the source resolution rather than
|
|
248
|
+
// after them. Measured: without it a pre-native cancel took 4.7 s to
|
|
249
|
+
// settle, because the first checkpoint was past all of that.
|
|
250
|
+
job.checkpoint('resolving')
|
|
251
|
+
const issues = validateProject(project)
|
|
252
|
+
if (issues.length > 0) {
|
|
253
|
+
throw new VideoExportRejected(issues.map((issue) => issue.message))
|
|
254
|
+
}
|
|
255
|
+
const unsupported = describeUnsupported(project, spec)
|
|
256
|
+
if (unsupported.length > 0) throw new VideoExportRejected(unsupported)
|
|
257
|
+
|
|
258
|
+
/*
|
|
259
|
+
Refused, never redirected.
|
|
260
|
+
|
|
261
|
+
Falling back to a temporary would produce an export that reports success
|
|
262
|
+
while the file is nowhere the caller asked for — and nothing downstream
|
|
263
|
+
can tell that apart from having worked.
|
|
264
|
+
*/
|
|
265
|
+
const decision = resolveDestination(spec.destination)
|
|
266
|
+
if (decision.destination === null) throw new VideoExportRejected(decision.reasons)
|
|
267
|
+
const destination = decision.destination
|
|
268
|
+
|
|
269
|
+
const playable = project.clips.filter((clip) => isPlayableWindow(clip))
|
|
270
|
+
if (playable.length === 0) throw new VideoExportRejected(['the project has no playable clips'])
|
|
271
|
+
if (!(projectDuration(project) > 0)) {
|
|
272
|
+
throw new VideoExportRejected(['the project has no duration'])
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Resolved without inspecting the descriptor: only the host knows what a
|
|
276
|
+
// `MediaSource` is, and this must not learn.
|
|
277
|
+
const uris: string[] = []
|
|
278
|
+
const unresolved: string[] = []
|
|
279
|
+
for (const clip of playable) {
|
|
280
|
+
const uri = options.resolveSource(clip.source, { kind: 'clip', clipId: clip.id })
|
|
281
|
+
if (typeof uri !== 'string' || uri.length === 0) {
|
|
282
|
+
unresolved.push(`clip '${clip.id}' could not be resolved to a readable source`)
|
|
283
|
+
continue
|
|
284
|
+
}
|
|
285
|
+
uris.push(uri)
|
|
286
|
+
}
|
|
287
|
+
if (unresolved.length > 0) throw new VideoExportRejected(unresolved)
|
|
288
|
+
|
|
289
|
+
const fps = DEFAULT_FPS
|
|
290
|
+
const placements = layout(project)
|
|
291
|
+
const requests: ResampleRequest[] = playable.map((clip) => ({
|
|
292
|
+
sourceStartUs: clip.sourceIn,
|
|
293
|
+
sourceEndUs: clip.sourceOut,
|
|
294
|
+
speed: clip.speed,
|
|
295
|
+
sourceFps: clip.metadata.fps ?? fps,
|
|
296
|
+
targetFps: fps,
|
|
297
|
+
}))
|
|
298
|
+
const plan = planClipShards(requests, fps)
|
|
299
|
+
const program = compileVideoSceneProgram(project)
|
|
300
|
+
if (program.segments.length === 0) {
|
|
301
|
+
throw new VideoExportRejected(['the compiled program has no segments'])
|
|
302
|
+
}
|
|
303
|
+
void placements
|
|
304
|
+
|
|
305
|
+
/*
|
|
306
|
+
Overlays are rasterised and checked **before** any native work.
|
|
307
|
+
|
|
308
|
+
A resource discovered missing once encoding has started leaves a partial
|
|
309
|
+
file, and the frames already written are silently missing something the
|
|
310
|
+
timeline said was on screen.
|
|
311
|
+
*/
|
|
312
|
+
const named = new Set<string>()
|
|
313
|
+
for (const segment of program.segments) {
|
|
314
|
+
for (const command of segment.commands) {
|
|
315
|
+
if (command.op === 'overlay') named.add(command.overlayId)
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const overlayPlan: OverlayPreparationResult = options.prepareOverlays
|
|
319
|
+
? options.prepareOverlays(program, project.size, 1)
|
|
320
|
+
: {
|
|
321
|
+
resources: [],
|
|
322
|
+
pending: [],
|
|
323
|
+
invalid: [...named].map((overlayId) => ({
|
|
324
|
+
overlayId,
|
|
325
|
+
kind: 'unknown',
|
|
326
|
+
reason: 'no prepareOverlays was supplied, so this overlay has no portable form',
|
|
327
|
+
})),
|
|
328
|
+
}
|
|
329
|
+
// Before the plan is judged: a cancel raised from inside the host's
|
|
330
|
+
// preparation must be reported as `overlays`, not as whatever the
|
|
331
|
+
// validation below happens to say first.
|
|
332
|
+
job.checkpoint('overlays')
|
|
333
|
+
|
|
334
|
+
/*
|
|
335
|
+
Anything already staged is released on every failure path below.
|
|
336
|
+
|
|
337
|
+
A rejection after rasterising leaves sidecars nothing else will come back
|
|
338
|
+
for, and the run that failed is exactly the one nobody cleans up after.
|
|
339
|
+
*/
|
|
340
|
+
const stagedNow = overlayPlan.resources.map((resource) => resource.file)
|
|
341
|
+
if (overlayPlan.pending.length > 0) {
|
|
342
|
+
options.releaseResources?.(stagedNow)
|
|
343
|
+
throw new VideoExportRejected(
|
|
344
|
+
overlayPlan.pending.map(
|
|
345
|
+
(id: string) => `overlay '${id}' is still preparing; export would omit it`,
|
|
346
|
+
),
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
if (overlayPlan.invalid.length > 0) {
|
|
350
|
+
options.releaseResources?.(stagedNow)
|
|
351
|
+
throw new VideoExportRejected(
|
|
352
|
+
overlayPlan.invalid.map(
|
|
353
|
+
(bad: { overlayId: string; kind: string; reason: string }) =>
|
|
354
|
+
`overlay '${bad.overlayId}' (${bad.kind}): ${bad.reason}`,
|
|
355
|
+
),
|
|
356
|
+
)
|
|
357
|
+
}
|
|
358
|
+
const resources = overlayPlan.resources
|
|
359
|
+
|
|
360
|
+
const shards = plan.map((shard, index) => {
|
|
361
|
+
const clip = playable[index]!
|
|
362
|
+
const cadence = cadenceFor(shard.plan.frames)
|
|
363
|
+
return {
|
|
364
|
+
uri: uris[index]!,
|
|
365
|
+
startMs: Math.round(clip.sourceIn / 1000),
|
|
366
|
+
endMs: Math.round(clip.sourceOut / 1000),
|
|
367
|
+
speed: clip.speed,
|
|
368
|
+
timelineOffsetUs: shard.timelineOffsetUs,
|
|
369
|
+
/*
|
|
370
|
+
The shard's own audio is dropped and replaced with generated silence.
|
|
371
|
+
|
|
372
|
+
Not because anything wants that silence — the final mux takes only
|
|
373
|
+
the video track from this pipeline — but because Media3 drives its
|
|
374
|
+
speed path through audio, and a video-only item at any rate other
|
|
375
|
+
than 1 stalls with "no output sample written". So the track exists to
|
|
376
|
+
satisfy the re-timing and is discarded.
|
|
377
|
+
|
|
378
|
+
What matters is that the *source's* audio never enters this pipeline:
|
|
379
|
+
a per-shard audio track has to be re-encoded to concatenate, which is
|
|
380
|
+
what put a second generation on everything the user hears.
|
|
381
|
+
*/
|
|
382
|
+
forceAudioTrack: true,
|
|
383
|
+
removeAudio: true,
|
|
384
|
+
emitsPerInput: cadence.emitsPerInput,
|
|
385
|
+
outputPtsUs: cadence.outputPtsUs,
|
|
386
|
+
spanUs: Math.round((shard.plan.frameCount * 1_000_000) / fps),
|
|
387
|
+
}
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
/*
|
|
391
|
+
The whole timeline's audio, compiled once and resolved once.
|
|
392
|
+
|
|
393
|
+
`compileAudioMixProgram` settles placements, trims, gains and fades; the
|
|
394
|
+
backend mixes and encodes exactly what it says. Sources are resolved by
|
|
395
|
+
span id through the same host resolver the video pipeline uses, and a
|
|
396
|
+
silent span needs none.
|
|
397
|
+
*/
|
|
398
|
+
const mixProgram = compileAudioMixProgram(project)
|
|
399
|
+
const mixProblems = validateAudioMixProgram(mixProgram)
|
|
400
|
+
if (mixProblems.length > 0) throw new VideoExportRejected(mixProblems)
|
|
401
|
+
/*
|
|
402
|
+
Resolved as what each span actually is, not as whichever clip came
|
|
403
|
+
first — and by the same function preview preparation calls, so the two
|
|
404
|
+
cannot disagree about which file a span means. A disagreement there
|
|
405
|
+
would be audible only in the finished video.
|
|
406
|
+
*/
|
|
407
|
+
const resolved = resolveMixSources(project, mixProgram, options.resolveSource)
|
|
408
|
+
const mixUris = resolved.uris
|
|
409
|
+
if (resolved.unresolved.length > 0) throw new VideoExportRejected(resolved.unresolved)
|
|
410
|
+
job.checkpoint('resolving')
|
|
411
|
+
|
|
412
|
+
job.checkpoint('overlays')
|
|
413
|
+
const staged = resources.map((resource) => resource.file)
|
|
414
|
+
|
|
415
|
+
/*
|
|
416
|
+
Progress starts here, after everything that can refuse.
|
|
417
|
+
|
|
418
|
+
A bar that appears and then vanishes because a photo clip was rejected
|
|
419
|
+
is worse than one that never appeared: the export never began, and
|
|
420
|
+
showing 0% claims otherwise. Everything above this line either throws or
|
|
421
|
+
establishes the plan the weights are computed from.
|
|
422
|
+
*/
|
|
423
|
+
const durationUs = projectDuration(project)
|
|
424
|
+
const progressPlan = planProgressWeights({
|
|
425
|
+
shards: plan.map((shard) => ({ frames: shard.plan.frameCount })),
|
|
426
|
+
audioSamples: mixProgram.spans.some((span) => !span.silent)
|
|
427
|
+
? Math.max(0, mixProgram.durationUs)
|
|
428
|
+
: 0,
|
|
429
|
+
durationUs,
|
|
430
|
+
})
|
|
431
|
+
// A box rather than a `let`: the assignment happens in a callback, and
|
|
432
|
+
// TypeScript narrows a closed-over `let` to `never` at the read.
|
|
433
|
+
const stalled: { at: { stage: string; idleMs: number } | null } = { at: null }
|
|
434
|
+
const pump = createProgressPump({
|
|
435
|
+
plan: progressPlan,
|
|
436
|
+
durationUs,
|
|
437
|
+
read: () => JSON.parse(nativeModule().exportProgressSnapshot()) as never,
|
|
438
|
+
// Delivered through the job, so nothing arrives after settlement: a
|
|
439
|
+
// host that has already shown a result must not be told 90%.
|
|
440
|
+
onProgress: onProgress
|
|
441
|
+
? (value) => { job.progress(value.progress, () => onProgress(value)) }
|
|
442
|
+
: undefined,
|
|
443
|
+
onStall: (verdict) => {
|
|
444
|
+
stalled.at = { stage: String(verdict.stage), idleMs: verdict.idleMs }
|
|
445
|
+
/*
|
|
446
|
+
Torn down through the path cancellation already proved.
|
|
447
|
+
|
|
448
|
+
A wedged export holds a Transformer, a muxer and its codecs, and the
|
|
449
|
+
only mechanism in this process that reliably lets go of all three is
|
|
450
|
+
the native cancel. What differs is what the caller is told: nobody
|
|
451
|
+
asked to stop, so this settles as `VideoExportStalled`.
|
|
452
|
+
*/
|
|
453
|
+
nativeModule().exportCancelJob(job.id)
|
|
454
|
+
},
|
|
455
|
+
thresholdMs: options.stallGraceMs,
|
|
456
|
+
pollMs: options.progressPollMs,
|
|
457
|
+
})
|
|
458
|
+
pump.begin()
|
|
459
|
+
const ticker = setInterval(() => { pump.tick() }, pump.pollMs)
|
|
460
|
+
|
|
461
|
+
let outcome: { ok: boolean; uri: string; error: string }
|
|
462
|
+
try {
|
|
463
|
+
outcome = JSON.parse(
|
|
464
|
+
await nativeModule().exportRunProjectAsync(
|
|
465
|
+
job.id,
|
|
466
|
+
JSON.stringify(shards),
|
|
467
|
+
JSON.stringify({
|
|
468
|
+
version: VIDEO_SCENE_PROGRAM_VERSION,
|
|
469
|
+
projectSize: { width: project.size.width, height: project.size.height },
|
|
470
|
+
durationUs: program.durationUs,
|
|
471
|
+
segments: program.segments,
|
|
472
|
+
}),
|
|
473
|
+
JSON.stringify(resources),
|
|
474
|
+
JSON.stringify(mixProgram),
|
|
475
|
+
JSON.stringify(mixUris),
|
|
476
|
+
/* timeoutSeconds= */ 600,
|
|
477
|
+
destination.kind === 'file'
|
|
478
|
+
? JSON.stringify({
|
|
479
|
+
kind: 'file',
|
|
480
|
+
path: destination.path,
|
|
481
|
+
overwrite: destination.overwrite,
|
|
482
|
+
})
|
|
483
|
+
: '',
|
|
484
|
+
),
|
|
485
|
+
) as { ok: boolean; uri: string; error: string }
|
|
486
|
+
} finally {
|
|
487
|
+
clearInterval(ticker)
|
|
488
|
+
// Every path, including the one that threw: the sidecars are this
|
|
489
|
+
// export's and nothing else will come back for them.
|
|
490
|
+
options.releaseResources?.(staged)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/*
|
|
494
|
+
A stall outranks whatever native said on the way out.
|
|
495
|
+
|
|
496
|
+
Tearing down a wedged Transformer reports itself as a cancellation,
|
|
497
|
+
because a cancellation is how it was torn down. Reporting that would
|
|
498
|
+
tell a host the user pressed stop when nothing of the sort happened.
|
|
499
|
+
*/
|
|
500
|
+
const wedged = stalled.at
|
|
501
|
+
if (wedged) {
|
|
502
|
+
pump.stop()
|
|
503
|
+
throw new VideoExportStalled(job.id, wedged.stage, wedged.idleMs)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/*
|
|
507
|
+
A native cancellation is a cancellation, with the stage native recorded.
|
|
508
|
+
|
|
509
|
+
Without this the job registry falls back to its own default and every
|
|
510
|
+
cancellation reports the same stage, which is worse than no stage at
|
|
511
|
+
all: it looks like information.
|
|
512
|
+
*/
|
|
513
|
+
const cancelledDuring = /^cancelled during (\w+)/.exec(outcome.error ?? '')
|
|
514
|
+
if (!outcome.ok && cancelledDuring) {
|
|
515
|
+
pump.stop()
|
|
516
|
+
throw new VideoExportCancelled(job.id, cancelledDuring[1]!)
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (!outcome.ok) {
|
|
520
|
+
/*
|
|
521
|
+
No uri either way.
|
|
522
|
+
|
|
523
|
+
A partial file is worse than none — it plays, it is the wrong length,
|
|
524
|
+
and a caller that took the uri would have no way to know. Native
|
|
525
|
+
deletes what it created on every one of its own failure paths; nothing
|
|
526
|
+
is swept here, because a sweep would also take the temporary a
|
|
527
|
+
*previous* export handed the caller.
|
|
528
|
+
*/
|
|
529
|
+
pump.stop()
|
|
530
|
+
const error = outcome.error || 'the export failed'
|
|
531
|
+
if (isRefusal(error)) throw new VideoExportRejected([error])
|
|
532
|
+
throw new VideoExportFailed(job.id, 'encoding', error)
|
|
533
|
+
}
|
|
534
|
+
job.checkpoint('publishing')
|
|
535
|
+
|
|
536
|
+
/*
|
|
537
|
+
One is the destination being readable, not the encoder stopping.
|
|
538
|
+
|
|
539
|
+
The container is closed after the last sample is written, and a file
|
|
540
|
+
that is closed is not necessarily one that parses. Probing it costs a
|
|
541
|
+
`MediaExtractor` open and turns "the encode finished" into "there is a
|
|
542
|
+
file you can play", which is the only claim a bar reaching 1 should be
|
|
543
|
+
making.
|
|
544
|
+
*/
|
|
545
|
+
const probe = JSON.parse(nativeModule().exportProbeOutput(outcome.uri)) as {
|
|
546
|
+
readable: boolean
|
|
547
|
+
error?: string
|
|
548
|
+
tracks?: number
|
|
549
|
+
}
|
|
550
|
+
if (!probe.readable) {
|
|
551
|
+
pump.stop()
|
|
552
|
+
// Written and unopenable: the work happened and the platform produced
|
|
553
|
+
// something unusable, which is a failure rather than a refusal.
|
|
554
|
+
throw new VideoExportFailed(
|
|
555
|
+
job.id,
|
|
556
|
+
'publishing',
|
|
557
|
+
`${describeTarget(outcome.uri)} could not be read back: ${probe.error ?? 'no video track'}`,
|
|
558
|
+
)
|
|
559
|
+
}
|
|
560
|
+
pump.complete()
|
|
561
|
+
pump.stop()
|
|
562
|
+
|
|
563
|
+
/*
|
|
564
|
+
Ownership, stated in the type.
|
|
565
|
+
|
|
566
|
+
A temporary is the adapter's until the caller says otherwise, so it
|
|
567
|
+
carries the one call that disposes of it. A file destination is the
|
|
568
|
+
caller's — they chose the path — so that branch has no `release` at all,
|
|
569
|
+
rather than one that quietly does nothing. An optional cleanup method on
|
|
570
|
+
a single shape would put "delete the user's video" one typo away from
|
|
571
|
+
every success path.
|
|
572
|
+
*/
|
|
573
|
+
if (destination.kind === 'file') return { kind: 'file', uri: outcome.uri }
|
|
574
|
+
const temporaryUri = outcome.uri
|
|
575
|
+
return {
|
|
576
|
+
kind: 'temporary',
|
|
577
|
+
uri: temporaryUri,
|
|
578
|
+
release: async () => {
|
|
579
|
+
// Idempotent, and scoped to this file: native refuses any path it did
|
|
580
|
+
// not create, so a second call — or one after the cache was cleared —
|
|
581
|
+
// is not an error.
|
|
582
|
+
await Promise.resolve()
|
|
583
|
+
nativeModule().exportReleaseTemporaryOutput(temporaryUri)
|
|
584
|
+
},
|
|
585
|
+
}
|
|
586
|
+
})
|
|
587
|
+
},
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Ask the running export to stop.
|
|
591
|
+
*
|
|
592
|
+
* Idempotent, and a no-op when nothing is running. Reaches the native job
|
|
593
|
+
* before the promise settles: a Transformer nobody is listening to still
|
|
594
|
+
* encodes, still writes and still holds its codecs, so rejecting in
|
|
595
|
+
* JavaScript alone would show a cancelled state over a running export.
|
|
596
|
+
*/
|
|
597
|
+
cancel(): void {
|
|
598
|
+
jobs.cancel()
|
|
599
|
+
},
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Refusal or failure — the distinction a caller acts on.
|
|
605
|
+
*
|
|
606
|
+
* A refusal means the project, the destination or a resource was not something
|
|
607
|
+
* this adapter could encode, decided before or instead of doing work: change
|
|
608
|
+
* the input. A failure means the work started and the platform gave up: retry,
|
|
609
|
+
* check storage, or report a bug. Reporting one as the other sends a caller to
|
|
610
|
+
* the wrong place, which is worse than an unhelpful message.
|
|
611
|
+
*
|
|
612
|
+
* Matched on the prefixes native produces rather than on a code, because the
|
|
613
|
+
* native side reports strings — and the list is exhaustive over the reasons
|
|
614
|
+
* `runProject` can refuse, so anything unrecognised is a failure. That default
|
|
615
|
+
* is the safe one: a new encoder error misreported as "unsupported" tells a user
|
|
616
|
+
* to edit a project that was fine.
|
|
617
|
+
*/
|
|
618
|
+
const REFUSAL_PATTERNS = [
|
|
619
|
+
/^program rejected/,
|
|
620
|
+
/^overlay resources? /,
|
|
621
|
+
/^overlay resource /,
|
|
622
|
+
/^no shards/,
|
|
623
|
+
/already exists; pass overwrite/,
|
|
624
|
+
/^the directory for /,
|
|
625
|
+
/^the destination /,
|
|
626
|
+
/cannot be written without overwrite/,
|
|
627
|
+
/^the staged file could not be read back/,
|
|
628
|
+
]
|
|
629
|
+
|
|
630
|
+
function isRefusal(error: string): boolean {
|
|
631
|
+
return REFUSAL_PATTERNS.some((pattern) => pattern.test(error))
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* The cadence transport: copies owed per arriving input, and their timestamps.
|
|
637
|
+
*
|
|
638
|
+
* Straight from `resampleClip`'s plan. Native walks these arrays and computes no
|
|
639
|
+
* grid arithmetic of its own.
|
|
640
|
+
*/
|
|
641
|
+
function cadenceFor(
|
|
642
|
+
frames: readonly { outputPtsUs: number; sourceFrameIndex: number }[],
|
|
643
|
+
): { emitsPerInput: number[]; outputPtsUs: number[] } {
|
|
644
|
+
const first = frames[0]!.sourceFrameIndex
|
|
645
|
+
const last = frames[frames.length - 1]!.sourceFrameIndex
|
|
646
|
+
const emitsPerInput: number[] = []
|
|
647
|
+
for (let i = 0; i <= last - first; i += 1) emitsPerInput.push(0)
|
|
648
|
+
const outputPtsUs: number[] = []
|
|
649
|
+
for (const frame of frames) {
|
|
650
|
+
const slot = frame.sourceFrameIndex - first
|
|
651
|
+
emitsPerInput[slot] = (emitsPerInput[slot] ?? 0) + 1
|
|
652
|
+
outputPtsUs.push(frame.outputPtsUs)
|
|
653
|
+
}
|
|
654
|
+
return { emitsPerInput, outputPtsUs }
|
|
655
|
+
}
|