@pictor-sdk/video-android 0.1.6 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pictor-sdk/video-android",
3
- "version": "0.1.6",
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.1.2"
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.1.2"
43
+ "@pictor-sdk/video-core": "0.2.0"
44
44
  },
45
45
  "codegenConfig": {
46
46
  "name": "PictorVideoSpec",
@@ -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/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(project: VideoProject, spec: VideoExportSpec): string[] {
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
- reasons.push(`clip '${clip.id}' is a photo, which this exporter does not encode yet`)
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
  }