@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.
- package/README.md +60 -26
- package/apps/desktop/application/directing-contract.ts +15 -3
- package/apps/desktop/application/directing-plan.ts +2 -1
- package/apps/desktop/application/operation.ts +1 -1
- package/apps/desktop/cli/args.ts +62 -1
- package/apps/desktop/cli/capability-manifest.ts +4 -1
- package/apps/desktop/cli/cinema-renderer.ts +9 -6
- package/apps/desktop/cli/command-host-resources.ts +7 -0
- package/apps/desktop/cli/commands.ts +555 -2
- package/apps/desktop/cli/credits.ts +818 -0
- package/apps/desktop/cli/directing-blob.ts +17 -12
- package/apps/desktop/cli/directing-media.ts +14 -4
- package/apps/desktop/cli/directing-quote.ts +29 -11
- package/apps/desktop/cli/directing-service.ts +6 -2
- package/apps/desktop/cli/gateway-media-catalog.ts +57 -0
- package/apps/desktop/cli/gateway-media-service.ts +12 -2
- package/apps/desktop/cli/help.ts +51 -8
- package/apps/desktop/cli/main.ts +1 -1
- package/apps/desktop/cli/project-renderer.ts +23 -9
- package/apps/desktop/cli/spatial-scene-service.ts +1 -1
- package/apps/desktop/dist/cli/main.js +337 -257
- package/apps/desktop/html-overlay/spatial-splat-runtime.ts +6 -0
- package/dist/cli.js +14 -4
- package/dist/index-11cb717y.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 +7 -3
- package/docs/studio.md +63 -3
- package/package.json +13 -7
- package/schema/drawing.schema.json +27 -0
- package/skills/slopcamera/SKILL.md +1 -0
- package/skills/slopcamera/references/directing-video.md +3 -3
- package/skills/slopcamera/references/educational-video.md +1 -1
- package/skills/slopcamera/references/install.md +1 -1
- package/skills/slopcamera/references/patent-drawings.md +53 -0
- package/skills/slopcamera/references/workflows-sdk.md +1 -1
- 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-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.
|
|
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":
|