@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/android/src/main/java/dev/pictor/video/ExportGate.kt +117 -0
- package/android/src/main/java/dev/pictor/video/PictorVideoModule.kt +44 -0
- package/dist/index.d.mts +12 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +117 -4
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +117 -4
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/NativePictorVideo.ts +18 -0
- package/src/exporter.ts +157 -4
|
@@ -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
|
*
|
package/dist/index.d.mts
CHANGED
|
@@ -1318,6 +1318,18 @@ interface Media3ExporterOptions {
|
|
|
1318
1318
|
* @experimental
|
|
1319
1319
|
*/
|
|
1320
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>;
|
|
1321
1333
|
}
|
|
1322
1334
|
/** Why an export will not start. @experimental */
|
|
1323
1335
|
declare class VideoExportRejected extends Error {
|
package/dist/index.d.ts
CHANGED
|
@@ -1318,6 +1318,18 @@ interface Media3ExporterOptions {
|
|
|
1318
1318
|
* @experimental
|
|
1319
1319
|
*/
|
|
1320
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>;
|
|
1321
1333
|
}
|
|
1322
1334
|
/** Why an export will not start. @experimental */
|
|
1323
1335
|
declare class VideoExportRejected extends Error {
|
package/dist/index.js
CHANGED
|
@@ -1825,11 +1825,26 @@ var VideoExportRejected = class extends Error {
|
|
|
1825
1825
|
}
|
|
1826
1826
|
};
|
|
1827
1827
|
var DEFAULT_FPS = 30;
|
|
1828
|
-
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) {
|
|
1829
1838
|
const reasons = [];
|
|
1830
1839
|
for (const clip of project.clips) {
|
|
1831
1840
|
if (clip.kind === "photo") {
|
|
1832
|
-
|
|
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
|
+
}
|
|
1833
1848
|
continue;
|
|
1834
1849
|
}
|
|
1835
1850
|
}
|
|
@@ -1861,11 +1876,107 @@ function createMedia3VideoExporter(options) {
|
|
|
1861
1876
|
if (issues.length > 0) {
|
|
1862
1877
|
throw new VideoExportRejected(issues.map((issue) => issue.message));
|
|
1863
1878
|
}
|
|
1864
|
-
const unsupported = describeUnsupported(project, spec);
|
|
1879
|
+
const unsupported = describeUnsupported(project, spec, options);
|
|
1865
1880
|
if (unsupported.length > 0) throw new VideoExportRejected(unsupported);
|
|
1866
1881
|
const decision = resolveDestination(spec.destination);
|
|
1867
1882
|
if (decision.destination === null) throw new VideoExportRejected(decision.reasons);
|
|
1868
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
|
+
}
|
|
1869
1980
|
const playable = project.clips.filter((clip) => (0, import_video_core6.isPlayableWindow)(clip));
|
|
1870
1981
|
if (playable.length === 0) throw new VideoExportRejected(["the project has no playable clips"]);
|
|
1871
1982
|
if (!((0, import_video_core6.projectDuration)(project) > 0)) {
|
|
@@ -1892,7 +2003,9 @@ function createMedia3VideoExporter(options) {
|
|
|
1892
2003
|
targetFps: fps
|
|
1893
2004
|
}));
|
|
1894
2005
|
const plan = (0, import_video_core6.planClipShards)(requests, fps);
|
|
1895
|
-
const program = (0, import_video_core6.compileVideoSceneProgram)(project
|
|
2006
|
+
const program = (0, import_video_core6.compileVideoSceneProgram)(project, {
|
|
2007
|
+
animatedSchedules: animatedSchedulesFor(project)
|
|
2008
|
+
});
|
|
1896
2009
|
if (program.segments.length === 0) {
|
|
1897
2010
|
throw new VideoExportRejected(["the compiled program has no segments"]);
|
|
1898
2011
|
}
|