@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
|
@@ -212,6 +212,9 @@ internal object CompositionSpike {
|
|
|
212
212
|
private set
|
|
213
213
|
|
|
214
214
|
@Volatile var readinessTimeouts = 0L
|
|
215
|
+
|
|
216
|
+
/** How many resumes had to be re-primed because no frame could satisfy them. */
|
|
217
|
+
@Volatile var primedResumes = 0L
|
|
215
218
|
private set
|
|
216
219
|
|
|
217
220
|
/** How long a hold may wait before it is reported as a failure. */
|
|
@@ -408,19 +411,24 @@ internal object CompositionSpike {
|
|
|
408
411
|
*/
|
|
409
412
|
if (seamHoldTargetUs >= 0) return pts >= seamHoldTargetUs
|
|
410
413
|
|
|
411
|
-
return pts
|
|
412
|
-
pts <= epochTargetUs + FRAME_MATCH_AHEAD_US
|
|
414
|
+
return ResumeReadiness.matches(pts, epochTargetUs)
|
|
413
415
|
}
|
|
414
416
|
|
|
415
417
|
/**
|
|
416
|
-
*
|
|
418
|
+
* The newest frame the pipeline actually has, or null if there is none.
|
|
417
419
|
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
420
|
+
* The same three checks the gate makes before it will look at a timestamp:
|
|
421
|
+
* latched rather than merely offered, pixels and timestamp from the same
|
|
422
|
+
* frame, and a usable project time. A caller deciding where to re-prime has
|
|
423
|
+
* to be reading the same frame the gate will be judging.
|
|
421
424
|
*/
|
|
422
|
-
private
|
|
423
|
-
|
|
425
|
+
private fun latchedFramePtsUs(): Long? {
|
|
426
|
+
val frame = FrameClock.latestSample() ?: return null
|
|
427
|
+
if (frame[FrameClock.Field.CONSUMED_NS] <= 0L) return null
|
|
428
|
+
if (frame[FrameClock.Field.ASSOCIATION] != 1L) return null
|
|
429
|
+
val pts = frame[FrameClock.Field.PTS_US]
|
|
430
|
+
return if (pts < 0L) null else pts
|
|
431
|
+
}
|
|
424
432
|
|
|
425
433
|
/** Retained evidence, so an excursion can be looked at rather than inferred. */
|
|
426
434
|
private const val RING = 8192
|
|
@@ -660,6 +668,7 @@ internal object CompositionSpike {
|
|
|
660
668
|
.put("releases", releases)
|
|
661
669
|
.put("readinessTimeouts", readinessTimeouts)
|
|
662
670
|
.put("readinessFailure", readinessFailure ?: JSONObject.NULL)
|
|
671
|
+
.put("primedResumes", primedResumes)
|
|
663
672
|
/*
|
|
664
673
|
Hold durations, listed rather than summarised.
|
|
665
674
|
|
|
@@ -912,7 +921,38 @@ internal object CompositionSpike {
|
|
|
912
921
|
fun play(): String = onPlayer {
|
|
913
922
|
intentPlaying = true
|
|
914
923
|
// Resume targets where the transport already is.
|
|
915
|
-
|
|
924
|
+
val clockUs = it.currentPosition * 1000
|
|
925
|
+
/*
|
|
926
|
+
Unless nothing can show that position.
|
|
927
|
+
|
|
928
|
+
Coming back from the background the renderer has stopped latching
|
|
929
|
+
while the transport kept counting, so the newest frame can sit a
|
|
930
|
+
frame or two behind the clock — past the gate's backward tolerance.
|
|
931
|
+
Arming there asks for a frame the decoder will never produce while it
|
|
932
|
+
is paused, and a seek to the position it already reports need not
|
|
933
|
+
produce one either, so the hold times out and every later press of
|
|
934
|
+
play repeats the same impossible wait.
|
|
935
|
+
|
|
936
|
+
Re-primed instead of relaxed: playback seeks to the timestamp of the
|
|
937
|
+
frame on screen, which is a real position and a different one, so the
|
|
938
|
+
decoder emits for it and the gate releases on a frame made for this
|
|
939
|
+
epoch. See `ResumeReadiness`.
|
|
940
|
+
*/
|
|
941
|
+
val primeUs =
|
|
942
|
+
if (seamHoldTargetUs >= 0) null
|
|
943
|
+
else ResumeReadiness.primeTargetUs(clockUs, latchedFramePtsUs())
|
|
944
|
+
if (primeUs == null) {
|
|
945
|
+
armRelease(clockUs)
|
|
946
|
+
return@onPlayer
|
|
947
|
+
}
|
|
948
|
+
// A new epoch, for the same reason a seek opens one: what is in flight
|
|
949
|
+
// describes the position being left, not the one being asked for.
|
|
950
|
+
epoch += 1
|
|
951
|
+
epochAtNs = System.nanoTime()
|
|
952
|
+
FrameClock.bump()
|
|
953
|
+
primedResumes += 1
|
|
954
|
+
armRelease(primeUs)
|
|
955
|
+
it.seekTo(primeUs / 1000)
|
|
916
956
|
}
|
|
917
957
|
|
|
918
958
|
fun pause(): String = onPlayer {
|
|
@@ -1598,6 +1598,123 @@ internal class ExportGate(private val context: Context) {
|
|
|
1598
1598
|
}
|
|
1599
1599
|
}
|
|
1600
1600
|
|
|
1601
|
+
/**
|
|
1602
|
+
* Export a single still photograph with animated overlays.
|
|
1603
|
+
*
|
|
1604
|
+
* The still is encoded directly from the PNG through the same image path
|
|
1605
|
+
* `runStill` proves, and the overlays are the same compiled scene program a
|
|
1606
|
+
* video project uses. There is no audio and no mux: a photo story gets a
|
|
1607
|
+
* silent MP4 whose duration is the story duration.
|
|
1608
|
+
*
|
|
1609
|
+
* Publication is the same staged rename as `runProject`, because the failure
|
|
1610
|
+
* that matters is identical — a half-written file replacing a good one.
|
|
1611
|
+
*/
|
|
1612
|
+
fun runPhotoProject(
|
|
1613
|
+
pngBytes: ByteArray,
|
|
1614
|
+
programJson: String,
|
|
1615
|
+
overlaysJson: String,
|
|
1616
|
+
durationUs: Long,
|
|
1617
|
+
timeoutSeconds: Long = 180,
|
|
1618
|
+
destinationJson: String = "",
|
|
1619
|
+
): Outcome {
|
|
1620
|
+
val target = if (destinationJson.isEmpty()) null else GateDestination.parse(destinationJson)
|
|
1621
|
+
if (target != null) {
|
|
1622
|
+
GateDestination.validate(target)?.let {
|
|
1623
|
+
refusals.incrementAndGet()
|
|
1624
|
+
return failed(it, 0, Thread.currentThread().name)
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
if (durationUs <= 0) {
|
|
1628
|
+
refusals.incrementAndGet()
|
|
1629
|
+
return failed("photo duration must be positive", 0, Thread.currentThread().name)
|
|
1630
|
+
}
|
|
1631
|
+
val (compiled, invalid) = SceneProgramNative.parse(programJson)
|
|
1632
|
+
if (compiled == null) {
|
|
1633
|
+
refusals.incrementAndGet()
|
|
1634
|
+
return failed("program rejected: " + (invalid?.reason ?: "unknown"), 0, Thread.currentThread().name)
|
|
1635
|
+
}
|
|
1636
|
+
val (resources, badResource) = OverlayResources.parse(context, overlaysJson)
|
|
1637
|
+
if (resources == null) {
|
|
1638
|
+
refusals.incrementAndGet()
|
|
1639
|
+
return failed(
|
|
1640
|
+
"overlay resources rejected: " + (badResource?.reason ?: "unknown"),
|
|
1641
|
+
0, Thread.currentThread().name,
|
|
1642
|
+
)
|
|
1643
|
+
}
|
|
1644
|
+
for (id in compiled.requiredResourceIds()) {
|
|
1645
|
+
if (!resources.entries.containsKey(id)) {
|
|
1646
|
+
refusals.incrementAndGet()
|
|
1647
|
+
return failed(
|
|
1648
|
+
"overlay resource '" + id + "' is referenced by the program but not supplied",
|
|
1649
|
+
0, Thread.currentThread().name,
|
|
1650
|
+
)
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
val still = File.createTempFile(TEMP_PREFIX + "photo-", ".png", context.cacheDir)
|
|
1654
|
+
synchronized(created) { tempFilesCreated.incrementAndGet(); created.add(still) }
|
|
1655
|
+
var videoUri: String? = null
|
|
1656
|
+
try {
|
|
1657
|
+
still.writeBytes(pngBytes)
|
|
1658
|
+
val video = runSequence(
|
|
1659
|
+
listOf(still.absolutePath), timeoutSeconds, compiled,
|
|
1660
|
+
withSkia = true, stillDurationUs = durationUs, overlays = resources,
|
|
1661
|
+
)
|
|
1662
|
+
if (!video.ok) return video
|
|
1663
|
+
videoUri = video.uri
|
|
1664
|
+
if (target == null) return video.copy(hasAudio = false)
|
|
1665
|
+
if (cancelled) {
|
|
1666
|
+
return failed("cancelled during publishing", 0, Thread.currentThread().name)
|
|
1667
|
+
}
|
|
1668
|
+
val output = File(video.uri)
|
|
1669
|
+
val sweptStaging = GateDestination.sweepStaging(target)
|
|
1670
|
+
val staging = GateDestination.stagingFor(target, System.nanoTime())
|
|
1671
|
+
synchronized(created) { tempFilesCreated.incrementAndGet(); created.add(staging) }
|
|
1672
|
+
val stageError = GateDestination.stage(
|
|
1673
|
+
output, staging, keepGoing = { !cancelled },
|
|
1674
|
+
beatEpoch = ExportHeartbeat.currentEpoch(),
|
|
1675
|
+
)
|
|
1676
|
+
if (stageError != null) {
|
|
1677
|
+
releaseTemp(staging)
|
|
1678
|
+
return failed(
|
|
1679
|
+
if (stageError == "cancelled") "cancelled during publishing" else stageError,
|
|
1680
|
+
0, Thread.currentThread().name,
|
|
1681
|
+
)
|
|
1682
|
+
}
|
|
1683
|
+
val stagedProbe = GateDestination.probe(staging.absolutePath)
|
|
1684
|
+
if (!stagedProbe.optBoolean("readable", false)) {
|
|
1685
|
+
releaseTemp(staging)
|
|
1686
|
+
return failed(
|
|
1687
|
+
"the staged file could not be read back: " + stagedProbe.optString("error", "no video track"),
|
|
1688
|
+
0, Thread.currentThread().name,
|
|
1689
|
+
)
|
|
1690
|
+
}
|
|
1691
|
+
GateDestination.publish(staging, target)?.let {
|
|
1692
|
+
releaseTemp(staging)
|
|
1693
|
+
return failed(it, 0, Thread.currentThread().name)
|
|
1694
|
+
}
|
|
1695
|
+
synchronized(created) {
|
|
1696
|
+
if (created.remove(staging)) tempFilesReleased.incrementAndGet()
|
|
1697
|
+
}
|
|
1698
|
+
return video.copy(
|
|
1699
|
+
uri = target.path,
|
|
1700
|
+
fileBytes = File(target.path).length(),
|
|
1701
|
+
hasAudio = false,
|
|
1702
|
+
boundary = if (sweptStaging > 0) "sweptStaging=" + sweptStaging else video.boundary,
|
|
1703
|
+
)
|
|
1704
|
+
} finally {
|
|
1705
|
+
releaseTemp(still)
|
|
1706
|
+
/*
|
|
1707
|
+
A temporary belongs to the caller.
|
|
1708
|
+
|
|
1709
|
+
The adapter probes the returned uri before it reports success, so
|
|
1710
|
+
releasing it here deletes the file out from under that probe —
|
|
1711
|
+
which is exactly an unopenable "success". Only a published file
|
|
1712
|
+
leaves a temp of ours to clean up.
|
|
1713
|
+
*/
|
|
1714
|
+
if (target != null) videoUri?.let { releaseTemp(File(it)) }
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1601
1718
|
/**
|
|
1602
1719
|
* One audio-only pass over the whole timeline.
|
|
1603
1720
|
*
|
|
@@ -369,6 +369,50 @@ class PictorVideoModule(reactContext: ReactApplicationContext) :
|
|
|
369
369
|
return GateFrames.framePng(reactApplicationContext, uri, timeUs.toLong())
|
|
370
370
|
}
|
|
371
371
|
|
|
372
|
+
/**
|
|
373
|
+
* Export a photo story: one still, animated overlays, no audio.
|
|
374
|
+
*
|
|
375
|
+
* The base is PNG bytes because a photo story has no video source. Animated
|
|
376
|
+
* overlays are the same resource list the video exporter prepares, so the
|
|
377
|
+
* still path cannot drift into a second renderer.
|
|
378
|
+
*/
|
|
379
|
+
@androidx.media3.common.util.UnstableApi
|
|
380
|
+
override fun exportRunPhotoProjectAsync(
|
|
381
|
+
jobId: String,
|
|
382
|
+
pngPath: String,
|
|
383
|
+
programJson: String,
|
|
384
|
+
overlaysJson: String,
|
|
385
|
+
durationUs: Double,
|
|
386
|
+
timeoutSeconds: Double,
|
|
387
|
+
destinationJson: String,
|
|
388
|
+
promise: com.facebook.react.bridge.Promise,
|
|
389
|
+
) {
|
|
390
|
+
val running = gate ?: ExportGate(reactApplicationContext).also { gate = it }
|
|
391
|
+
if (!running.beginJob(jobId)) {
|
|
392
|
+
promise.resolve(
|
|
393
|
+
org.json.JSONObject().put("ok", false)
|
|
394
|
+
.put("error", "an export is already running").toString(),
|
|
395
|
+
)
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
exportExecutor.execute {
|
|
399
|
+
val outcome = try {
|
|
400
|
+
val bytes = java.io.File(pngPath).readBytes()
|
|
401
|
+
val result = running.runPhotoProject(
|
|
402
|
+
bytes, programJson, overlaysJson, durationUs.toLong(),
|
|
403
|
+
timeoutSeconds.toLong(), destinationJson,
|
|
404
|
+
)
|
|
405
|
+
report(result, running)
|
|
406
|
+
} catch (e: Throwable) {
|
|
407
|
+
org.json.JSONObject().put("ok", false)
|
|
408
|
+
.put("error", e.javaClass.simpleName + ": " + e.message).toString()
|
|
409
|
+
} finally {
|
|
410
|
+
running.endJob()
|
|
411
|
+
}
|
|
412
|
+
promise.resolve(outcome)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
372
416
|
/**
|
|
373
417
|
* Run an export off the bridge, so it can be cancelled while it runs.
|
|
374
418
|
*
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
package dev.pictor.video
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where a resume may be released, and where it has to be re-primed first.
|
|
5
|
+
*
|
|
6
|
+
* The readiness gate releases the transport only on a frame that shows the
|
|
7
|
+
* epoch's position. That is the right rule, and it is kept exactly: this is the
|
|
8
|
+
* arithmetic behind it, pulled out so it can be reasoned about and tested
|
|
9
|
+
* without a player.
|
|
10
|
+
*
|
|
11
|
+
* ## The failure it exists for
|
|
12
|
+
*
|
|
13
|
+
* A resume's target is the transport clock, and the clock can sit ahead of the
|
|
14
|
+
* newest frame that exists. When the app goes to the background the renderer
|
|
15
|
+
* stops latching before the transport stops counting, so the last frame on
|
|
16
|
+
* screen ends up a frame or two behind the position the player reports. Arming
|
|
17
|
+
* on the clock then asks for a frame that nothing will produce — the decoder is
|
|
18
|
+
* paused, and Media3 need not emit anything for a seek to the position it is
|
|
19
|
+
* already at — so the gate waits out its three seconds and the transport is
|
|
20
|
+
* left stopped. Pressing play again re-arms the same impossible wait.
|
|
21
|
+
*
|
|
22
|
+
* Measured on a Pixel 7, opening the system document picker from "Add music":
|
|
23
|
+
* targets 46,667 us and 53,334 us ahead of the newest frame against a 40,000 us
|
|
24
|
+
* backward tolerance, wedging two runs in five. The runs that recovered had
|
|
25
|
+
* gaps inside the tolerance.
|
|
26
|
+
*
|
|
27
|
+
* ## What is done about it
|
|
28
|
+
*
|
|
29
|
+
* Re-prime, rather than relax. When no frame can satisfy the hold, playback
|
|
30
|
+
* seeks to the timestamp of the frame already on screen — a real position that
|
|
31
|
+
* differs from the clock, so the decoder emits for it — and the gate then
|
|
32
|
+
* releases on a frame produced for this epoch. The tolerances are untouched,
|
|
33
|
+
* nothing starts on an unverified frame, and the distance moved is whatever the
|
|
34
|
+
* renderer fell behind rather than a nudge chosen to clear the comparison.
|
|
35
|
+
*/
|
|
36
|
+
internal object ResumeReadiness {
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* How far a frame may sit from the epoch's target and still be its first.
|
|
40
|
+
*
|
|
41
|
+
* Backwards by about a frame, because a seek lands on the preceding
|
|
42
|
+
* keyframe-derived sample; forwards by a second, because the first frame
|
|
43
|
+
* after a seek into a long GOP can legitimately be later than asked for.
|
|
44
|
+
*/
|
|
45
|
+
const val FRAME_MATCH_BACK_US = 40_000L
|
|
46
|
+
const val FRAME_MATCH_AHEAD_US = 1_000_000L
|
|
47
|
+
|
|
48
|
+
/** Whether a frame at [framePtsUs] shows the position [targetUs] asks for. */
|
|
49
|
+
fun matches(framePtsUs: Long, targetUs: Long): Boolean =
|
|
50
|
+
framePtsUs >= targetUs - FRAME_MATCH_BACK_US &&
|
|
51
|
+
framePtsUs <= targetUs + FRAME_MATCH_AHEAD_US
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The position a stalled resume has to re-prime at, or null to wait as usual.
|
|
55
|
+
*
|
|
56
|
+
* [framePtsUs] is the newest latched, associated frame of the current
|
|
57
|
+
* generation, or null when there is none — after a seek, for instance, where
|
|
58
|
+
* `FrameClock.bump` has cleared it and the seek's own frame is on its way.
|
|
59
|
+
* With nothing latched there is nothing to re-prime at and nothing to
|
|
60
|
+
* conclude, so the ordinary wait applies.
|
|
61
|
+
*
|
|
62
|
+
* Returns null when the gate can already be satisfied, so an ordinary start
|
|
63
|
+
* or seek is completely unaffected: this only fires where playback would
|
|
64
|
+
* otherwise wait for a frame that cannot arrive.
|
|
65
|
+
*
|
|
66
|
+
* A frame further behind than [FRAME_MATCH_AHEAD_US] is not treated as the
|
|
67
|
+
* current picture at all. That is a stalled pipeline rather than a renderer
|
|
68
|
+
* that stopped a couple of frames early, and seeking a second backwards to
|
|
69
|
+
* meet it would be starting playback somewhere nobody asked for. It is left
|
|
70
|
+
* to time out and be reported, which is what that timeout is for.
|
|
71
|
+
*/
|
|
72
|
+
fun primeTargetUs(clockUs: Long, framePtsUs: Long?): Long? {
|
|
73
|
+
if (framePtsUs == null) return null
|
|
74
|
+
if (matches(framePtsUs, clockUs)) return null
|
|
75
|
+
// Ahead of the clock is not this problem: a frame in front of the target
|
|
76
|
+
// is already covered by the forward tolerance, and seeking back to it
|
|
77
|
+
// would move playback the wrong way.
|
|
78
|
+
if (framePtsUs > clockUs) return null
|
|
79
|
+
if (framePtsUs < clockUs - FRAME_MATCH_AHEAD_US) return null
|
|
80
|
+
return framePtsUs
|
|
81
|
+
}
|
|
82
|
+
}
|
package/dist/index.d.mts
CHANGED
|
@@ -1026,6 +1026,19 @@ interface CompositionPlaybackState {
|
|
|
1026
1026
|
width: number;
|
|
1027
1027
|
height: number;
|
|
1028
1028
|
error: string | null;
|
|
1029
|
+
/**
|
|
1030
|
+
* Why the transport refused to start, or null.
|
|
1031
|
+
*
|
|
1032
|
+
* Playback is released on a frame that shows the position asked for, and if
|
|
1033
|
+
* no such frame arrives the hold times out and the transport stays stopped
|
|
1034
|
+
* rather than starting on an unverified picture. That used to be invisible:
|
|
1035
|
+
* the control stayed enabled, the press was accepted, and nothing happened,
|
|
1036
|
+
* with no way for a host to tell that apart from a person who had not pressed
|
|
1037
|
+
* it yet. Pressing play again clears it and tries afresh.
|
|
1038
|
+
*
|
|
1039
|
+
* @experimental
|
|
1040
|
+
*/
|
|
1041
|
+
readinessFailure: string | null;
|
|
1029
1042
|
}
|
|
1030
1043
|
/** @experimental */
|
|
1031
1044
|
type CompositionPlayback = Omit<Media3Playback, 'state'> & {
|
|
@@ -1305,6 +1318,18 @@ interface Media3ExporterOptions {
|
|
|
1305
1318
|
* @experimental
|
|
1306
1319
|
*/
|
|
1307
1320
|
videoSpeedVia?: 'cadence' | 'media3';
|
|
1321
|
+
/**
|
|
1322
|
+
* Render one photo clip to the PNG a photo-story export encodes.
|
|
1323
|
+
*
|
|
1324
|
+
* A photo project has no video source for the base frame, so the host renders
|
|
1325
|
+
* it through the same document renderer the preview uses and returns a local
|
|
1326
|
+
* PNG path. Without this a photo project is refused exactly as before.
|
|
1327
|
+
*
|
|
1328
|
+
* Only a project consisting of exactly one photo is accepted: a sequence of
|
|
1329
|
+
* stills is a slideshow with its own timeline rules, and silently treating it
|
|
1330
|
+
* as one long still would be a different composition.
|
|
1331
|
+
*/
|
|
1332
|
+
renderPhotoBase?(source: MediaSource, clipId: string): Promise<string>;
|
|
1308
1333
|
}
|
|
1309
1334
|
/** Why an export will not start. @experimental */
|
|
1310
1335
|
declare class VideoExportRejected extends Error {
|
|
@@ -2501,6 +2526,16 @@ interface CompositionSpikeState {
|
|
|
2501
2526
|
releases?: number;
|
|
2502
2527
|
readinessTimeouts?: number;
|
|
2503
2528
|
readinessFailure?: string | null;
|
|
2529
|
+
/**
|
|
2530
|
+
* How many resumes had to be re-primed because no frame could satisfy them.
|
|
2531
|
+
*
|
|
2532
|
+
* A resume whose target sits past the newest frame cannot be released on
|
|
2533
|
+
* anything that exists, so playback seeks to the frame on screen to make the
|
|
2534
|
+
* decoder produce one. Counted because it is a recovery, not a normal path:
|
|
2535
|
+
* a number that climbs steadily is the renderer falling behind the transport
|
|
2536
|
+
* more often than it should.
|
|
2537
|
+
*/
|
|
2538
|
+
primedResumes?: number;
|
|
2504
2539
|
/** How long the last hold lasted before playback was released. */
|
|
2505
2540
|
holdMs?: number;
|
|
2506
2541
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -1026,6 +1026,19 @@ interface CompositionPlaybackState {
|
|
|
1026
1026
|
width: number;
|
|
1027
1027
|
height: number;
|
|
1028
1028
|
error: string | null;
|
|
1029
|
+
/**
|
|
1030
|
+
* Why the transport refused to start, or null.
|
|
1031
|
+
*
|
|
1032
|
+
* Playback is released on a frame that shows the position asked for, and if
|
|
1033
|
+
* no such frame arrives the hold times out and the transport stays stopped
|
|
1034
|
+
* rather than starting on an unverified picture. That used to be invisible:
|
|
1035
|
+
* the control stayed enabled, the press was accepted, and nothing happened,
|
|
1036
|
+
* with no way for a host to tell that apart from a person who had not pressed
|
|
1037
|
+
* it yet. Pressing play again clears it and tries afresh.
|
|
1038
|
+
*
|
|
1039
|
+
* @experimental
|
|
1040
|
+
*/
|
|
1041
|
+
readinessFailure: string | null;
|
|
1029
1042
|
}
|
|
1030
1043
|
/** @experimental */
|
|
1031
1044
|
type CompositionPlayback = Omit<Media3Playback, 'state'> & {
|
|
@@ -1305,6 +1318,18 @@ interface Media3ExporterOptions {
|
|
|
1305
1318
|
* @experimental
|
|
1306
1319
|
*/
|
|
1307
1320
|
videoSpeedVia?: 'cadence' | 'media3';
|
|
1321
|
+
/**
|
|
1322
|
+
* Render one photo clip to the PNG a photo-story export encodes.
|
|
1323
|
+
*
|
|
1324
|
+
* A photo project has no video source for the base frame, so the host renders
|
|
1325
|
+
* it through the same document renderer the preview uses and returns a local
|
|
1326
|
+
* PNG path. Without this a photo project is refused exactly as before.
|
|
1327
|
+
*
|
|
1328
|
+
* Only a project consisting of exactly one photo is accepted: a sequence of
|
|
1329
|
+
* stills is a slideshow with its own timeline rules, and silently treating it
|
|
1330
|
+
* as one long still would be a different composition.
|
|
1331
|
+
*/
|
|
1332
|
+
renderPhotoBase?(source: MediaSource, clipId: string): Promise<string>;
|
|
1308
1333
|
}
|
|
1309
1334
|
/** Why an export will not start. @experimental */
|
|
1310
1335
|
declare class VideoExportRejected extends Error {
|
|
@@ -2501,6 +2526,16 @@ interface CompositionSpikeState {
|
|
|
2501
2526
|
releases?: number;
|
|
2502
2527
|
readinessTimeouts?: number;
|
|
2503
2528
|
readinessFailure?: string | null;
|
|
2529
|
+
/**
|
|
2530
|
+
* How many resumes had to be re-primed because no frame could satisfy them.
|
|
2531
|
+
*
|
|
2532
|
+
* A resume whose target sits past the newest frame cannot be released on
|
|
2533
|
+
* anything that exists, so playback seeks to the frame on screen to make the
|
|
2534
|
+
* decoder produce one. Counted because it is a recovery, not a normal path:
|
|
2535
|
+
* a number that climbs steadily is the renderer falling behind the transport
|
|
2536
|
+
* more often than it should.
|
|
2537
|
+
*/
|
|
2538
|
+
primedResumes?: number;
|
|
2504
2539
|
/** How long the last hold lasted before playback was released. */
|
|
2505
2540
|
holdMs?: number;
|
|
2506
2541
|
/**
|
package/dist/index.js
CHANGED
|
@@ -884,7 +884,8 @@ function composedState(snapshot) {
|
|
|
884
884
|
*/
|
|
885
885
|
width: 0,
|
|
886
886
|
height: 0,
|
|
887
|
-
error: snapshot.error ?? null
|
|
887
|
+
error: snapshot.error ?? null,
|
|
888
|
+
readinessFailure: snapshot.readinessFailure ?? null
|
|
888
889
|
};
|
|
889
890
|
}
|
|
890
891
|
function useCompositionPlayback(options) {
|
|
@@ -1824,11 +1825,26 @@ var VideoExportRejected = class extends Error {
|
|
|
1824
1825
|
}
|
|
1825
1826
|
};
|
|
1826
1827
|
var DEFAULT_FPS = 30;
|
|
1827
|
-
function
|
|
1828
|
+
function animatedSchedulesFor(project) {
|
|
1829
|
+
const schedules = {};
|
|
1830
|
+
for (const overlay of project.overlays) {
|
|
1831
|
+
if (overlay.type !== "sticker") continue;
|
|
1832
|
+
const data = overlay.data;
|
|
1833
|
+
if (data && data.animation) schedules[overlay.id] = data.animation;
|
|
1834
|
+
}
|
|
1835
|
+
return schedules;
|
|
1836
|
+
}
|
|
1837
|
+
function describeUnsupported(project, spec, options) {
|
|
1828
1838
|
const reasons = [];
|
|
1829
1839
|
for (const clip of project.clips) {
|
|
1830
1840
|
if (clip.kind === "photo") {
|
|
1831
|
-
|
|
1841
|
+
if (!options.renderPhotoBase) {
|
|
1842
|
+
reasons.push(`clip '${clip.id}' is a photo, and no photo renderer was supplied`);
|
|
1843
|
+
} else if (project.clips.length !== 1) {
|
|
1844
|
+
reasons.push(
|
|
1845
|
+
`clip '${clip.id}' is a photo in a project of ${project.clips.length} clips; only a single-photo story is supported`
|
|
1846
|
+
);
|
|
1847
|
+
}
|
|
1832
1848
|
continue;
|
|
1833
1849
|
}
|
|
1834
1850
|
}
|
|
@@ -1860,11 +1876,107 @@ function createMedia3VideoExporter(options) {
|
|
|
1860
1876
|
if (issues.length > 0) {
|
|
1861
1877
|
throw new VideoExportRejected(issues.map((issue) => issue.message));
|
|
1862
1878
|
}
|
|
1863
|
-
const unsupported = describeUnsupported(project, spec);
|
|
1879
|
+
const unsupported = describeUnsupported(project, spec, options);
|
|
1864
1880
|
if (unsupported.length > 0) throw new VideoExportRejected(unsupported);
|
|
1865
1881
|
const decision = resolveDestination(spec.destination);
|
|
1866
1882
|
if (decision.destination === null) throw new VideoExportRejected(decision.reasons);
|
|
1867
1883
|
const destination = decision.destination;
|
|
1884
|
+
const photoOnly = project.clips.length === 1 && project.clips[0].kind === "photo";
|
|
1885
|
+
if (photoOnly) {
|
|
1886
|
+
const clip = project.clips[0];
|
|
1887
|
+
if (!options.renderPhotoBase) {
|
|
1888
|
+
throw new VideoExportRejected([`clip '${clip.id}' is a photo, and no photo renderer was supplied`]);
|
|
1889
|
+
}
|
|
1890
|
+
const pngPath = await options.renderPhotoBase(clip.source, clip.id);
|
|
1891
|
+
job.checkpoint("resolving");
|
|
1892
|
+
const photoProgram = (0, import_video_core6.compileVideoSceneProgram)(project, {
|
|
1893
|
+
animatedSchedules: animatedSchedulesFor(project)
|
|
1894
|
+
});
|
|
1895
|
+
if (photoProgram.segments.length === 0) {
|
|
1896
|
+
throw new VideoExportRejected(["the compiled program has no segments"]);
|
|
1897
|
+
}
|
|
1898
|
+
const named2 = /* @__PURE__ */ new Set();
|
|
1899
|
+
for (const segment of photoProgram.segments) {
|
|
1900
|
+
for (const command of segment.commands) {
|
|
1901
|
+
if (command.op === "overlay") named2.add(command.overlayId);
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
const overlayPlan2 = options.prepareOverlays ? options.prepareOverlays(photoProgram, project.size, 1) : {
|
|
1905
|
+
resources: [],
|
|
1906
|
+
pending: [],
|
|
1907
|
+
invalid: [...named2].map((overlayId) => ({
|
|
1908
|
+
overlayId,
|
|
1909
|
+
kind: "unknown",
|
|
1910
|
+
reason: "no prepareOverlays was supplied, so this overlay has no portable form"
|
|
1911
|
+
}))
|
|
1912
|
+
};
|
|
1913
|
+
const stagedNow2 = overlayPlan2.resources.map((resource) => resource.file);
|
|
1914
|
+
if (overlayPlan2.pending.length > 0) {
|
|
1915
|
+
options.releaseResources?.(stagedNow2);
|
|
1916
|
+
throw new VideoExportRejected(
|
|
1917
|
+
overlayPlan2.pending.map(
|
|
1918
|
+
(id) => `overlay '${id}' is still preparing; export would omit it`
|
|
1919
|
+
)
|
|
1920
|
+
);
|
|
1921
|
+
}
|
|
1922
|
+
if (overlayPlan2.invalid.length > 0) {
|
|
1923
|
+
options.releaseResources?.(stagedNow2);
|
|
1924
|
+
throw new VideoExportRejected(
|
|
1925
|
+
overlayPlan2.invalid.map(
|
|
1926
|
+
(bad) => `overlay '${bad.overlayId}' (${bad.kind}): ${bad.reason}`
|
|
1927
|
+
)
|
|
1928
|
+
);
|
|
1929
|
+
}
|
|
1930
|
+
job.checkpoint("overlays");
|
|
1931
|
+
let outcome2;
|
|
1932
|
+
try {
|
|
1933
|
+
outcome2 = JSON.parse(
|
|
1934
|
+
await nativeModule().exportRunPhotoProjectAsync(
|
|
1935
|
+
job.id,
|
|
1936
|
+
pngPath,
|
|
1937
|
+
JSON.stringify({
|
|
1938
|
+
version: import_video_core6.VIDEO_SCENE_PROGRAM_VERSION,
|
|
1939
|
+
projectSize: { width: project.size.width, height: project.size.height },
|
|
1940
|
+
durationUs: photoProgram.durationUs,
|
|
1941
|
+
segments: photoProgram.segments
|
|
1942
|
+
}),
|
|
1943
|
+
JSON.stringify(overlayPlan2.resources),
|
|
1944
|
+
(0, import_video_core6.projectDuration)(project),
|
|
1945
|
+
180,
|
|
1946
|
+
destination.kind === "file" ? JSON.stringify({
|
|
1947
|
+
kind: "file",
|
|
1948
|
+
path: destination.path,
|
|
1949
|
+
overwrite: destination.overwrite
|
|
1950
|
+
}) : ""
|
|
1951
|
+
)
|
|
1952
|
+
);
|
|
1953
|
+
} finally {
|
|
1954
|
+
options.releaseResources?.(stagedNow2);
|
|
1955
|
+
}
|
|
1956
|
+
if (!outcome2.ok) {
|
|
1957
|
+
const error = outcome2.error || "the export failed";
|
|
1958
|
+
if (isRefusal(error)) throw new VideoExportRejected([error]);
|
|
1959
|
+
throw new import_video_core6.VideoExportFailed(job.id, "encoding", error);
|
|
1960
|
+
}
|
|
1961
|
+
const probe2 = JSON.parse(nativeModule().exportProbeOutput(outcome2.uri));
|
|
1962
|
+
if (!probe2.readable) {
|
|
1963
|
+
throw new import_video_core6.VideoExportFailed(
|
|
1964
|
+
job.id,
|
|
1965
|
+
"publishing",
|
|
1966
|
+
`${describeTarget(outcome2.uri)} could not be read back: ${probe2.error ?? "no video track"}`
|
|
1967
|
+
);
|
|
1968
|
+
}
|
|
1969
|
+
if (destination.kind === "file") return { kind: "file", uri: outcome2.uri };
|
|
1970
|
+
const temporaryUri2 = outcome2.uri;
|
|
1971
|
+
return {
|
|
1972
|
+
kind: "temporary",
|
|
1973
|
+
uri: temporaryUri2,
|
|
1974
|
+
release: async () => {
|
|
1975
|
+
await Promise.resolve();
|
|
1976
|
+
nativeModule().exportReleaseTemporaryOutput(temporaryUri2);
|
|
1977
|
+
}
|
|
1978
|
+
};
|
|
1979
|
+
}
|
|
1868
1980
|
const playable = project.clips.filter((clip) => (0, import_video_core6.isPlayableWindow)(clip));
|
|
1869
1981
|
if (playable.length === 0) throw new VideoExportRejected(["the project has no playable clips"]);
|
|
1870
1982
|
if (!((0, import_video_core6.projectDuration)(project) > 0)) {
|
|
@@ -1891,7 +2003,9 @@ function createMedia3VideoExporter(options) {
|
|
|
1891
2003
|
targetFps: fps
|
|
1892
2004
|
}));
|
|
1893
2005
|
const plan = (0, import_video_core6.planClipShards)(requests, fps);
|
|
1894
|
-
const program = (0, import_video_core6.compileVideoSceneProgram)(project
|
|
2006
|
+
const program = (0, import_video_core6.compileVideoSceneProgram)(project, {
|
|
2007
|
+
animatedSchedules: animatedSchedulesFor(project)
|
|
2008
|
+
});
|
|
1895
2009
|
if (program.segments.length === 0) {
|
|
1896
2010
|
throw new VideoExportRejected(["the compiled program has no segments"]);
|
|
1897
2011
|
}
|