@hraness/slopcamera 3.3.4 → 3.3.6
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/README.md +14 -4
- package/apps/desktop/application/operation.ts +1 -1
- package/apps/desktop/cli/args.ts +61 -0
- package/apps/desktop/cli/capability-manifest.ts +4 -1
- package/apps/desktop/cli/command-host-resources.ts +7 -0
- package/apps/desktop/cli/commands.ts +550 -2
- package/apps/desktop/cli/credits.ts +818 -0
- package/apps/desktop/cli/help.ts +47 -4
- package/apps/desktop/dist/cli/main.js +327 -253
- package/dist/cli.js +14 -4
- package/dist/index-10bz03js.js +28 -0
- package/dist/{index-296qhpf2.js → index-qtn3s421.js} +1 -1
- package/dist/{index-0bbesn4m.js → index-sy1n4zfy.js} +2 -2
- package/dist/index.js +1 -1
- package/dist/operations.js +1 -1
- package/dist/workflow.js +1 -1
- package/docs/README.md +1 -0
- package/package.json +1 -1
- package/schema/drawing.schema.json +27 -0
- package/skills/slopcamera/SKILL.md +1 -0
- package/skills/slopcamera/references/install.md +1 -1
- package/skills/slopcamera/references/patent-drawings.md +53 -0
- package/src/cli.ts +90 -2
- package/src/drawing-pdf.ts +210 -0
- package/src/drawing.ts +293 -0
- package/src/index.ts +19 -0
- package/src/render.ts +6 -3
- package/src/version.ts +1 -1
- package/dist/index-c8jpst4g.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.
|
|
365
|
+
export const SLOPCAMERA_VERSION = "3.3.6";
|
|
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
|
|
@@ -5328,6 +5335,25 @@ async function handleAiImageGenerate(
|
|
|
5328
5335
|
context: CommandContext,
|
|
5329
5336
|
command: Extract<CliCommand, { readonly kind: "ai-image-generate" }>,
|
|
5330
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
|
+
}
|
|
5331
5357
|
await withGatewayErrors(async () => {
|
|
5332
5358
|
const hasLocalMedia = command.images.length > 0 || command.mask !== undefined;
|
|
5333
5359
|
if (hasLocalMedia && !command.allowCloudUpload) {
|
|
@@ -5459,6 +5485,518 @@ async function handleAiImageGenerate(
|
|
|
5459
5485
|
});
|
|
5460
5486
|
}
|
|
5461
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
|
+
// The issuing poll reports state "consumed" alongside the one-time token,
|
|
5726
|
+
// so the token check must precede state matching.
|
|
5727
|
+
if (status.deviceToken !== undefined) {
|
|
5728
|
+
// The token is returned exactly once; persist before reporting.
|
|
5729
|
+
await store.writeDeviceToken(status.deviceToken);
|
|
5730
|
+
await store.clearPendingClaim();
|
|
5731
|
+
const view = {
|
|
5732
|
+
claimId: pending.claimId,
|
|
5733
|
+
configured: true,
|
|
5734
|
+
state: "paid" as const,
|
|
5735
|
+
next: ["slopcamera credits status", "slopcamera ai image generate --hosted --model <id> --prompt <text>"],
|
|
5736
|
+
};
|
|
5737
|
+
writeValue(context.io, command.json, view, () => [
|
|
5738
|
+
"paid\tcredits wallet is configured on this device",
|
|
5739
|
+
"",
|
|
5740
|
+
"Next:",
|
|
5741
|
+
" slopcamera credits status",
|
|
5742
|
+
" slopcamera ai image generate --hosted --model <id> --prompt <text>",
|
|
5743
|
+
].join("\n"));
|
|
5744
|
+
return;
|
|
5745
|
+
}
|
|
5746
|
+
if (status.state === "paid") {
|
|
5747
|
+
await store.clearPendingClaim();
|
|
5748
|
+
const view = {
|
|
5749
|
+
claimId: pending.claimId,
|
|
5750
|
+
configured: true,
|
|
5751
|
+
state: "paid" as const,
|
|
5752
|
+
walletBound: true,
|
|
5753
|
+
next: ["slopcamera credits status"],
|
|
5754
|
+
};
|
|
5755
|
+
writeValue(context.io, command.json, view, () => [
|
|
5756
|
+
"paid\ttop-up applied to the configured wallet",
|
|
5757
|
+
"",
|
|
5758
|
+
" slopcamera credits status",
|
|
5759
|
+
].join("\n"));
|
|
5760
|
+
return;
|
|
5761
|
+
}
|
|
5762
|
+
if (status.state === "consumed") {
|
|
5763
|
+
await store.clearPendingClaim();
|
|
5764
|
+
const stored = await store.readDeviceToken();
|
|
5765
|
+
if (stored !== undefined) {
|
|
5766
|
+
const view = {
|
|
5767
|
+
claimId: pending.claimId,
|
|
5768
|
+
configured: true,
|
|
5769
|
+
state: "consumed" as const,
|
|
5770
|
+
next: ["slopcamera credits status"],
|
|
5771
|
+
};
|
|
5772
|
+
writeValue(context.io, command.json, view, () =>
|
|
5773
|
+
"paid\tcredits wallet is configured on this device\n\n slopcamera credits status");
|
|
5774
|
+
return;
|
|
5775
|
+
}
|
|
5776
|
+
throw new CliError(
|
|
5777
|
+
"conflict",
|
|
5778
|
+
"The claim was consumed elsewhere and no device token is stored locally. Run `slopcamera credits topup` to start a new top-up.",
|
|
5779
|
+
);
|
|
5780
|
+
}
|
|
5781
|
+
if (status.state === "expired") {
|
|
5782
|
+
await store.clearPendingClaim();
|
|
5783
|
+
throw new CliError(
|
|
5784
|
+
"cancelled",
|
|
5785
|
+
"The checkout expired before payment. Run `slopcamera credits topup` for a fresh link.",
|
|
5786
|
+
);
|
|
5787
|
+
}
|
|
5788
|
+
if (context.clock() >= effectiveDeadline) {
|
|
5789
|
+
const view = {
|
|
5790
|
+
claimId: pending.claimId,
|
|
5791
|
+
configured: false,
|
|
5792
|
+
expiresAt: pending.expiresAt,
|
|
5793
|
+
state: "pending" as const,
|
|
5794
|
+
url: pending.url,
|
|
5795
|
+
next: ["slopcamera credits wait"],
|
|
5796
|
+
};
|
|
5797
|
+
writeValue(context.io, command.json, view, () => [
|
|
5798
|
+
`pending\t${pending.url}`,
|
|
5799
|
+
`claim expires\t${pending.expiresAt}`,
|
|
5800
|
+
"",
|
|
5801
|
+
"Payment is not complete yet. Re-run:",
|
|
5802
|
+
" slopcamera credits wait",
|
|
5803
|
+
].join("\n"));
|
|
5804
|
+
return;
|
|
5805
|
+
}
|
|
5806
|
+
await context.sleep(2_500);
|
|
5807
|
+
}
|
|
5808
|
+
}
|
|
5809
|
+
|
|
5810
|
+
async function handleCreditsForget(
|
|
5811
|
+
context: CommandContext,
|
|
5812
|
+
command: Extract<CliCommand, { readonly kind: "credits-forget" }>,
|
|
5813
|
+
): Promise<void> {
|
|
5814
|
+
const store = new CreditsStore(context.stateRoot);
|
|
5815
|
+
const [tokenRemoved, claimRemoved] = await Promise.all([
|
|
5816
|
+
store.clearDeviceToken(),
|
|
5817
|
+
store.clearPendingClaim(),
|
|
5818
|
+
]);
|
|
5819
|
+
const view = {
|
|
5820
|
+
claimRemoved,
|
|
5821
|
+
configured: false,
|
|
5822
|
+
tokenRemoved,
|
|
5823
|
+
};
|
|
5824
|
+
writeValue(context.io, command.json, view, () => [
|
|
5825
|
+
tokenRemoved
|
|
5826
|
+
? "removed the stored device token"
|
|
5827
|
+
: "no stored device token",
|
|
5828
|
+
claimRemoved
|
|
5829
|
+
? "removed the pending claim"
|
|
5830
|
+
: "no pending claim",
|
|
5831
|
+
...(tokenRemoved
|
|
5832
|
+
? ["", "The wallet still exists; a removed token cannot be re-read. Run `slopcamera credits topup` to configure a new one."]
|
|
5833
|
+
: []),
|
|
5834
|
+
].join("\n"));
|
|
5835
|
+
}
|
|
5836
|
+
|
|
5837
|
+
function hostedImageFlagViolation(
|
|
5838
|
+
command: Extract<CliCommand, { readonly kind: "ai-image-generate" }>,
|
|
5839
|
+
): string | undefined {
|
|
5840
|
+
if (command.images.length > 0) return "--image";
|
|
5841
|
+
if (command.mask !== undefined) return "--mask";
|
|
5842
|
+
if (command.aspectRatio !== undefined) return "--aspect-ratio";
|
|
5843
|
+
if (command.size !== undefined) return "--size";
|
|
5844
|
+
if (command.seed !== undefined) return "--seed";
|
|
5845
|
+
if (command.temperature !== undefined) return "--temperature";
|
|
5846
|
+
if (command.stopSequences.length > 0) return "--stop";
|
|
5847
|
+
if (command.maxOutputTokens !== undefined) return "--max-output-tokens";
|
|
5848
|
+
if (command.providerOptions !== undefined) return "--provider-options";
|
|
5849
|
+
if (command.count !== 1) return "--count";
|
|
5850
|
+
if (command.maxPerCall !== undefined) return "--max-per-call";
|
|
5851
|
+
if (command.allowCloudUpload) return "--allow-cloud-upload";
|
|
5852
|
+
return undefined;
|
|
5853
|
+
}
|
|
5854
|
+
|
|
5855
|
+
async function handleHostedImageGenerate(
|
|
5856
|
+
context: CommandContext,
|
|
5857
|
+
command: Extract<CliCommand, { readonly kind: "ai-image-generate" }>,
|
|
5858
|
+
): Promise<void> {
|
|
5859
|
+
const unsupported = hostedImageFlagViolation(command);
|
|
5860
|
+
if (unsupported !== undefined) {
|
|
5861
|
+
throw new CliError(
|
|
5862
|
+
"usage",
|
|
5863
|
+
`${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.`,
|
|
5864
|
+
);
|
|
5865
|
+
}
|
|
5866
|
+
const prompt = await gatewayCommandText(context, command.prompt, command.promptFile);
|
|
5867
|
+
if (prompt === undefined || prompt.trim() === "") {
|
|
5868
|
+
throw new CliError(
|
|
5869
|
+
"usage",
|
|
5870
|
+
"Hosted image generation requires --prompt or --prompt-file.",
|
|
5871
|
+
);
|
|
5872
|
+
}
|
|
5873
|
+
const store = new CreditsStore(context.stateRoot);
|
|
5874
|
+
const loaded = await loadCreditsToken(context.io.env, store);
|
|
5875
|
+
if (loaded === undefined) {
|
|
5876
|
+
const pending = await store.readPendingClaim();
|
|
5877
|
+
throw new CliError(
|
|
5878
|
+
"authorization-required",
|
|
5879
|
+
pending === undefined
|
|
5880
|
+
? "Hosted generation needs a Slopcamera credits device token. Run `slopcamera credits topup`, then `slopcamera credits wait`."
|
|
5881
|
+
: `Hosted generation needs a paid top-up first: finish payment at ${pending.url} then run \`slopcamera credits wait\`.`,
|
|
5882
|
+
);
|
|
5883
|
+
}
|
|
5884
|
+
const client = creditsClient(context);
|
|
5885
|
+
const idempotencyKey = `cli:${randomUUID()}`;
|
|
5886
|
+
let call;
|
|
5887
|
+
try {
|
|
5888
|
+
call = await client.executeHostedImage({
|
|
5889
|
+
deviceToken: loaded.token,
|
|
5890
|
+
idempotencyKey,
|
|
5891
|
+
model: command.model,
|
|
5892
|
+
prompt,
|
|
5893
|
+
outputName: "output.png",
|
|
5894
|
+
});
|
|
5895
|
+
} catch (error) {
|
|
5896
|
+
throw creditsErrorToCli(error);
|
|
5897
|
+
}
|
|
5898
|
+
if (!call.ok) {
|
|
5899
|
+
throw new CliError(
|
|
5900
|
+
"unavailable",
|
|
5901
|
+
"The hosted generation failed.",
|
|
5902
|
+
);
|
|
5903
|
+
}
|
|
5904
|
+
const artifact = call.artifacts.find(entry => /^image\//u.test(entry.contentType));
|
|
5905
|
+
if (artifact === undefined) {
|
|
5906
|
+
throw new CliError(
|
|
5907
|
+
"invalid-data",
|
|
5908
|
+
"The hosted generation returned no image artifact.",
|
|
5909
|
+
);
|
|
5910
|
+
}
|
|
5911
|
+
const bytes = await client.downloadArtifact(artifact);
|
|
5912
|
+
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
5913
|
+
if (digest !== artifact.sha256) {
|
|
5914
|
+
throw new CliError(
|
|
5915
|
+
"invalid-data",
|
|
5916
|
+
"The hosted artifact bytes did not match the returned sha256.",
|
|
5917
|
+
);
|
|
5918
|
+
}
|
|
5919
|
+
const extension = artifact.contentType === "image/jpeg"
|
|
5920
|
+
? ".jpg"
|
|
5921
|
+
: artifact.contentType === "image/webp"
|
|
5922
|
+
? ".webp"
|
|
5923
|
+
: ".png";
|
|
5924
|
+
const outputs = await resolveGeneratedOutputPaths(
|
|
5925
|
+
context.paths,
|
|
5926
|
+
"image",
|
|
5927
|
+
undefined,
|
|
5928
|
+
extension,
|
|
5929
|
+
);
|
|
5930
|
+
const staged = `${outputs.outputPath}.${randomUUID()}.tmp`;
|
|
5931
|
+
{
|
|
5932
|
+
const handle = await open(
|
|
5933
|
+
staged,
|
|
5934
|
+
constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY
|
|
5935
|
+
| (constants.O_NOFOLLOW ?? 0),
|
|
5936
|
+
0o600,
|
|
5937
|
+
);
|
|
5938
|
+
try {
|
|
5939
|
+
await handle.writeFile(bytes);
|
|
5940
|
+
await handle.sync();
|
|
5941
|
+
} finally {
|
|
5942
|
+
await handle.close();
|
|
5943
|
+
}
|
|
5944
|
+
await rename(staged, outputs.outputPath);
|
|
5945
|
+
}
|
|
5946
|
+
const receipt = {
|
|
5947
|
+
schemaVersion: 1,
|
|
5948
|
+
kind: "slopcamera.hosted-media-receipt",
|
|
5949
|
+
createdAt: context.io.now().toISOString(),
|
|
5950
|
+
provider: "hosted",
|
|
5951
|
+
operation: "slopcamera.image.generate",
|
|
5952
|
+
model: command.model,
|
|
5953
|
+
idempotencyKey,
|
|
5954
|
+
request: {
|
|
5955
|
+
promptCharacters: prompt.length,
|
|
5956
|
+
promptSha256: sha256Hex(prompt),
|
|
5957
|
+
},
|
|
5958
|
+
artifact: {
|
|
5959
|
+
bytes: artifact.bytes,
|
|
5960
|
+
contentType: artifact.contentType,
|
|
5961
|
+
expiresAt: artifact.expiresAt,
|
|
5962
|
+
id: artifact.id,
|
|
5963
|
+
name: artifact.name,
|
|
5964
|
+
sha256: artifact.sha256,
|
|
5965
|
+
url: artifact.url,
|
|
5966
|
+
},
|
|
5967
|
+
outputs: [
|
|
5968
|
+
{
|
|
5969
|
+
bytes: bytes.byteLength,
|
|
5970
|
+
file: basename(outputs.outputPath),
|
|
5971
|
+
mediaType: artifact.contentType,
|
|
5972
|
+
sha256: digest,
|
|
5973
|
+
},
|
|
5974
|
+
],
|
|
5975
|
+
};
|
|
5976
|
+
await publishGeneratedReceipt(context.paths, outputs.receiptPath, receipt);
|
|
5977
|
+
const summary = {
|
|
5978
|
+
artifact: {
|
|
5979
|
+
id: artifact.id,
|
|
5980
|
+
expiresAt: artifact.expiresAt,
|
|
5981
|
+
url: artifact.url,
|
|
5982
|
+
},
|
|
5983
|
+
model: command.model,
|
|
5984
|
+
output: displayPath(context.paths.repositoryRoot, outputs.outputPath),
|
|
5985
|
+
provider: "hosted",
|
|
5986
|
+
receipt: displayPath(context.paths.repositoryRoot, outputs.receiptPath),
|
|
5987
|
+
sha256: digest,
|
|
5988
|
+
bytes: bytes.byteLength,
|
|
5989
|
+
};
|
|
5990
|
+
writeValue(context.io, command.json, summary, () => [
|
|
5991
|
+
`provider\thosted (Hraness Credits)`,
|
|
5992
|
+
`model\t${command.model}`,
|
|
5993
|
+
`output\t${displayPath(context.paths.repositoryRoot, outputs.outputPath)}`,
|
|
5994
|
+
`sha256\t${digest}`,
|
|
5995
|
+
`receipt\t${displayPath(context.paths.repositoryRoot, outputs.receiptPath)}`,
|
|
5996
|
+
`artifact\t${artifact.url} (expires ${artifact.expiresAt})`,
|
|
5997
|
+
].join("\n"));
|
|
5998
|
+
}
|
|
5999
|
+
|
|
5462
6000
|
async function dispatchGalleryCandidate(
|
|
5463
6001
|
context: CommandContext,
|
|
5464
6002
|
command: Extract<CliCommand, { readonly kind: "ai-image-gallery" }>,
|
|
@@ -7468,6 +8006,10 @@ async function dispatch(context: CommandContext, command: CliCommand): Promise<v
|
|
|
7468
8006
|
case "ai-video-generate": await handleAiVideoGenerate(context, command); return;
|
|
7469
8007
|
case "ai-speech-generate": await handleAiSpeechGenerate(context, command); return;
|
|
7470
8008
|
case "ai-transcribe": await handleAiTranscribe(context, command); return;
|
|
8009
|
+
case "credits-status": await handleCreditsStatus(context, command); return;
|
|
8010
|
+
case "credits-topup": await handleCreditsTopup(context, command); return;
|
|
8011
|
+
case "credits-wait": await handleCreditsWait(context, command); return;
|
|
8012
|
+
case "credits-forget": await handleCreditsForget(context, command); return;
|
|
7471
8013
|
case "media-audio": await handleMediaAudio(context, command); return;
|
|
7472
8014
|
case "media-color": await handleMediaColor(context, command); return;
|
|
7473
8015
|
case "menubar": {
|
|
@@ -7767,6 +8309,12 @@ function commandMutationReference(command: CliCommand): MutationReference | unde
|
|
|
7767
8309
|
case "ai-video-generate":
|
|
7768
8310
|
case "ai-speech-generate":
|
|
7769
8311
|
case "ai-transcribe": return undefined;
|
|
8312
|
+
// Credits commands mutate only the private per-machine state root, which
|
|
8313
|
+
// owns its own file-level writes; no bundle lease applies.
|
|
8314
|
+
case "credits-status":
|
|
8315
|
+
case "credits-topup":
|
|
8316
|
+
case "credits-wait":
|
|
8317
|
+
case "credits-forget": return undefined;
|
|
7770
8318
|
case "help":
|
|
7771
8319
|
case "version":
|
|
7772
8320
|
case "capabilities":
|