@hraness/slopcamera 3.3.2 → 3.3.4

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.
Files changed (37) hide show
  1. package/README.md +49 -25
  2. package/apps/desktop/application/directing-contract.ts +15 -3
  3. package/apps/desktop/application/directing-plan.ts +2 -1
  4. package/apps/desktop/application/operation.ts +1 -1
  5. package/apps/desktop/cli/args.ts +1 -1
  6. package/apps/desktop/cli/cinema-renderer.ts +9 -6
  7. package/apps/desktop/cli/commands.ts +8 -1
  8. package/apps/desktop/cli/directing-blob.ts +17 -12
  9. package/apps/desktop/cli/directing-media.ts +14 -4
  10. package/apps/desktop/cli/directing-quote.ts +29 -11
  11. package/apps/desktop/cli/directing-service.ts +6 -2
  12. package/apps/desktop/cli/gateway-media-catalog.ts +57 -0
  13. package/apps/desktop/cli/gateway-media-service.ts +12 -2
  14. package/apps/desktop/cli/help.ts +4 -4
  15. package/apps/desktop/cli/main.ts +1 -1
  16. package/apps/desktop/cli/project-renderer.ts +23 -9
  17. package/apps/desktop/cli/spatial-scene-service.ts +1 -1
  18. package/apps/desktop/dist/cli/main.js +254 -248
  19. package/apps/desktop/html-overlay/spatial-splat-runtime.ts +6 -0
  20. package/dist/cli.js +1 -1
  21. package/dist/code/index.js +4 -4
  22. package/dist/{index-gs48afpt.js → index-c8jpst4g.js} +2 -2
  23. package/dist/index-z1kd1x8j.js +3 -0
  24. package/dist/index.js +1 -1
  25. package/docs/README.md +6 -3
  26. package/docs/studio.md +63 -3
  27. package/package.json +13 -7
  28. package/skills/slopcamera/references/directing-video.md +3 -3
  29. package/skills/slopcamera/references/educational-video.md +1 -1
  30. package/skills/slopcamera/references/install.md +1 -1
  31. package/skills/slopcamera/references/parametric-design.md +5 -1
  32. package/skills/slopcamera/references/scene-building.md +2 -0
  33. package/skills/slopcamera/references/workflows-sdk.md +1 -1
  34. package/src/spatial-scene/geometry.ts +88 -35
  35. package/src/spatial-scene/parametric.ts +42 -15
  36. package/src/version.ts +1 -1
  37. package/dist/index-zhg36tf9.js +0 -3
@@ -920,3 +920,60 @@ export function createFileGatewayMediaCatalogSnapshotStore(
920
920
  },
921
921
  };
922
922
  }
923
+
924
+ /**
925
+ * Alibaba Wan video models advertise normalized catalog resolutions such as
926
+ * "720p", but the provider API accepts only explicit pixel sizes bound to
927
+ * the requested aspect ratio ("1280x720"). This adapter translates the
928
+ * catalog value through Wan's documented size table so local qualification
929
+ * and duration pricing stay anchored to the catalog row while paid dispatch
930
+ * sends the provider representation. The `1280x720` (16:9) cell and the
931
+ * `480x832` enumeration hint are verified against the live provider; the
932
+ * remaining cells mirror Wan's published size enum.
933
+ */
934
+ const WAN_RESOLUTION_PIXEL_SIZES: Readonly<
935
+ Record<string, Readonly<Record<string, string>>>
936
+ > = {
937
+ "1080p": {
938
+ "16:9": "1920x1080",
939
+ "9:16": "1080x1920",
940
+ "1:1": "1440x1440",
941
+ "3:4": "1248x1632",
942
+ "4:3": "1632x1248",
943
+ },
944
+ "480p": {
945
+ "16:9": "832x480",
946
+ "9:16": "480x832",
947
+ "1:1": "624x624",
948
+ },
949
+ "720p": {
950
+ "16:9": "1280x720",
951
+ "9:16": "720x1280",
952
+ "1:1": "960x960",
953
+ "3:4": "832x1088",
954
+ "4:3": "1088x832",
955
+ },
956
+ };
957
+
958
+ /**
959
+ * Resolve the resolution value a provider expects on the wire.
960
+ * Returns `undefined` when the request carries no resolution, `null` when a
961
+ * Wan catalog resolution cannot be mapped for the requested aspect ratio,
962
+ * and the wire-ready value otherwise.
963
+ */
964
+ export function providerVideoResolution(
965
+ modelId: string,
966
+ resolution: string | undefined,
967
+ aspectRatio: string | undefined,
968
+ ): string | null | undefined {
969
+ if (
970
+ resolution === undefined
971
+ || !modelId.startsWith("alibaba/wan-")
972
+ || !/^[1-9]\d{2,4}p$/iu.test(resolution)
973
+ ) {
974
+ return resolution;
975
+ }
976
+ // Wan produces 16:9 landscape output when a request omits the aspect ratio.
977
+ return WAN_RESOLUTION_PIXEL_SIZES[resolution.toLocaleLowerCase("en-US")]
978
+ ?.[aspectRatio ?? "16:9"] ?? null;
979
+ }
@@ -16,6 +16,7 @@ import {
16
16
  } from "./gateway-media-artifacts";
