@hraness/slopcamera 3.3.3 → 3.3.5

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 (46) hide show
  1. package/README.md +60 -26
  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 +62 -1
  6. package/apps/desktop/cli/capability-manifest.ts +4 -1
  7. package/apps/desktop/cli/cinema-renderer.ts +9 -6
  8. package/apps/desktop/cli/command-host-resources.ts +7 -0
  9. package/apps/desktop/cli/commands.ts +555 -2
  10. package/apps/desktop/cli/credits.ts +818 -0
  11. package/apps/desktop/cli/directing-blob.ts +17 -12
  12. package/apps/desktop/cli/directing-media.ts +14 -4
  13. package/apps/desktop/cli/directing-quote.ts +29 -11
  14. package/apps/desktop/cli/directing-service.ts +6 -2
  15. package/apps/desktop/cli/gateway-media-catalog.ts +57 -0
  16. package/apps/desktop/cli/gateway-media-service.ts +12 -2
  17. package/apps/desktop/cli/help.ts +51 -8
  18. package/apps/desktop/cli/main.ts +1 -1
  19. package/apps/desktop/cli/project-renderer.ts +23 -9
  20. package/apps/desktop/cli/spatial-scene-service.ts +1 -1
  21. package/apps/desktop/dist/cli/main.js +337 -257
  22. package/apps/desktop/html-overlay/spatial-splat-runtime.ts +6 -0
  23. package/dist/cli.js +14 -4
  24. package/dist/index-11cb717y.js +28 -0
  25. package/dist/{index-296qhpf2.js → index-qtn3s421.js} +1 -1
  26. package/dist/{index-0bbesn4m.js → index-sy1n4zfy.js} +2 -2
  27. package/dist/index.js +1 -1
  28. package/dist/operations.js +1 -1
  29. package/dist/workflow.js +1 -1
  30. package/docs/README.md +7 -3
  31. package/docs/studio.md +63 -3
  32. package/package.json +13 -7
  33. package/schema/drawing.schema.json +27 -0
  34. package/skills/slopcamera/SKILL.md +1 -0
  35. package/skills/slopcamera/references/directing-video.md +3 -3
  36. package/skills/slopcamera/references/educational-video.md +1 -1
  37. package/skills/slopcamera/references/install.md +1 -1
  38. package/skills/slopcamera/references/patent-drawings.md +53 -0
  39. package/skills/slopcamera/references/workflows-sdk.md +1 -1
  40. package/src/cli.ts +90 -2
  41. package/src/drawing-pdf.ts +210 -0
  42. package/src/drawing.ts +293 -0
  43. package/src/index.ts +19 -0
  44. package/src/render.ts +6 -3
  45. package/src/version.ts +1 -1
  46. package/dist/index-3e1pcxb5.js +0 -7
