@pictor-sdk/video-android 0.1.5 → 0.2.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/android/src/main/java/dev/pictor/video/CompositionSpike.kt +49 -9
- package/android/src/main/java/dev/pictor/video/ExportGate.kt +117 -0
- package/android/src/main/java/dev/pictor/video/PictorVideoModule.kt +44 -0
- package/android/src/main/java/dev/pictor/video/ResumeReadiness.kt +82 -0
- package/dist/index.d.mts +35 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +119 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +119 -5
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/NativePictorVideo.ts +18 -0
- package/src/compositionSpike.ts +10 -0
- package/src/exporter.ts +157 -4
- package/src/useCompositionPlayback.ts +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pictor-sdk/video-android",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Optional Android video playback and export adapter for Pictor. Requires Android API 29+.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"react": ">=18.0.0",
|
|
30
30
|
"@expo/config-plugins": ">=54.0.0",
|
|
31
31
|
"react-native-reanimated": ">=3.16.0",
|
|
32
|
-
"@pictor-sdk/video-core": "^0.
|
|
32
|
+
"@pictor-sdk/video-core": "^0.2.0"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"react-native": "^0.81.0",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"@types/react": "^19.0.0",
|
|
41
41
|
"@expo/config-plugins": "^54.0.5",
|
|
42
42
|
"react-native-reanimated": "^4.1.0",
|
|
43
|
-
"@pictor-sdk/video-core": "0.
|
|
43
|
+
"@pictor-sdk/video-core": "0.2.0"
|
|
44
44
|
},
|
|
45
45
|
"codegenConfig": {
|
|
46
46
|
"name": "PictorVideoSpec",
|
package/src/NativePictorVideo.ts
CHANGED
|
@@ -248,6 +248,24 @@ export interface Spec extends TurboModule {
|
|
|
248
248
|
*
|
|
249
249
|
* Resolves with the same JSON outcome the blocking call returns. @experimental
|
|
250
250
|
*/
|
|
251
|
+
/**
|
|
252
|
+
* Export one still photograph with animated overlays.
|
|
253
|
+
*
|
|
254
|
+
* `pngPath` is a local PNG the host rendered; native never draws the base
|
|
255
|
+
* twice. No audio track is produced, so this is the photo-story path rather
|
|
256
|
+
* than a shortened video export that happened to have no sound.
|
|
257
|
+
*
|
|
258
|
+
* @experimental
|
|
259
|
+
*/
|
|
260
|
+
exportRunPhotoProjectAsync(
|
|
261
|
+
jobId: string,
|
|
262
|
+
pngPath: string,
|
|
263
|
+
programJson: string,
|
|
264
|
+
overlaysJson: string,
|
|
265
|
+
durationUs: number,
|
|
266
|
+
timeoutSeconds: number,
|
|
267
|
+
destinationJson: string,
|
|
268
|
+
): Promise<string>
|
|
251
269
|
exportRunProjectAsync(
|
|
252
270
|
jobId: string,
|
|
253
271
|
shardsJson: string,
|
package/src/compositionSpike.ts
CHANGED
|
@@ -106,6 +106,16 @@ export interface CompositionSpikeState {
|
|
|
106
106
|
releases?: number
|
|
107
107
|
readinessTimeouts?: number
|
|
108
108
|
readinessFailure?: string | null
|
|
109
|
+
/**
|
|
110
|
+
* How many resumes had to be re-primed because no frame could satisfy them.
|
|
111
|
+
*
|
|
112
|
+
* A resume whose target sits past the newest frame cannot be released on
|
|
113
|
+
* anything that exists, so playback seeks to the frame on screen to make the
|
|
114
|
+
* decoder produce one. Counted because it is a recovery, not a normal path:
|
|
115
|
+
* a number that climbs steadily is the renderer falling behind the transport
|
|
116
|
+
* more often than it should.
|
|
117
|
+
*/
|
|
118
|
+
primedResumes?: number
|
|
109
119
|
/** How long the last hold lasted before playback was released. */
|
|
110
120
|
holdMs?: number
|
|
111
121
|
/**
|
package/src/exporter.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
validateAudioMixProgram,
|
|
17
17
|
validateProject,
|
|
18
18
|
VIDEO_SCENE_PROGRAM_VERSION,
|
|
19
|
+
type AnimatedFrameSchedule,
|
|
19
20
|
type ExportSourceContext,
|
|
20
21
|
type MediaSource,
|
|
21
22
|
type ResampleRequest,
|
|
@@ -190,6 +191,18 @@ export interface Media3ExporterOptions {
|
|
|
190
191
|
* @experimental
|
|
191
192
|
*/
|
|
192
193
|
videoSpeedVia?: 'cadence' | 'media3'
|
|
194
|
+
/**
|
|
195
|
+
* Render one photo clip to the PNG a photo-story export encodes.
|
|
196
|
+
*
|
|
197
|
+
* A photo project has no video source for the base frame, so the host renders
|
|
198
|
+
* it through the same document renderer the preview uses and returns a local
|
|
199
|
+
* PNG path. Without this a photo project is refused exactly as before.
|
|
200
|
+
*
|
|
201
|
+
* Only a project consisting of exactly one photo is accepted: a sequence of
|
|
202
|
+
* stills is a slideshow with its own timeline rules, and silently treating it
|
|
203
|
+
* as one long still would be a different composition.
|
|
204
|
+
*/
|
|
205
|
+
renderPhotoBase?(source: MediaSource, clipId: string): Promise<string>
|
|
193
206
|
}
|
|
194
207
|
|
|
195
208
|
/** Why an export will not start. @experimental */
|
|
@@ -205,6 +218,24 @@ export class VideoExportRejected extends Error {
|
|
|
205
218
|
/** The frame rate an export runs at when the spec does not say. */
|
|
206
219
|
const DEFAULT_FPS = 30
|
|
207
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Timings carried by animated stickers in this project.
|
|
223
|
+
*
|
|
224
|
+
* Without them the compiled scene names the overlay once, so native is handed
|
|
225
|
+
* the overlay's base id instead of a per-frame resource and refuses the first
|
|
226
|
+
* frame it cannot find. The schedule lives on the overlay's own data, which is
|
|
227
|
+
* where the app that built the story put it.
|
|
228
|
+
*/
|
|
229
|
+
function animatedSchedulesFor(project: VideoProject): Record<string, AnimatedFrameSchedule> {
|
|
230
|
+
const schedules: Record<string, AnimatedFrameSchedule> = {}
|
|
231
|
+
for (const overlay of project.overlays) {
|
|
232
|
+
if (overlay.type !== 'sticker') continue
|
|
233
|
+
const data = overlay.data as { animation?: AnimatedFrameSchedule } | null
|
|
234
|
+
if (data && data.animation) schedules[overlay.id] = data.animation
|
|
235
|
+
}
|
|
236
|
+
return schedules
|
|
237
|
+
}
|
|
238
|
+
|
|
208
239
|
|
|
209
240
|
/**
|
|
210
241
|
* What this slice cannot encode, stated rather than skipped.
|
|
@@ -212,12 +243,22 @@ const DEFAULT_FPS = 30
|
|
|
212
243
|
* Each of these has a milestone. Until then the export fails naming the clip or
|
|
213
244
|
* the field, because the alternative is a file that looks finished.
|
|
214
245
|
*/
|
|
215
|
-
function describeUnsupported(
|
|
246
|
+
function describeUnsupported(
|
|
247
|
+
project: VideoProject,
|
|
248
|
+
spec: VideoExportSpec,
|
|
249
|
+
options: Media3ExporterOptions,
|
|
250
|
+
): string[] {
|
|
216
251
|
const reasons: string[] = []
|
|
217
252
|
|
|
218
253
|
for (const clip of project.clips) {
|
|
219
254
|
if (clip.kind === 'photo') {
|
|
220
|
-
|
|
255
|
+
if (!options.renderPhotoBase) {
|
|
256
|
+
reasons.push(`clip '${clip.id}' is a photo, and no photo renderer was supplied`)
|
|
257
|
+
} else if (project.clips.length !== 1) {
|
|
258
|
+
reasons.push(
|
|
259
|
+
`clip '${clip.id}' is a photo in a project of ${project.clips.length} clips; only a single-photo story is supported`,
|
|
260
|
+
)
|
|
261
|
+
}
|
|
221
262
|
continue
|
|
222
263
|
}
|
|
223
264
|
}
|
|
@@ -273,7 +314,7 @@ export function createMedia3VideoExporter(options: Media3ExporterOptions): Video
|
|
|
273
314
|
if (issues.length > 0) {
|
|
274
315
|
throw new VideoExportRejected(issues.map((issue) => issue.message))
|
|
275
316
|
}
|
|
276
|
-
const unsupported = describeUnsupported(project, spec)
|
|
317
|
+
const unsupported = describeUnsupported(project, spec, options)
|
|
277
318
|
if (unsupported.length > 0) throw new VideoExportRejected(unsupported)
|
|
278
319
|
|
|
279
320
|
/*
|
|
@@ -287,6 +328,116 @@ export function createMedia3VideoExporter(options: Media3ExporterOptions): Video
|
|
|
287
328
|
if (decision.destination === null) throw new VideoExportRejected(decision.reasons)
|
|
288
329
|
const destination = decision.destination
|
|
289
330
|
|
|
331
|
+
/*
|
|
332
|
+
A photo story is encoded as one still, not as a video of the same
|
|
333
|
+
length. The compiled program still carries the animated overlays, and
|
|
334
|
+
native draws them over the image through the same scene path.
|
|
335
|
+
*/
|
|
336
|
+
const photoOnly = project.clips.length === 1 && project.clips[0]!.kind === 'photo'
|
|
337
|
+
if (photoOnly) {
|
|
338
|
+
const clip = project.clips[0]!
|
|
339
|
+
if (!options.renderPhotoBase) {
|
|
340
|
+
throw new VideoExportRejected([`clip '${clip.id}' is a photo, and no photo renderer was supplied`])
|
|
341
|
+
}
|
|
342
|
+
const pngPath = await options.renderPhotoBase(clip.source, clip.id)
|
|
343
|
+
job.checkpoint('resolving')
|
|
344
|
+
const photoProgram = compileVideoSceneProgram(project, {
|
|
345
|
+
animatedSchedules: animatedSchedulesFor(project),
|
|
346
|
+
})
|
|
347
|
+
if (photoProgram.segments.length === 0) {
|
|
348
|
+
throw new VideoExportRejected(['the compiled program has no segments'])
|
|
349
|
+
}
|
|
350
|
+
const named = new Set<string>()
|
|
351
|
+
for (const segment of photoProgram.segments) {
|
|
352
|
+
for (const command of segment.commands) {
|
|
353
|
+
if (command.op === 'overlay') named.add(command.overlayId)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const overlayPlan: OverlayPreparationResult = options.prepareOverlays
|
|
357
|
+
? options.prepareOverlays(photoProgram, project.size, 1)
|
|
358
|
+
: {
|
|
359
|
+
resources: [],
|
|
360
|
+
pending: [],
|
|
361
|
+
invalid: [...named].map((overlayId) => ({
|
|
362
|
+
overlayId,
|
|
363
|
+
kind: 'unknown',
|
|
364
|
+
reason: 'no prepareOverlays was supplied, so this overlay has no portable form',
|
|
365
|
+
})),
|
|
366
|
+
}
|
|
367
|
+
const stagedNow = overlayPlan.resources.map((resource) => resource.file)
|
|
368
|
+
if (overlayPlan.pending.length > 0) {
|
|
369
|
+
options.releaseResources?.(stagedNow)
|
|
370
|
+
throw new VideoExportRejected(
|
|
371
|
+
overlayPlan.pending.map(
|
|
372
|
+
(id: string) => `overlay '${id}' is still preparing; export would omit it`,
|
|
373
|
+
),
|
|
374
|
+
)
|
|
375
|
+
}
|
|
376
|
+
if (overlayPlan.invalid.length > 0) {
|
|
377
|
+
options.releaseResources?.(stagedNow)
|
|
378
|
+
throw new VideoExportRejected(
|
|
379
|
+
overlayPlan.invalid.map(
|
|
380
|
+
(bad: { overlayId: string; kind: string; reason: string }) =>
|
|
381
|
+
`overlay '${bad.overlayId}' (${bad.kind}): ${bad.reason}`,
|
|
382
|
+
),
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
job.checkpoint('overlays')
|
|
386
|
+
let outcome: { ok: boolean; uri: string; error: string }
|
|
387
|
+
try {
|
|
388
|
+
outcome = JSON.parse(
|
|
389
|
+
await nativeModule().exportRunPhotoProjectAsync(
|
|
390
|
+
job.id,
|
|
391
|
+
pngPath,
|
|
392
|
+
JSON.stringify({
|
|
393
|
+
version: VIDEO_SCENE_PROGRAM_VERSION,
|
|
394
|
+
projectSize: { width: project.size.width, height: project.size.height },
|
|
395
|
+
durationUs: photoProgram.durationUs,
|
|
396
|
+
segments: photoProgram.segments,
|
|
397
|
+
}),
|
|
398
|
+
JSON.stringify(overlayPlan.resources),
|
|
399
|
+
projectDuration(project),
|
|
400
|
+
180,
|
|
401
|
+
destination.kind === 'file'
|
|
402
|
+
? JSON.stringify({
|
|
403
|
+
kind: 'file',
|
|
404
|
+
path: destination.path,
|
|
405
|
+
overwrite: destination.overwrite,
|
|
406
|
+
})
|
|
407
|
+
: '',
|
|
408
|
+
),
|
|
409
|
+
) as { ok: boolean; uri: string; error: string }
|
|
410
|
+
} finally {
|
|
411
|
+
options.releaseResources?.(stagedNow)
|
|
412
|
+
}
|
|
413
|
+
if (!outcome.ok) {
|
|
414
|
+
const error = outcome.error || 'the export failed'
|
|
415
|
+
if (isRefusal(error)) throw new VideoExportRejected([error])
|
|
416
|
+
throw new VideoExportFailed(job.id, 'encoding', error)
|
|
417
|
+
}
|
|
418
|
+
const probe = JSON.parse(nativeModule().exportProbeOutput(outcome.uri)) as {
|
|
419
|
+
readable: boolean
|
|
420
|
+
error?: string
|
|
421
|
+
}
|
|
422
|
+
if (!probe.readable) {
|
|
423
|
+
throw new VideoExportFailed(
|
|
424
|
+
job.id,
|
|
425
|
+
'publishing',
|
|
426
|
+
`${describeTarget(outcome.uri)} could not be read back: ${probe.error ?? 'no video track'}`,
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
if (destination.kind === 'file') return { kind: 'file', uri: outcome.uri }
|
|
430
|
+
const temporaryUri = outcome.uri
|
|
431
|
+
return {
|
|
432
|
+
kind: 'temporary',
|
|
433
|
+
uri: temporaryUri,
|
|
434
|
+
release: async () => {
|
|
435
|
+
await Promise.resolve()
|
|
436
|
+
nativeModule().exportReleaseTemporaryOutput(temporaryUri)
|
|
437
|
+
},
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
290
441
|
const playable = project.clips.filter((clip) => isPlayableWindow(clip))
|
|
291
442
|
if (playable.length === 0) throw new VideoExportRejected(['the project has no playable clips'])
|
|
292
443
|
if (!(projectDuration(project) > 0)) {
|
|
@@ -317,7 +468,9 @@ export function createMedia3VideoExporter(options: Media3ExporterOptions): Video
|
|
|
317
468
|
targetFps: fps,
|
|
318
469
|
}))
|
|
319
470
|
const plan = planClipShards(requests, fps)
|
|
320
|
-
const program = compileVideoSceneProgram(project
|
|
471
|
+
const program = compileVideoSceneProgram(project, {
|
|
472
|
+
animatedSchedules: animatedSchedulesFor(project),
|
|
473
|
+
})
|
|
321
474
|
if (program.segments.length === 0) {
|
|
322
475
|
throw new VideoExportRejected(['the compiled program has no segments'])
|
|
323
476
|
}
|
|
@@ -105,6 +105,19 @@ export interface CompositionPlaybackState {
|
|
|
105
105
|
width: number
|
|
106
106
|
height: number
|
|
107
107
|
error: string | null
|
|
108
|
+
/**
|
|
109
|
+
* Why the transport refused to start, or null.
|
|
110
|
+
*
|
|
111
|
+
* Playback is released on a frame that shows the position asked for, and if
|
|
112
|
+
* no such frame arrives the hold times out and the transport stays stopped
|
|
113
|
+
* rather than starting on an unverified picture. That used to be invisible:
|
|
114
|
+
* the control stayed enabled, the press was accepted, and nothing happened,
|
|
115
|
+
* with no way for a host to tell that apart from a person who had not pressed
|
|
116
|
+
* it yet. Pressing play again clears it and tries afresh.
|
|
117
|
+
*
|
|
118
|
+
* @experimental
|
|
119
|
+
*/
|
|
120
|
+
readinessFailure: string | null
|
|
108
121
|
}
|
|
109
122
|
|
|
110
123
|
function composedState(snapshot: CompositionSpikeState): CompositionPlaybackState {
|
|
@@ -130,6 +143,7 @@ function composedState(snapshot: CompositionSpikeState): CompositionPlaybackStat
|
|
|
130
143
|
width: 0,
|
|
131
144
|
height: 0,
|
|
132
145
|
error: snapshot.error ?? null,
|
|
146
|
+
readinessFailure: snapshot.readinessFailure ?? null,
|
|
133
147
|
}
|
|
134
148
|
}
|
|
135
149
|
|