17
17
  import {
18
18
  GATEWAY_MEDIA_CATALOG_URL,
19
+ providerVideoResolution,
19
20
  type GatewayJsonValue,
20
21
  type GatewayMediaCatalogView,
21
22
  type GatewayMediaKind,
@@ -2930,6 +2931,12 @@ export function createGatewayMediaService(options: Readonly<{
2930
2931
  execution.signal,
2931
2932
  );
2932
2933
  assertVideoCapabilities(catalog.model, request);
2934
+ const wireResolution = providerVideoResolution(
2935
+ catalog.model.id,
2936
+ request.resolution,
2937
+ request.aspectRatio,
2938
+ );
2939
+ if (wireResolution === null) unsupportedModelSetting();
2933
2940
  const dispatchAt = now();
2934
2941
  const credential = await options.loadCredential(dispatchAt);
2935
2942
  const timeout = combineAbortSignals(
@@ -2989,9 +2996,9 @@ export function createGatewayMediaService(options: Readonly<{
2989
2996
  ...(request.providerOptions === undefined
2990
2997
  ? {}
2991
2998
  : { providerOptions: request.providerOptions }),
2992
- ...(request.resolution === undefined
2999
+ ...(wireResolution === undefined
2993
3000
  ? {}
2994
- : { resolution: request.resolution }),
3001
+ : { resolution: wireResolution }),
2995
3002
  ...(request.seed === undefined ? {} : { seed: request.seed }),
2996
3003
  });
2997
3004
  return parseGeneratedFiles(sdkResult, "videos", ["video/"]);
@@ -3025,6 +3032,9 @@ export function createGatewayMediaService(options: Readonly<{
3025
3032
  generateAudio: optionalSummaryValue(request.generateAudio),
3026
3033
  maxVideosPerCall: optionalSummaryValue(request.maxVideosPerCall),
3027
3034
  n: optionalSummaryValue(request.n),
3035
+ providerResolution: optionalSummaryValue(
3036
+ wireResolution === request.resolution ? undefined : wireResolution,
3037
+ ),
3028
3038
  resolution: optionalSummaryValue(request.resolution),
3029
3039
  seed: optionalSummaryValue(request.seed),
3030
3040
  }), request.prompt, request.providerOptions),
@@ -91,7 +91,7 @@ For local SDK workflows, code run, workflows run and runs resume accept
91
91
  The fixed local operation is slopcamera.studio.run; its SDK surface is studio.run().`,
92
92
  direct: `Usage:
93
93
  slopcamera direct init <recipe.json> [--json]
94
- slopcamera direct anchor --input <image> [--json]
94
+ slopcamera direct anchor --input <media> [--json]
95
95
  slopcamera direct plan <recipe.json> [--json]
96
96
  slopcamera direct start <recipe.json> --budget-usd <USD> [--json]
97
97
  slopcamera direct inspect <direct-id> [--json]
@@ -105,13 +105,13 @@ The fixed local operation is slopcamera.studio.run; its SDK surface is studio.ru
105
105
  slopcamera direct assemble <direct-id> [--json]
106
106
 
107
107
  Init writes a new recipe without overwriting. Edit its ID, shots, prompts and settings.
108
- Anchor imports one explicit local image and returns a hash-bound source reference.
108
+ Anchor imports one explicit local image or video and returns a hash-bound source reference.
109
109
  Plan checks live Gateway video capabilities and duration pricing without a paid call.
110
110
  Start retains one budget for the direct ID; starting again cannot reset it.
111
- Generate dispatches at most one new take and requires a unique take_<id>. Local images,
111
+ Generate dispatches at most one new take and requires a unique take_<id>. Local media,
112
112
  including a predecessor's endpoint, require --allow-cloud-upload on that invocation.
113
113
  URL-only models require --allow-reference-hosting and a private Vercel Blob store.
114
- Only exact reference images receive short-lived signed read URLs. Signed URLs and
114
+ Only exact shot references receive short-lived signed read URLs. Signed URLs and
115
115
  credentials are not saved. Cleanup deletes those exact objects; inspect cleanup
116
116
  receipts and use direct cleanup after interruptions. Blob charges are separate
117
117
  from the model catalog estimate and require room in your overall spending limit.
@@ -55,7 +55,7 @@ export async function main(
55
55
  }
56
56
  const paths = await resolveRepositoryPaths(processIo.cwd(), processIo.env);
57
57
  if (earlyCommand.kind === "html-render" || earlyCommand.kind === "spatial-world" || earlyCommand.kind === "spatial-asset" || earlyCommand.kind === "spatial-design" || earlyCommand.kind === "directing" || earlyCommand.kind === "studio"
58
- || earlyCommand.kind === "spatial-scene" && (earlyCommand.action === "camera-track" || earlyCommand.action === "render-audit" || earlyCommand.action === "review")) {
58
+ || earlyCommand.kind === "spatial-scene" && (earlyCommand.action === "render" || earlyCommand.action === "camera-track" || earlyCommand.action === "render-audit" || earlyCommand.action === "review")) {
59
59
  const controller = new AbortController();
60
60
  const cancel = () => controller.abort(new CliError("cancelled", "Command interrupted."));
61
61
  process.on("SIGINT", cancel);
@@ -959,6 +959,7 @@ function overlayVideoChain(
959
959
  } else {
960
960
  filters.push(
961
961
  `trim=duration=${seconds(sliceDurationUs)}`,
962
+ "settb=AVTB",
962
963
  `setpts=PTS-STARTPTS+${seconds(resolved.outputRange.startUs)}/TB`,
963
964
  );
964
965
  }
@@ -1246,7 +1247,7 @@ export async function buildProjectFfmpegInvocation(
1246
1247
  if (overlayInputIndex.has(overlay.overlayId)) continue;
1247
1248
  overlayInputIndex.set(overlay.overlayId, mediaInputIndex.size + overlayInputIndex.size);
1248
1249
  if (overlay.source.kind === "image" || overlay.source.kind === "svg" || overlay.source.kind === "emoji") {
1249
- inputArguments.push("-loop", "1");
1250
+ inputArguments.push("-loop", "1", "-framerate", emittedRate);
1250
1251
  }
1251
1252
  const prepared = await prepareProjectOverlayMedia(overlay, options);
1252
1253
  preparedOverlayMedia.set(overlay.overlayId, prepared);
@@ -1309,6 +1310,7 @@ export async function buildProjectFfmpegInvocation(
1309
1310
  } else {
1310
1311
  const transparent = `video_blend_canvas_${serial++}`;
1311
1312
  const positioned = `video_blend_positioned_${serial++}`;
1313
+ const layerColorSource = `video_blend_color_source_${serial++}`;
1312
1314
  const layerColor = `video_blend_color_${serial++}`;
1313
1315
  const layerAlphaSource = `video_blend_alpha_source_${serial++}`;
1314
1316
  const layerMask = `video_blend_mask_${serial++}`;
@@ -1317,10 +1319,13 @@ export async function buildProjectFfmpegInvocation(
1317
1319
  const blended = `video_blend_result_${serial++}`;
1318
1320
  filters.push(
1319
1321
  `color=c=black@0:s=${plan.output.pixelWidth}x${plan.output.pixelHeight}:r=${emittedRate}:d=${seconds(plan.output.durationUs)},format=rgba[${transparent}]`,
1320
- `[${transparent}][${label}]overlay=x=${transform.x}:y=${transform.y}:eof_action=repeat:repeatlast=1:enable='${enable}'[${positioned}]`,
1321
- `[${positioned}]format=rgba,split=2[${layerColor}][${layerAlphaSource}]`,
1322
+ // Preserve the positioned layer's alpha and blend color channels in RGB.
1323
+ // YUV blend arithmetic changes even uncovered pixels (neutral chroma is 128).
1324
+ `[${transparent}][${label}]overlay=x=${transform.x}:y=${transform.y}:eof_action=repeat:repeatlast=1:enable='${enable}':format=auto[${positioned}]`,
1325
+ `[${positioned}]format=rgba,split=2[${layerColorSource}][${layerAlphaSource}]`,
1326
+ `[${layerColorSource}]format=gbrp[${layerColor}]`,
1322
1327
  `[${layerAlphaSource}]alphaextract[${layerMask}]`,
1323
- `[${currentVideo}]split=2[${baseBlend}][${baseMerge}]`,
1328
+ `[${currentVideo}]format=gbrp,split=2[${baseBlend}][${baseMerge}]`,
1324
1329
  `[${baseBlend}][${layerColor}]blend=all_mode=${slice.presentation.blendMode}[${blended}]`,
1325
1330
  `[${baseMerge}][${blended}][${layerMask}]maskedmerge[${next}]`,
1326
1331
  );
@@ -1365,15 +1370,23 @@ export async function buildProjectFfmpegInvocation(
1365
1370
  const x = overlayPositionExpression(overlay, "x", bounds.startUs, visibleFullEndUs, animations.entranceUs, animations.exitUs);
1366
1371
  const y = overlayPositionExpression(overlay, "y", bounds.startUs, visibleFullEndUs, animations.entranceUs, animations.exitUs);
1367
1372
  const next = `canvas_${serial++}`;
1368
- const enable = `between(t,${seconds(resolvedOverlay.outputRange.startUs)},${seconds(chain.visibleEndUs)})`;
1373
+ const staticSource = source.kind === "image" || source.kind === "svg" || source.kind === "emoji";
1374
+ // A still's last decoded PTS can precede the final output frame, especially
1375
+ // after cuts or speed edits. Hold it only inside its half-open interval;
1376
+ // animated sources retain their explicit hide/freeze/loop behavior.
1377
+ const enable = staticSource
1378
+ ? `gte(t,${seconds(resolvedOverlay.outputRange.startUs)})*lt(t,${seconds(chain.visibleEndUs)})`
1379
+ : `between(t,${seconds(resolvedOverlay.outputRange.startUs)},${seconds(chain.visibleEndUs)})`;
1380
+ const terminal = staticSource ? "eof_action=repeat:repeatlast=1" : "eof_action=pass:repeatlast=0";
1369
1381
  const blendMode = overlay.blendMode ?? "normal";
1370
1382
  if (blendMode === "normal") {
1371
1383
  filters.push(
1372
- `[${currentVideo}][${label}]overlay=x='${x}':y='${y}':eof_action=pass:repeatlast=0:enable='${enable}'[${next}]`,
1384
+ `[${currentVideo}][${label}]overlay=x='${x}':y='${y}':${terminal}:enable='${enable}'[${next}]`,
1373
1385
  );
1374
1386
  } else {
1375
1387
  const transparent = `graphic_blend_canvas_${serial++}`;
1376
1388
  const positioned = `graphic_blend_positioned_${serial++}`;
1389
+ const layerColorSource = `graphic_blend_color_source_${serial++}`;
1377
1390
  const layerColor = `graphic_blend_color_${serial++}`;
1378
1391
  const layerAlphaSource = `graphic_blend_alpha_source_${serial++}`;
1379
1392
  const layerMask = `graphic_blend_mask_${serial++}`;
@@ -1382,10 +1395,11 @@ export async function buildProjectFfmpegInvocation(
1382
1395
  const blended = `graphic_blend_result_${serial++}`;
1383
1396
  filters.push(
1384
1397
  `color=c=black@0:s=${plan.output.pixelWidth}x${plan.output.pixelHeight}:r=${emittedRate}:d=${seconds(plan.output.durationUs)},format=rgba[${transparent}]`,
1385
- `[${transparent}][${label}]overlay=x='${x}':y='${y}':eof_action=pass:repeatlast=0:enable='${enable}'[${positioned}]`,
1386
- `[${positioned}]format=rgba,split=2[${layerColor}][${layerAlphaSource}]`,
1398
+ `[${transparent}][${label}]overlay=x='${x}':y='${y}':${terminal}:enable='${enable}':format=auto[${positioned}]`,
1399
+ `[${positioned}]format=rgba,split=2[${layerColorSource}][${layerAlphaSource}]`,
1400
+ `[${layerColorSource}]format=gbrp[${layerColor}]`,
1387
1401
  `[${layerAlphaSource}]alphaextract[${layerMask}]`,
1388
- `[${currentVideo}]split=2[${baseBlend}][${baseMerge}]`,
1402
+ `[${currentVideo}]format=gbrp,split=2[${baseBlend}][${baseMerge}]`,
1389
1403
  `[${baseBlend}][${layerColor}]blend=all_mode=${blendMode}[${blended}]`,
1390
1404
  `[${baseMerge}][${blended}][${layerMask}]maskedmerge[${next}]`,
1391
1405
  );
@@ -337,7 +337,7 @@ export async function executeSpatialSceneCommand(application: ApplicationContext
337
337
  if (command.action === "plan" || command.action === "render") {
338
338
  const request = bindSpatialCliExecutionProfile(await readSpatialJson(resolve(application.paths.repositoryRoot, command.request)), command.executionProfile);
339
339
  if (command.action === "plan") return planSpatialRender(scene, request);
340
- const result = await createApplicationOperationRegistry().execute({ application, abortSignal: new AbortController().signal }, {
340
+ const result = await createApplicationOperationRegistry().execute({ application, abortSignal: signal ?? new AbortController().signal }, {
341
341
  kind: "scene.render", version: 1, input: {
342
342
  source: { path: relative(application.paths.repositoryRoot, sourcePath) }, request,
343
343
  ...(command.assets === undefined ? {} : { assets: await readSpatialJson(resolve(application.paths.repositoryRoot, command.assets)) }),