@@ -43,7 +43,7 @@ import {
43
43
  import { executeSpatialProjectCommand } from "./spatial-project-service";
44
44
  import { executeSpatialWorldCommand } from "./spatial-world-service";
45
45
  import { constants } from "node:fs";
46
- import { link, lstat, mkdir, open, realpath, rm } from "node:fs/promises";
46
+ import { link, lstat, mkdir, open, realpath, rename, rm } from "node:fs/promises";
47
47
  import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
48
48
  import {
49
49
  createDefaultHostResourceCoordinator,
@@ -264,6 +264,13 @@ import {
264
264
  inspectGatewayCredential,
265
265
  loadGatewayCredential,
266
266
  } from "./gateway-credential";
267
+ import {
268
+ CreditsClient,
269
+ CreditsStore,
270
+ creditsErrorToCli,
271
+ loadCreditsToken,
272
+ type CreditsBalance,
273
+ } from "./credits";
267
274
  import {
268
275
  createGatewaySceneProvider,
269
276
  type GatewaySceneProviderOptions,
@@ -355,7 +362,7 @@ import {
355
362
  workflowRunStore,
356
363
  } from "./workflow-runs";
357
364
 
358
- export const SLOPCAMERA_VERSION = "3.3.3";
365
+ export const SLOPCAMERA_VERSION = "3.3.5";
359
366
 
360
367
  // Legacy direct renders predate per-target output contracts. Keep them
361
368
  // bounded generously enough for long-form production while preventing one
@@ -3897,6 +3904,11 @@ async function inspectStagedGatewayMediaFile(
3897
3904
  ffprobe: string,
3898
3905
  signal?: AbortSignal,
3899
3906
  ): Promise<LoadedGatewayPhysicalFile> {
3907
+ // On-disk JPEG files probe through ffmpeg's image2 sequence demuxer, which the
3908
+ // whitelist deliberately omits; pin the single-image pipe demuxer instead.
3909
+ const forcedInputFormat = file.mediaType === "image/jpeg"
3910
+ ? ["-f", "jpeg_pipe"]
3911
+ : [];
3900
3912
  const probe = await context.runner.run([
3901
3913
  ffprobe,
3902
3914
  "-hide_banner",
@@ -3906,6 +3918,7 @@ async function inspectStagedGatewayMediaFile(
3906
3918
  "-show_entries",
3907
3919
  "format=duration:stream=codec_type,width,height,duration,channels,sample_rate,avg_frame_rate,r_frame_rate",
3908
3920
  "-of", "json",
3921
+ ...forcedInputFormat,
3909
3922
  inspectionPath,
3910
3923
  ], {
3911
3924
  ...(signal === undefined ? {} : { abortSignal: signal }),
@@ -4078,6 +4091,7 @@ async function inspectStagedGatewayMediaFile(
4078
4091
  "-xerror",
4079
4092
  ...SELF_CONTAINED_MEDIA_INPUT_ARGUMENTS,
4080
4093
  "-threads", "1",
4094
+ ...forcedInputFormat,
4081
4095
  "-i", inspectionPath,
4082
4096
  "-map", "0:V?",
4083
4097
  "-map", "0:a?",
@@ -5321,6 +5335,25 @@ async function handleAiImageGenerate(
5321
5335
  context: CommandContext,
5322
5336
  command: Extract<CliCommand, { readonly kind: "ai-image-generate" }>,
5323
5337
  ): Promise<void> {
5338
+ const gatewayConfigured = inspectGatewayCredential(context.io.env).configured;
5339
+ if (command.hosted) {
5340
+ await handleHostedImageGenerate(context, command);
5341
+ return;
5342
+ }
5343
+ if (!gatewayConfigured) {
5344
+ const creditsToken = await loadCreditsToken(
5345
+ context.io.env,
5346
+ new CreditsStore(context.stateRoot),
5347
+ );
5348
+ if (creditsToken !== undefined) {
5349
+ await handleHostedImageGenerate(context, command);
5350
+ return;
5351
+ }
5352
+ throw new CliError(
5353
+ "authorization-required",
5354
+ "Image generation needs a credential: set AI_GATEWAY_API_KEY (or `vercel env run -- …` for VERCEL_OIDC_TOKEN), or run `slopcamera credits topup` then `slopcamera credits wait` to use prepaid hosted generation.",
5355
+ );
5356
+ }
5324
5357
  await withGatewayErrors(async () => {
5325
5358
  const hasLocalMedia = command.images.length > 0 || command.mask !== undefined;
5326
5359
  if (hasLocalMedia && !command.allowCloudUpload) {
@@ -5452,6 +5485,516 @@ async function handleAiImageGenerate(
5452
5485
  });
5453
5486
  }
5454
5487
 
5488
+ function creditsClient(context: CommandContext): CreditsClient {
5489
+ return new CreditsClient({ fetch: context.fetch });
5490
+ }
5491
+
5492
+ function packSummaries(packs: readonly { readonly id: string; readonly usd: number; readonly credits: number; readonly bonusCredits: number; readonly label: string }[]) {
5493
+ return packs.map(pack => ({
5494
+ bonusCredits: pack.bonusCredits,
5495
+ credits: pack.credits,
5496
+ id: pack.id,
5497
+ label: pack.label,
5498
+ usd: pack.usd,
5499
+ }));
5500
+ }
5501
+
5502
+ async function handleCreditsStatus(
5503
+ context: CommandContext,
5504
+ command: Extract<CliCommand, { readonly kind: "credits-status" }>,
5505
+ ): Promise<void> {
5506
+ const store = new CreditsStore(context.stateRoot);
5507
+ const pending = await store.readPendingClaim();
5508
+ const loaded = await loadCreditsToken(context.io.env, store);
5509
+ if (loaded === undefined) {
5510
+ const view = {
5511
+ configured: false,
5512
+ ...(pending === undefined
5513
+ ? {}
5514
+ : {
5515
+ pendingClaim: {
5516
+ claimId: pending.claimId,
5517
+ expiresAt: pending.expiresAt,
5518
+ url: pending.url,
5519
+ },
5520
+ }),
5521
+ next: pending === undefined
5522
+ ? ["slopcamera credits topup [--pack <id>]"]
5523
+ : ["slopcamera credits wait"],
5524
+ };
5525
+ writeValue(context.io, command.json, view, () => [
5526
+ "credits\tnot configured",
5527
+ ...(pending === undefined
5528
+ ? ["", "Top up hosted credits:", " slopcamera credits topup [--pack <id>]"]
5529
+ : [
5530
+ "",
5531
+ `pending claim\t${pending.url}`,
5532
+ "Finish payment, then run:",
5533
+ " slopcamera credits wait",
5534
+ ]),
5535
+ ].join("\n"));
5536
+ return;
5537
+ }
5538
+ let balance: CreditsBalance;
5539
+ try {
5540
+ balance = await creditsClient(context).balance(loaded.token);
5541
+ } catch (error) {
5542
+ throw creditsErrorToCli(error);
5543
+ }
5544
+ const view = {
5545
+ configured: true,
5546
+ tokenSource: loaded.source,
5547
+ product: balance.product,
5548
+ balance: balance.balance,
5549
+ heldMicroUsd: balance.heldMicroUsd,
5550
+ lowBalance: balance.lowBalance,
5551
+ ...(balance.topup === null ? {} : { topup: {
5552
+ url: balance.topup.url,
5553
+ packs: packSummaries(balance.topup.packs),
5554
+ suggestedPackId: balance.topup.suggestedPackId,
5555
+ } }),
5556
+ ...(pending === undefined
5557
+ ? {}
5558
+ : {
5559
+ pendingClaim: {
5560
+ claimId: pending.claimId,
5561
+ expiresAt: pending.expiresAt,
5562
+ url: pending.url,
5563
+ },
5564
+ }),
5565
+ };
5566
+ writeValue(context.io, command.json, view, () => [
5567
+ `product\t${balance.product.name} (${balance.product.id})`,
5568
+ `balance\t${balance.balance.usd} USD (${balance.balance.credits} credits)`,
5569
+ `held\t$${(balance.heldMicroUsd / 1_000_000).toFixed(2)}`,
5570
+ `lowBalance\t${balance.lowBalance}`,
5571
+ `token\t${loaded.source === "env" ? "environment" : "local state"}`,
5572
+ ...(balance.topup === null || !balance.lowBalance
5573
+ ? []
5574
+ : ["", `Top up: ${balance.topup.url}`, " slopcamera credits topup"]),
5575
+ ].join("\n"));
5576
+ }
5577
+
5578
+ async function handleCreditsTopup(
5579
+ context: CommandContext,
5580
+ command: Extract<CliCommand, { readonly kind: "credits-topup" }>,
5581
+ ): Promise<void> {
5582
+ const store = new CreditsStore(context.stateRoot);
5583
+ const client = creditsClient(context);
5584
+ const loaded = await loadCreditsToken(context.io.env, store);
5585
+
5586
+ if (loaded !== undefined) {
5587
+ let balance: CreditsBalance;
5588
+ try {
5589
+ balance = await client.balance(loaded.token);
5590
+ } catch (error) {
5591
+ throw creditsErrorToCli(error);
5592
+ }
5593
+ const walletTopup = balance.topup;
5594
+ if (walletTopup !== null) {
5595
+ const view = {
5596
+ balance: balance.balance,
5597
+ packs: packSummaries(walletTopup.packs),
5598
+ suggestedPackId: walletTopup.suggestedPackId,
5599
+ url: walletTopup.url,
5600
+ walletBound: true,
5601
+ next: ["slopcamera credits status"],
5602
+ };
5603
+ writeValue(context.io, command.json, view, () => [
5604
+ `pay\t${walletTopup.url}`,
5605
+ `balance\t${balance.balance.usd} USD (${balance.balance.credits} credits)`,
5606
+ "",
5607
+ "Open the URL, pay, then check:",
5608
+ " slopcamera credits status",
5609
+ ].join("\n"));
5610
+ return;
5611
+ }
5612
+ }
5613
+
5614
+ // A configured wallet without a bound top-up URL cannot be credited by a new
5615
+ // or pending unbound claim: paying it would issue a different wallet's token
5616
+ // on `wait` and replace the stored one. Refuse instead of switching wallets.
5617
+ if (loaded !== undefined) {
5618
+ throw new CliError(
5619
+ "unavailable",
5620
+ "The configured wallet does not expose a bound top-up right now. Run `slopcamera credits forget` to abandon this wallet, or retry `slopcamera credits topup` later.",
5621
+ );
5622
+ }
5623
+
5624
+ const now = context.clock();
5625
+ const pending = await store.readPendingClaim();
5626
+ if (
5627
+ pending !== undefined
5628
+ && Date.parse(pending.expiresAt) > now + 60_000
5629
+ ) {
5630
+ const view = {
5631
+ claimId: pending.claimId,
5632
+ expiresAt: pending.expiresAt,
5633
+ packs: packSummaries(pending.packs),
5634
+ suggestedPackId: pending.suggestedPackId,
5635
+ url: pending.url,
5636
+ walletBound: false,
5637
+ reused: true,
5638
+ next: ["slopcamera credits wait"],
5639
+ };
5640
+ writeValue(context.io, command.json, view, () => [
5641
+ `pay\t${pending.url}`,
5642
+ `claim\t${pending.claimId} (existing, expires ${pending.expiresAt})`,
5643
+ "",
5644
+ "Open the URL to pay, then run:",
5645
+ " slopcamera credits wait",
5646
+ ].join("\n"));
5647
+ return;
5648
+ }
5649
+
5650
+ const identity = await store.deviceIdentity();
5651
+ let claim;
5652
+ try {
5653
+ claim = await client.createClaim({
5654
+ deviceId: identity.deviceId,
5655
+ deviceLabel: identity.label,
5656
+ ...(command.email === undefined ? {} : { email: command.email }),
5657
+ ...(command.pack === undefined ? {} : { packId: command.pack }),
5658
+ resumeArgv: ["slopcamera", "credits", "wait"],
5659
+ });
5660
+ } catch (error) {
5661
+ throw creditsErrorToCli(error);
5662
+ }
5663
+ await store.writePendingClaim({
5664
+ claimId: claim.claimId,
5665
+ claimSecret: claim.claimSecret,
5666
+ url: claim.url,
5667
+ expiresAt: claim.expiresAt,
5668
+ packs: claim.packs,
5669
+ suggestedPackId: claim.suggestedPackId,
5670
+ createdAt: new Date(now).toISOString(),
5671
+ });
5672
+ const view = {
5673
+ claimId: claim.claimId,
5674
+ expiresAt: claim.expiresAt,
5675
+ packs: packSummaries(claim.packs),
5676
+ suggestedPackId: claim.suggestedPackId,
5677
+ url: claim.url,
5678
+ walletBound: false,
5679
+ reused: false,
5680
+ next: ["slopcamera credits wait"],
5681
+ };
5682
+ writeValue(context.io, command.json, view, () => [
5683
+ `pay\t${claim.url}`,
5684
+ `claim\t${claim.claimId} (expires ${claim.expiresAt})`,
5685
+ ...(claim.packs.length === 0
5686
+ ? []
5687
+ : [
5688
+ "",
5689
+ "packs:",
5690
+ ...claim.packs.map(pack => ` ${pack.id}\t${pack.label}`),
5691
+ ]),
5692
+ "",
5693
+ "Open the URL to pay, then run:",
5694
+ " slopcamera credits wait",
5695
+ ].join("\n"));
5696
+ }
5697
+
5698
+ async function handleCreditsWait(
5699
+ context: CommandContext,
5700
+ command: Extract<CliCommand, { readonly kind: "credits-wait" }>,
5701
+ ): Promise<void> {
5702
+ const store = new CreditsStore(context.stateRoot);
5703
+ const client = creditsClient(context);
5704
+ const pending = await store.readPendingClaim();
5705
+ if (pending === undefined) {
5706
+ throw new CliError(
5707
+ "usage",
5708
+ "No pending credits top-up. Run `slopcamera credits topup` first.",
5709
+ );
5710
+ }
5711
+ const bearer = pending.claimSecret;
5712
+ const deadline = context.clock() + gatewayTimeoutMs(command.timeout);
5713
+ const claimExpiry = Date.parse(pending.expiresAt);
5714
+ const effectiveDeadline = Number.isFinite(claimExpiry)
5715
+ ? Math.min(deadline, claimExpiry + 5 * 60_000)
5716
+ : deadline;
5717
+
5718
+ for (;;) {
5719
+ let status;
5720
+ try {
5721
+ status = await client.claimStatus(pending.claimId, bearer);
5722
+ } catch (error) {
5723
+ throw creditsErrorToCli(error);
5724
+ }
5725
+ if (status.state === "paid") {
5726
+ if (status.deviceToken !== undefined) {
5727
+ // The token is returned exactly once; persist before reporting.
5728
+ await store.writeDeviceToken(status.deviceToken);
5729
+ await store.clearPendingClaim();
5730
+ const view = {
5731
+ claimId: pending.claimId,
5732
+ configured: true,
5733
+ state: "paid" as const,
5734
+ next: ["slopcamera credits status", "slopcamera ai image generate --hosted --model <id> --prompt <text>"],
5735
+ };
5736
+ writeValue(context.io, command.json, view, () => [
5737
+ "paid\tcredits wallet is configured on this device",
5738
+ "",
5739
+ "Next:",
5740
+ " slopcamera credits status",
5741
+ " slopcamera ai image generate --hosted --model <id> --prompt <text>",
5742
+ ].join("\n"));
5743
+ return;
5744
+ }
5745
+ await store.clearPendingClaim();
5746
+ const view = {
5747
+ claimId: pending.claimId,
5748
+ configured: true,
5749
+ state: "paid" as const,
5750
+ walletBound: true,
5751
+ next: ["slopcamera credits status"],
5752
+ };
5753
+ writeValue(context.io, command.json, view, () => [
5754
+ "paid\ttop-up applied to the configured wallet",
5755
+ "",
5756
+ " slopcamera credits status",
5757
+ ].join("\n"));
5758
+ return;
5759
+ }
5760
+ if (status.state === "consumed") {
5761
+ await store.clearPendingClaim();
5762
+ const stored = await store.readDeviceToken();
5763
+ if (stored !== undefined) {
5764
+ const view = {
5765
+ claimId: pending.claimId,
5766
+ configured: true,
5767
+ state: "consumed" as const,
5768
+ next: ["slopcamera credits status"],
5769
+ };
5770
+ writeValue(context.io, command.json, view, () =>
5771
+ "paid\tcredits wallet is configured on this device\n\n slopcamera credits status");
5772
+ return;
5773
+ }
5774
+ throw new CliError(
5775
+ "conflict",
5776
+ "The claim was consumed elsewhere and no device token is stored locally. Run `slopcamera credits topup` to start a new top-up.",
5777
+ );
5778
+ }
5779
+ if (status.state === "expired") {
5780
+ await store.clearPendingClaim();
5781
+ throw new CliError(
5782
+ "cancelled",
5783
+ "The checkout expired before payment. Run `slopcamera credits topup` for a fresh link.",
5784
+ );
5785
+ }
5786
+ if (context.clock() >= effectiveDeadline) {
5787
+ const view = {
5788
+ claimId: pending.claimId,
5789
+ configured: false,
5790
+ expiresAt: pending.expiresAt,
5791
+ state: "pending" as const,
5792
+ url: pending.url,
5793
+ next: ["slopcamera credits wait"],
5794
+ };
5795
+ writeValue(context.io, command.json, view, () => [
5796
+ `pending\t${pending.url}`,
5797
+ `claim expires\t${pending.expiresAt}`,
5798
+ "",
5799
+ "Payment is not complete yet. Re-run:",
5800
+ " slopcamera credits wait",
5801
+ ].join("\n"));
5802
+ return;
5803
+ }
5804
+ await context.sleep(2_500);
5805
+ }
5806
+ }
5807
+
5808
+ async function handleCreditsForget(
5809
+ context: CommandContext,
5810
+ command: Extract<CliCommand, { readonly kind: "credits-forget" }>,
5811
+ ): Promise<void> {
5812
+ const store = new CreditsStore(context.stateRoot);
5813
+ const [tokenRemoved, claimRemoved] = await Promise.all([
5814
+ store.clearDeviceToken(),
5815
+ store.clearPendingClaim(),
5816
+ ]);
5817
+ const view = {
5818
+ claimRemoved,
5819
+ configured: false,
5820
+ tokenRemoved,
5821
+ };
5822
+ writeValue(context.io, command.json, view, () => [
5823
+ tokenRemoved
5824
+ ? "removed the stored device token"
5825
+ : "no stored device token",
5826
+ claimRemoved
5827
+ ? "removed the pending claim"
5828
+ : "no pending claim",
5829
+ ...(tokenRemoved
5830
+ ? ["", "The wallet still exists; a removed token cannot be re-read. Run `slopcamera credits topup` to configure a new one."]
5831
+ : []),
5832
+ ].join("\n"));
5833
+ }
5834
+
5835
+ function hostedImageFlagViolation(
5836
+ command: Extract<CliCommand, { readonly kind: "ai-image-generate" }>,
5837
+ ): string | undefined {
5838
+ if (command.images.length > 0) return "--image";
5839
+ if (command.mask !== undefined) return "--mask";
5840
+ if (command.aspectRatio !== undefined) return "--aspect-ratio";
5841
+ if (command.size !== undefined) return "--size";
5842
+ if (command.seed !== undefined) return "--seed";
5843
+ if (command.temperature !== undefined) return "--temperature";
5844
+ if (command.stopSequences.length > 0) return "--stop";
5845
+ if (command.maxOutputTokens !== undefined) return "--max-output-tokens";
5846
+ if (command.providerOptions !== undefined) return "--provider-options";
5847
+ if (command.count !== 1) return "--count";
5848
+ if (command.maxPerCall !== undefined) return "--max-per-call";
5849
+ if (command.allowCloudUpload) return "--allow-cloud-upload";
5850
+ return undefined;
5851
+ }
5852
+
5853
+ async function handleHostedImageGenerate(
5854
+ context: CommandContext,
5855
+ command: Extract<CliCommand, { readonly kind: "ai-image-generate" }>,
5856
+ ): Promise<void> {
5857
+ const unsupported = hostedImageFlagViolation(command);
5858
+ if (unsupported !== undefined) {
5859
+ throw new CliError(
5860
+ "usage",
5861
+ `${unsupported} is not supported by hosted generation; hosted image generation admits a prompt and model only. Drop ${unsupported} or run without --hosted against your own Gateway credential.`,
5862
+ );
5863
+ }
5864
+ const prompt = await gatewayCommandText(context, command.prompt, command.promptFile);
5865
+ if (prompt === undefined || prompt.trim() === "") {
5866
+ throw new CliError(
5867
+ "usage",
5868
+ "Hosted image generation requires --prompt or --prompt-file.",
5869
+ );
5870
+ }
5871
+ const store = new CreditsStore(context.stateRoot);
5872
+ const loaded = await loadCreditsToken(context.io.env, store);
5873
+ if (loaded === undefined) {
5874
+ const pending = await store.readPendingClaim();
5875
+ throw new CliError(
5876
+ "authorization-required",
5877
+ pending === undefined
5878
+ ? "Hosted generation needs a Slopcamera credits device token. Run `slopcamera credits topup`, then `slopcamera credits wait`."
5879
+ : `Hosted generation needs a paid top-up first: finish payment at ${pending.url} then run \`slopcamera credits wait\`.`,
5880
+ );
5881
+ }
5882
+ const client = creditsClient(context);
5883
+ const idempotencyKey = `cli:${randomUUID()}`;
5884
+ let call;
5885
+ try {
5886
+ call = await client.executeHostedImage({
5887
+ deviceToken: loaded.token,
5888
+ idempotencyKey,
5889
+ model: command.model,
5890
+ prompt,
5891
+ outputName: "output.png",
5892
+ });
5893
+ } catch (error) {
5894
+ throw creditsErrorToCli(error);
5895
+ }
5896
+ if (!call.ok) {
5897
+ throw new CliError(
5898
+ "unavailable",
5899
+ "The hosted generation failed.",
5900
+ );
5901
+ }
5902
+ const artifact = call.artifacts.find(entry => /^image\//u.test(entry.contentType));
5903
+ if (artifact === undefined) {
5904
+ throw new CliError(
5905
+ "invalid-data",
5906
+ "The hosted generation returned no image artifact.",
5907
+ );
5908
+ }
5909
+ const bytes = await client.downloadArtifact(artifact);
5910
+ const digest = createHash("sha256").update(bytes).digest("hex");
5911
+ if (digest !== artifact.sha256) {
5912
+ throw new CliError(
5913
+ "invalid-data",
5914
+ "The hosted artifact bytes did not match the returned sha256.",
5915
+ );
5916
+ }
5917
+ const extension = artifact.contentType === "image/jpeg"
5918
+ ? ".jpg"
5919
+ : artifact.contentType === "image/webp"
5920
+ ? ".webp"
5921
+ : ".png";
5922
+ const outputs = await resolveGeneratedOutputPaths(
5923
+ context.paths,
5924
+ "image",
5925
+ undefined,
5926
+ extension,
5927
+ );
5928
+ const staged = `${outputs.outputPath}.${randomUUID()}.tmp`;
5929
+ {
5930
+ const handle = await open(
5931
+ staged,
5932
+ constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY
5933
+ | (constants.O_NOFOLLOW ?? 0),
5934
+ 0o600,
5935
+ );
5936
+ try {
5937
+ await handle.writeFile(bytes);
5938
+ await handle.sync();
5939
+ } finally {
5940
+ await handle.close();
5941
+ }
5942
+ await rename(staged, outputs.outputPath);
5943
+ }
5944
+ const receipt = {
5945
+ schemaVersion: 1,
5946
+ kind: "slopcamera.hosted-media-receipt",
5947
+ createdAt: context.io.now().toISOString(),
5948
+ provider: "hosted",
5949
+ operation: "slopcamera.image.generate",
5950
+ model: command.model,
5951
+ idempotencyKey,
5952
+ request: {
5953
+ promptCharacters: prompt.length,
5954
+ promptSha256: sha256Hex(prompt),
5955
+ },
5956
+ artifact: {
5957
+ bytes: artifact.bytes,
5958
+ contentType: artifact.contentType,
5959
+ expiresAt: artifact.expiresAt,
5960
+ id: artifact.id,
5961
+ name: artifact.name,
5962
+ sha256: artifact.sha256,
5963
+ url: artifact.url,
5964
+ },
5965
+ outputs: [
5966
+ {
5967
+ bytes: bytes.byteLength,
5968
+ file: basename(outputs.outputPath),
5969
+ mediaType: artifact.contentType,
5970
+ sha256: digest,
5971
+ },
5972
+ ],
5973
+ };
5974
+ await publishGeneratedReceipt(context.paths, outputs.receiptPath, receipt);
5975
+ const summary = {
5976
+ artifact: {
5977
+ id: artifact.id,
5978
+ expiresAt: artifact.expiresAt,
5979
+ url: artifact.url,
5980
+ },
5981
+ model: command.model,
5982
+ output: displayPath(context.paths.repositoryRoot, outputs.outputPath),
5983
+ provider: "hosted",
5984
+ receipt: displayPath(context.paths.repositoryRoot, outputs.receiptPath),
5985
+ sha256: digest,
5986
+ bytes: bytes.byteLength,
5987
+ };
5988
+ writeValue(context.io, command.json, summary, () => [
5989
+ `provider\thosted (Hraness Credits)`,
5990
+ `model\t${command.model}`,
5991
+ `output\t${displayPath(context.paths.repositoryRoot, outputs.outputPath)}`,
5992
+ `sha256\t${digest}`,
5993
+ `receipt\t${displayPath(context.paths.repositoryRoot, outputs.receiptPath)}`,
5994
+ `artifact\t${artifact.url} (expires ${artifact.expiresAt})`,
5995
+ ].join("\n"));
5996
+ }
5997
+
5455
5998
  async function dispatchGalleryCandidate(
5456
5999
  context: CommandContext,
5457
6000
  command: Extract<CliCommand, { readonly kind: "ai-image-gallery" }>,
@@ -7461,6 +8004,10 @@ async function dispatch(context: CommandContext, command: CliCommand): Promise<v
7461
8004
  case "ai-video-generate": await handleAiVideoGenerate(context, command); return;
7462
8005
  case "ai-speech-generate": await handleAiSpeechGenerate(context, command); return;
7463
8006
  case "ai-transcribe": await handleAiTranscribe(context, command); return;
8007
+ case "credits-status": await handleCreditsStatus(context, command); return;
8008
+ case "credits-topup": await handleCreditsTopup(context, command); return;
8009
+ case "credits-wait": await handleCreditsWait(context, command); return;
8010
+ case "credits-forget": await handleCreditsForget(context, command); return;
7464
8011
  case "media-audio": await handleMediaAudio(context, command); return;
7465
8012
  case "media-color": await handleMediaColor(context, command); return;
7466
8013
  case "menubar": {
@@ -7760,6 +8307,12 @@ function commandMutationReference(command: CliCommand): MutationReference | unde
7760
8307
  case "ai-video-generate":
7761
8308
  case "ai-speech-generate":
7762
8309
  case "ai-transcribe": return undefined;
8310
+ // Credits commands mutate only the private per-machine state root, which
8311
+ // owns its own file-level writes; no bundle lease applies.
8312
+ case "credits-status":
8313
+ case "credits-topup":
8314
+ case "credits-wait":
8315
+ case "credits-forget": return undefined;
7763
8316
  case "help":
7764
8317
  case "version":
7765
8318
  case "capabilities":