@hubfluencer/mcp 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -15
- package/dist/index.js +723 -203
- package/package.json +1 -1
- package/src/campaign.ts +1 -1
- package/src/client.ts +4 -1
- package/src/core.ts +329 -16
- package/src/index.ts +819 -123
- package/src/output-schemas.ts +21 -2
- package/src/uploads.ts +401 -163
package/dist/index.js
CHANGED
|
@@ -29488,16 +29488,23 @@ function normalizeStatus(kind, slug, data) {
|
|
|
29488
29488
|
const latest = asRecord(d.latest_render);
|
|
29489
29489
|
const videoUrl = latest.video_url ?? null;
|
|
29490
29490
|
if (kind === "short") {
|
|
29491
|
-
const
|
|
29492
|
-
|
|
29491
|
+
const stage2 = d.stage ?? "unknown";
|
|
29492
|
+
const status = {
|
|
29493
29493
|
kind,
|
|
29494
29494
|
slug,
|
|
29495
|
-
stage,
|
|
29496
|
-
terminal:
|
|
29497
|
-
ready:
|
|
29495
|
+
stage: stage2,
|
|
29496
|
+
terminal: stage2 === "video_ready" || stage2 === "failed",
|
|
29497
|
+
ready: stage2 === "video_ready",
|
|
29498
29498
|
video_url: videoUrl,
|
|
29499
29499
|
error: d.error_message ?? null
|
|
29500
29500
|
};
|
|
29501
|
+
if (typeof d.latest_render_free === "boolean") {
|
|
29502
|
+
status.latest_render_free = d.latest_render_free;
|
|
29503
|
+
}
|
|
29504
|
+
if (typeof d.last_free_rerender_failed === "boolean") {
|
|
29505
|
+
status.last_free_rerender_failed = d.last_free_rerender_failed;
|
|
29506
|
+
}
|
|
29507
|
+
return status;
|
|
29501
29508
|
}
|
|
29502
29509
|
if (kind === "tracking") {
|
|
29503
29510
|
const result = asRecord(d.result);
|
|
@@ -29515,29 +29522,127 @@ function normalizeStatus(kind, slug, data) {
|
|
|
29515
29522
|
};
|
|
29516
29523
|
}
|
|
29517
29524
|
if (kind === "slider") {
|
|
29518
|
-
const
|
|
29525
|
+
const stage2 = d.status ?? "unknown";
|
|
29519
29526
|
return {
|
|
29520
29527
|
kind,
|
|
29521
29528
|
slug,
|
|
29522
|
-
stage,
|
|
29523
|
-
terminal:
|
|
29524
|
-
ready:
|
|
29529
|
+
stage: stage2,
|
|
29530
|
+
terminal: stage2 === "completed" || stage2 === "failed",
|
|
29531
|
+
ready: stage2 === "completed",
|
|
29525
29532
|
video_url: null,
|
|
29526
29533
|
error: d.error_message ?? null
|
|
29527
29534
|
};
|
|
29528
29535
|
}
|
|
29529
29536
|
const autopilot = d.autopilot_status ?? "unknown";
|
|
29530
29537
|
const renderStatus = latest.status ?? null;
|
|
29531
|
-
const
|
|
29532
|
-
const
|
|
29538
|
+
const segments = Array.isArray(d.segments) ? d.segments.map(asRecord) : [];
|
|
29539
|
+
const activeSegments = segments.filter((segment) => {
|
|
29540
|
+
const status = segment.status;
|
|
29541
|
+
const preview = segment.preview_status;
|
|
29542
|
+
return status === "processing" || preview === "processing" || preview === "generating";
|
|
29543
|
+
});
|
|
29544
|
+
const staleSegments = segments.filter((segment) => segment.video_stale === true && segment.status === "completed");
|
|
29545
|
+
const failedSegments = segments.filter((segment) => segment.status === "failed");
|
|
29546
|
+
const batchStatus = d.batch_generation_status ?? null;
|
|
29547
|
+
const batchActive = batchStatus === "running" || batchStatus === "preparing" || batchStatus === "awaiting_previews" || batchStatus === "paused_for_retry";
|
|
29548
|
+
const batchInsufficientCredits = batchStatus === "paused_insufficient_credits";
|
|
29549
|
+
const batchFailed = batchStatus === "failed";
|
|
29550
|
+
const autopilotActive = [
|
|
29551
|
+
"pending",
|
|
29552
|
+
"running",
|
|
29553
|
+
"generating",
|
|
29554
|
+
"rendering"
|
|
29555
|
+
].includes(autopilot);
|
|
29556
|
+
const audioStale = d.narration_stale === true || d.voice_stale === true || d.music_stale === true;
|
|
29557
|
+
const renderStale = latest.is_stale === true;
|
|
29558
|
+
const renderFresh = latest.is_stale === false;
|
|
29559
|
+
const stale = renderStale || staleSegments.length > 0 || audioStale;
|
|
29560
|
+
const renderActive = renderStatus === "pending" || renderStatus === "rendering" || renderStatus === "processing";
|
|
29561
|
+
const scenarioActive = d.scenario_status === "generating" || d.scenario_apply_status === "applying";
|
|
29562
|
+
const narrationActive = d.narration_status === "generating";
|
|
29563
|
+
const scenarioFailed = d.scenario_status === "failed";
|
|
29564
|
+
const scenarioApplyFailed = d.scenario_apply_status === "failed";
|
|
29565
|
+
const narrationFailed = d.narration_status === "failed";
|
|
29566
|
+
const currentAudio = asRecord(d.current_audio);
|
|
29567
|
+
const currentMusic = asRecord(d.current_music);
|
|
29568
|
+
const audioVersions = Array.isArray(d.audio_versions) ? d.audio_versions.map(asRecord) : [];
|
|
29569
|
+
const musicVersions = Array.isArray(d.music_versions) ? d.music_versions.map(asRecord) : [];
|
|
29570
|
+
const numericVersion = (version2) => typeof version2 === "number" && Number.isFinite(version2) ? version2 : 0;
|
|
29571
|
+
const selectedAudioVersion = numericVersion(currentAudio.version);
|
|
29572
|
+
const selectedMusicVersion = numericVersion(currentMusic.version);
|
|
29573
|
+
const newerAudioVersions = audioVersions.filter((version2) => numericVersion(version2.version) > selectedAudioVersion);
|
|
29574
|
+
const newerMusicVersions = musicVersions.filter((version2) => numericVersion(version2.version) > selectedMusicVersion);
|
|
29575
|
+
const versionActive = (version2) => version2.status === "pending" || version2.status === "processing";
|
|
29576
|
+
const newestVersion = (versions2) => versions2.reduce((latest2, version2) => {
|
|
29577
|
+
if (!latest2)
|
|
29578
|
+
return version2;
|
|
29579
|
+
return numericVersion(version2.version) > numericVersion(latest2.version) ? version2 : latest2;
|
|
29580
|
+
}, null);
|
|
29581
|
+
const newestReplacementAudio = newestVersion(newerAudioVersions);
|
|
29582
|
+
const newestReplacementMusic = newestVersion(newerMusicVersions);
|
|
29583
|
+
const audioFailed = currentAudio.status === "failed" || currentMusic.status === "failed" || newestReplacementAudio?.status === "failed" || newestReplacementMusic?.status === "failed";
|
|
29584
|
+
const audioError = [
|
|
29585
|
+
newestReplacementAudio?.error_message,
|
|
29586
|
+
newestReplacementMusic?.error_message,
|
|
29587
|
+
currentAudio.error_message,
|
|
29588
|
+
currentMusic.error_message
|
|
29589
|
+
].find((value) => typeof value === "string" && value.trim().length > 0);
|
|
29590
|
+
const audioActive = currentAudio.status === "pending" || currentAudio.status === "processing" || currentMusic.status === "pending" || currentMusic.status === "processing" || newerAudioVersions.some(versionActive) || newerMusicVersions.some(versionActive);
|
|
29591
|
+
const workActive = autopilotActive || batchActive || renderActive || scenarioActive || narrationActive || audioActive || activeSegments.length > 0;
|
|
29592
|
+
const ready = renderStatus === "completed" && Boolean(videoUrl) && renderFresh && !stale && !workActive && failedSegments.length === 0 && !audioFailed && !scenarioFailed && !scenarioApplyFailed && !narrationFailed && !batchFailed;
|
|
29593
|
+
const autopilotFailed = autopilot === "failed" || autopilot === "cancelled";
|
|
29594
|
+
const renderFailed = renderStatus === "failed";
|
|
29595
|
+
const failed = autopilotFailed || renderFailed || scenarioApplyFailed || scenarioFailed || narrationFailed || batchFailed || failedSegments.length > 0 || audioFailed;
|
|
29596
|
+
const terminal = ready || failed || batchInsufficientCredits || !workActive;
|
|
29597
|
+
let stage = autopilot;
|
|
29598
|
+
if (autopilot === "failed")
|
|
29599
|
+
stage = "failed";
|
|
29600
|
+
else if (autopilot === "cancelled")
|
|
29601
|
+
stage = "cancelled";
|
|
29602
|
+
else if (renderFailed)
|
|
29603
|
+
stage = "render_failed";
|
|
29604
|
+
else if (scenarioApplyFailed)
|
|
29605
|
+
stage = "scenario_apply_failed";
|
|
29606
|
+
else if (scenarioFailed)
|
|
29607
|
+
stage = "scenario_failed";
|
|
29608
|
+
else if (narrationFailed)
|
|
29609
|
+
stage = "narration_failed";
|
|
29610
|
+
else if (batchFailed)
|
|
29611
|
+
stage = "batch_failed";
|
|
29612
|
+
else if (failedSegments.length > 0)
|
|
29613
|
+
stage = "segment_failed";
|
|
29614
|
+
else if (audioFailed)
|
|
29615
|
+
stage = "audio_failed";
|
|
29616
|
+
else if (batchInsufficientCredits)
|
|
29617
|
+
stage = "insufficient_credits";
|
|
29618
|
+
else if (activeSegments.length > 0)
|
|
29619
|
+
stage = "segment_processing";
|
|
29620
|
+
else if (batchActive)
|
|
29621
|
+
stage = "segment_generation";
|
|
29622
|
+
else if (renderActive)
|
|
29623
|
+
stage = "rendering";
|
|
29624
|
+
else if (scenarioActive)
|
|
29625
|
+
stage = "scenario_processing";
|
|
29626
|
+
else if (narrationActive)
|
|
29627
|
+
stage = "narration_processing";
|
|
29628
|
+
else if (audioActive)
|
|
29629
|
+
stage = "audio_processing";
|
|
29630
|
+
else if (!workActive && stale)
|
|
29631
|
+
stage = "editing_required";
|
|
29632
|
+
else if (ready)
|
|
29633
|
+
stage = "completed";
|
|
29634
|
+
const segmentError = failedSegments.map((segment) => segment.error_message).find((value) => typeof value === "string" && value.length > 0);
|
|
29533
29635
|
return {
|
|
29534
29636
|
kind,
|
|
29535
29637
|
slug,
|
|
29536
|
-
stage
|
|
29638
|
+
stage,
|
|
29537
29639
|
terminal,
|
|
29538
29640
|
ready,
|
|
29539
29641
|
video_url: videoUrl,
|
|
29540
|
-
error: d.autopilot_error_message ?? null
|
|
29642
|
+
error: d.autopilot_error_message ?? latest.error_message ?? d.scenario_apply_error_message ?? d.scenario_error_message ?? d.narration_error_message ?? d.batch_generation_error_message ?? segmentError ?? audioError ?? (batchInsufficientCredits ? "Batch generation paused: not enough credits to finish. Top up or reduce scope, then resume." : null),
|
|
29643
|
+
stale,
|
|
29644
|
+
active_segment_ids: activeSegments.map((segment) => segment.id).filter((id) => typeof id === "number" || typeof id === "string"),
|
|
29645
|
+
stale_segment_ids: staleSegments.map((segment) => segment.id).filter((id) => typeof id === "number" || typeof id === "string")
|
|
29541
29646
|
};
|
|
29542
29647
|
}
|
|
29543
29648
|
function startStateDiscriminator(kind, data) {
|
|
@@ -29567,10 +29672,10 @@ function makeVideoAutopilotKey(slug, a, startState) {
|
|
|
29567
29672
|
return idemKey("autopilot", slug, a.prompt, a.product_subject ?? "", a.project_intent ?? "", a.language ?? "en", a.aspect ?? "", a.voice_id ?? "", a.creative_format ?? "", a.visual_language ?? "", a.theme ?? "", startState);
|
|
29568
29673
|
}
|
|
29569
29674
|
function editorAdCreateKey(a) {
|
|
29570
|
-
return idemKey("create-editor", a.product_prompt ?? "", a.product_subject ?? "", a.project_intent ?? "", a.language ?? "en", a.creative_format ?? "", a.visual_language ?? "", a.theme ?? "", a.voice_id ?? "", a.export_aspect_ratio ?? "");
|
|
29675
|
+
return idemKey("create-editor", a.product_prompt ?? "", a.product_subject ?? "", a.project_intent ?? "", a.language ?? "en", a.creative_format ?? "", a.visual_language ?? "", a.theme ?? "", a.voice_id ?? "", a.export_aspect_ratio ?? "", a.product_image_path ?? "", a.product_image_identity ?? "", a.product_description ?? "", a.closing_image_path ?? "", a.closing_image_identity ?? "", a.logo_path ?? "", a.logo_identity ?? "", a.logo_treatment ?? "", a.logo_position ?? "", a.logo_duration_seconds === undefined ? "" : String(a.logo_duration_seconds), a.cast_mode ?? "");
|
|
29571
29676
|
}
|
|
29572
29677
|
function editorAdAutopilotKey(slug, a) {
|
|
29573
|
-
return idemKey("autopilot", slug, a.product_prompt ?? "", a.product_subject ?? "", a.project_intent ?? "", a.language ?? "en", a.creative_format ?? "", a.visual_language ?? "", a.theme ?? "", a.voice_id ?? "", a.export_aspect_ratio ?? "");
|
|
29678
|
+
return idemKey("autopilot", slug, a.product_prompt ?? "", a.product_subject ?? "", a.project_intent ?? "", a.language ?? "en", a.creative_format ?? "", a.visual_language ?? "", a.theme ?? "", a.voice_id ?? "", a.export_aspect_ratio ?? "", a.product_image_path ?? "", a.product_description ?? "", a.closing_image_path ?? "", a.logo_path ?? "", a.logo_treatment ?? "", a.logo_position ?? "", a.logo_duration_seconds === undefined ? "" : String(a.logo_duration_seconds), a.cast_mode ?? "", a.max_credits === undefined ? "" : String(a.max_credits));
|
|
29574
29679
|
}
|
|
29575
29680
|
function sliderCreateKey(a) {
|
|
29576
29681
|
return idemKey("create-slider", a.prompt, a.mode ?? "", a.template ?? "", a.slide_count === undefined ? "" : String(a.slide_count), a.aspect_ratio ?? "", a.accent_color ?? "", a.text_position ?? "");
|
|
@@ -29580,7 +29685,14 @@ function addSegmentKey(slug, index, nonce) {
|
|
|
29580
29685
|
}
|
|
29581
29686
|
function resolveSavePath(savePath) {
|
|
29582
29687
|
const base = resolve(process.env.HUBFLUENCER_OUTPUT_DIR || process.cwd());
|
|
29583
|
-
|
|
29688
|
+
let target;
|
|
29689
|
+
if (isAbsolute(savePath)) {
|
|
29690
|
+
target = resolve(savePath);
|
|
29691
|
+
} else {
|
|
29692
|
+
const workspaceCandidate = resolve(process.cwd(), savePath);
|
|
29693
|
+
const workspaceCandidateInsideBase = workspaceCandidate === base || workspaceCandidate.startsWith(base + sep);
|
|
29694
|
+
target = workspaceCandidateInsideBase ? workspaceCandidate : resolve(base, savePath);
|
|
29695
|
+
}
|
|
29584
29696
|
if (!target.toLowerCase().endsWith(".mp4")) {
|
|
29585
29697
|
throw new Error("save_path must end in .mp4");
|
|
29586
29698
|
}
|
|
@@ -30020,7 +30132,7 @@ async function runCreateCampaign(client, args, log = () => {}) {
|
|
|
30020
30132
|
items,
|
|
30021
30133
|
summary,
|
|
30022
30134
|
steps,
|
|
30023
|
-
next: `get_campaign_pack({ id: ${campaignPackId} }) to review the drafts; then
|
|
30135
|
+
next: `get_campaign_pack({ id: ${campaignPackId} }) to review the drafts; then quote each editor draft with start_autopilot({ slug }) and, after approval, launch with start_autopilot({ slug, max_credits: approved_cap }); use generate_short/generate_slider for approved short/carousel drafts.`,
|
|
30024
30136
|
note: `Campaign pack ${campaignPackId} created with ${summary.created} item(s), ${draftable} materialized into editable DRAFTS. ` + (positioning ? `Positioning: ${positioning}. ` : "") + "$0 so far (free AI-assist quota + 0-credit drafts). Generation/render is a separate, explicit, PAID step per draft — nothing was generated."
|
|
30025
30137
|
};
|
|
30026
30138
|
}
|
|
@@ -30120,7 +30232,7 @@ function readStoredCredentials() {
|
|
|
30120
30232
|
// package.json
|
|
30121
30233
|
var package_default = {
|
|
30122
30234
|
name: "@hubfluencer/mcp",
|
|
30123
|
-
version: "0.
|
|
30235
|
+
version: "0.9.0",
|
|
30124
30236
|
description: "Model Context Protocol server for Hubfluencer — let AI agents generate post-ready shorts and editor ads.",
|
|
30125
30237
|
license: "MIT",
|
|
30126
30238
|
author: "Monocursive <contact@monocursive.com>",
|
|
@@ -30286,7 +30398,7 @@ class HubfluencerClient {
|
|
|
30286
30398
|
if (e instanceof Error && (e.name === "TimeoutError" || e.name === "AbortError")) {
|
|
30287
30399
|
throw new Error(`Request to ${method} ${path} timed out after ${attemptTimeoutMs}ms.`);
|
|
30288
30400
|
}
|
|
30289
|
-
throw e instanceof Error ? new Error(`Network error on ${method} ${path}: ${e.message}`) : e;
|
|
30401
|
+
throw e instanceof Error ? Object.assign(new Error(`Network error on ${method} ${path}: ${e.message}`), { retryable: isRetryableNetworkError(e) }) : e;
|
|
30290
30402
|
}
|
|
30291
30403
|
if (attempt < attempts && isRetryableStatus(res.status) && !deadlinePassed(deadline)) {
|
|
30292
30404
|
await res.text().catch(() => {});
|
|
@@ -30429,7 +30541,12 @@ var getStatusOutput = exports_external.object({
|
|
|
30429
30541
|
terminal: exports_external.boolean().optional(),
|
|
30430
30542
|
ready: exports_external.boolean().optional(),
|
|
30431
30543
|
video_url: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
|
|
30432
|
-
error: exports_external.union([exports_external.string(), exports_external.null()]).optional()
|
|
30544
|
+
error: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
|
|
30545
|
+
stale: exports_external.boolean().optional(),
|
|
30546
|
+
active_segment_ids: exports_external.array(exports_external.union([exports_external.number(), exports_external.string()])).optional(),
|
|
30547
|
+
stale_segment_ids: exports_external.array(exports_external.union([exports_external.number(), exports_external.string()])).optional(),
|
|
30548
|
+
latest_render_free: exports_external.boolean().optional(),
|
|
30549
|
+
last_free_rerender_failed: exports_external.boolean().optional()
|
|
30433
30550
|
}).passthrough();
|
|
30434
30551
|
var waitForCompletionOutput = exports_external.object({
|
|
30435
30552
|
kind: exports_external.string().optional(),
|
|
@@ -30439,6 +30556,11 @@ var waitForCompletionOutput = exports_external.object({
|
|
|
30439
30556
|
ready: exports_external.boolean().optional(),
|
|
30440
30557
|
video_url: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
|
|
30441
30558
|
error: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
|
|
30559
|
+
stale: exports_external.boolean().optional(),
|
|
30560
|
+
active_segment_ids: exports_external.array(exports_external.union([exports_external.number(), exports_external.string()])).optional(),
|
|
30561
|
+
stale_segment_ids: exports_external.array(exports_external.union([exports_external.number(), exports_external.string()])).optional(),
|
|
30562
|
+
latest_render_free: exports_external.boolean().optional(),
|
|
30563
|
+
last_free_rerender_failed: exports_external.boolean().optional(),
|
|
30442
30564
|
saved_to: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
|
|
30443
30565
|
timed_out: exports_external.boolean().optional()
|
|
30444
30566
|
}).passthrough();
|
|
@@ -30482,7 +30604,9 @@ var projectRow = exports_external.object({
|
|
|
30482
30604
|
terminal: exports_external.boolean().optional(),
|
|
30483
30605
|
ready: exports_external.boolean().optional(),
|
|
30484
30606
|
video_url: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
|
|
30485
|
-
error: exports_external.union([exports_external.string(), exports_external.null()]).optional()
|
|
30607
|
+
error: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
|
|
30608
|
+
latest_render_free: exports_external.boolean().optional(),
|
|
30609
|
+
last_free_rerender_failed: exports_external.boolean().optional()
|
|
30486
30610
|
}).passthrough();
|
|
30487
30611
|
var listProjectsOutput = exports_external.object({
|
|
30488
30612
|
shorts: exports_external.array(projectRow).optional(),
|
|
@@ -30498,6 +30622,11 @@ var createEditorOutput = exports_external.object({
|
|
|
30498
30622
|
slug: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
|
|
30499
30623
|
kind: exports_external.string().optional(),
|
|
30500
30624
|
status: projectRow.optional(),
|
|
30625
|
+
estimated_credits: exports_external.union([exports_external.number(), exports_external.null()]).optional(),
|
|
30626
|
+
available_credits: exports_external.union([exports_external.number(), exports_external.null()]).optional(),
|
|
30627
|
+
affordable: exports_external.boolean().optional(),
|
|
30628
|
+
charged: exports_external.boolean().optional(),
|
|
30629
|
+
note: exports_external.string().optional(),
|
|
30501
30630
|
next: exports_external.string().optional()
|
|
30502
30631
|
}).passthrough();
|
|
30503
30632
|
var makeVideoOutput = exports_external.object({
|
|
@@ -30509,8 +30638,8 @@ var makeVideoOutput = exports_external.object({
|
|
|
30509
30638
|
ready: exports_external.boolean().optional(),
|
|
30510
30639
|
video_url: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
|
|
30511
30640
|
error: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
|
|
30512
|
-
estimated_credits: exports_external.
|
|
30513
|
-
available_credits: exports_external.
|
|
30641
|
+
estimated_credits: exports_external.union([exports_external.number(), exports_external.null()]).optional(),
|
|
30642
|
+
available_credits: exports_external.union([exports_external.number(), exports_external.null()]).optional(),
|
|
30514
30643
|
affordable: exports_external.boolean().optional(),
|
|
30515
30644
|
charged: exports_external.boolean().optional(),
|
|
30516
30645
|
saved_to: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
|
|
@@ -30961,14 +31090,19 @@ function calendarScopeErrorText(status, code, message) {
|
|
|
30961
31090
|
}
|
|
30962
31091
|
|
|
30963
31092
|
// src/uploads.ts
|
|
30964
|
-
import {
|
|
30965
|
-
import {
|
|
31093
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
31094
|
+
import { constants } from "node:fs";
|
|
31095
|
+
import { open, realpath, stat } from "node:fs/promises";
|
|
30966
31096
|
import { basename, extname, isAbsolute as isAbsolute2, resolve as resolve2, sep as sep2 } from "node:path";
|
|
31097
|
+
import { Readable } from "node:stream";
|
|
30967
31098
|
var sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
30968
31099
|
var PART_RETRIES = 2;
|
|
30969
31100
|
var ALLOW_LOOPBACK_FETCH = /^http:\/\/(localhost|127\.0\.0\.1|\[?::1\]?)/.test(process.env.HUBFLUENCER_BASE_URL || "");
|
|
30970
31101
|
var MAX_VIDEO_BYTES = 500000000;
|
|
30971
31102
|
var MAX_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
31103
|
+
var MAX_PRODUCT_IMAGE_BYTES = 8 * 1024 * 1024;
|
|
31104
|
+
var MAX_LOGO_BYTES = 1 * 1024 * 1024;
|
|
31105
|
+
var MAX_CLOSING_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
30972
31106
|
var MULTIPART_THRESHOLD = 50 * 1024 * 1024;
|
|
30973
31107
|
var MAX_CATALOG_VIDEO_DURATION_SECONDS = 60;
|
|
30974
31108
|
var VIDEO_EXT_MIME = {
|
|
@@ -30992,6 +31126,53 @@ var CATALOG_ASSET_EXTS = Object.keys(CATALOG_EXT_MIME);
|
|
|
30992
31126
|
function catalogMaxBytes(mime) {
|
|
30993
31127
|
return mime.startsWith("video/") ? MAX_VIDEO_BYTES : MAX_IMAGE_BYTES;
|
|
30994
31128
|
}
|
|
31129
|
+
function preparedUploadIdentity(file) {
|
|
31130
|
+
if (!file.bytes) {
|
|
31131
|
+
throw new Error("Prepared upload has no retained byte snapshot.");
|
|
31132
|
+
}
|
|
31133
|
+
return `${file.size}:${createHash2("sha256").update(file.bytes).digest("hex")}`;
|
|
31134
|
+
}
|
|
31135
|
+
async function fillPositionalRead(handle, buffer, offset, length, position) {
|
|
31136
|
+
let filled = 0;
|
|
31137
|
+
while (filled < length) {
|
|
31138
|
+
const { bytesRead } = await handle.read(buffer, offset + filled, length - filled, position + filled);
|
|
31139
|
+
if (bytesRead === 0)
|
|
31140
|
+
break;
|
|
31141
|
+
filled += bytesRead;
|
|
31142
|
+
}
|
|
31143
|
+
return filled;
|
|
31144
|
+
}
|
|
31145
|
+
async function closeReadHandle(handle) {
|
|
31146
|
+
await handle.close();
|
|
31147
|
+
}
|
|
31148
|
+
async function readValidatedBytes(file) {
|
|
31149
|
+
const buffer = Buffer.allocUnsafe(file.size);
|
|
31150
|
+
let offset = 0;
|
|
31151
|
+
while (offset < file.size) {
|
|
31152
|
+
const { bytesRead } = await file.handle.read(buffer, offset, file.size - offset, offset);
|
|
31153
|
+
if (bytesRead === 0) {
|
|
31154
|
+
throw new Error(`Short read: got ${offset} of ${file.size} validated bytes (file changed under us?).`);
|
|
31155
|
+
}
|
|
31156
|
+
offset += bytesRead;
|
|
31157
|
+
}
|
|
31158
|
+
return buffer;
|
|
31159
|
+
}
|
|
31160
|
+
function validatedByteStream(file) {
|
|
31161
|
+
return Readable.from(async function* () {
|
|
31162
|
+
const chunkSize = 1024 * 1024;
|
|
31163
|
+
let offset = 0;
|
|
31164
|
+
while (offset < file.size) {
|
|
31165
|
+
const length = Math.min(chunkSize, file.size - offset);
|
|
31166
|
+
const chunk = Buffer.allocUnsafe(length);
|
|
31167
|
+
const filled = await fillPositionalRead(file.handle, chunk, 0, length, offset);
|
|
31168
|
+
if (filled !== length) {
|
|
31169
|
+
throw new Error(`Short stream read: got ${filled} of ${length} bytes at offset ${offset} (file changed under us?).`);
|
|
31170
|
+
}
|
|
31171
|
+
offset += filled;
|
|
31172
|
+
yield chunk;
|
|
31173
|
+
}
|
|
31174
|
+
}());
|
|
31175
|
+
}
|
|
30995
31176
|
function planMultipartParts(size, partSize) {
|
|
30996
31177
|
if (!Number.isFinite(size) || size <= 0) {
|
|
30997
31178
|
throw new Error(`size must be a positive number, got ${size}.`);
|
|
@@ -31014,14 +31195,29 @@ function assertFullRead(bytesRead, len, partNumber, offset) {
|
|
|
31014
31195
|
throw new Error(`Short read on part ${partNumber}: got ${bytesRead} of ${len} bytes at offset ${offset} (file changed under us?).`);
|
|
31015
31196
|
}
|
|
31016
31197
|
}
|
|
31017
|
-
async function
|
|
31198
|
+
async function openReadPath(filePath, extToMime, maxBytes) {
|
|
31018
31199
|
if (!filePath || typeof filePath !== "string") {
|
|
31019
31200
|
throw new Error("file_path is required.");
|
|
31020
31201
|
}
|
|
31021
|
-
const
|
|
31022
|
-
|
|
31202
|
+
const configuredBase = resolve2(process.env.HUBFLUENCER_INPUT_DIR || process.cwd());
|
|
31203
|
+
let base;
|
|
31204
|
+
try {
|
|
31205
|
+
base = await realpath(configuredBase);
|
|
31206
|
+
} catch (e) {
|
|
31207
|
+
throw new Error(`Cannot resolve input directory ${configuredBase}: ${e instanceof Error ? e.message : String(e)}`);
|
|
31208
|
+
}
|
|
31209
|
+
const requested = isAbsolute2(filePath) ? resolve2(filePath) : resolve2(configuredBase, filePath);
|
|
31210
|
+
if (requested !== configuredBase && !requested.startsWith(configuredBase + sep2)) {
|
|
31211
|
+
throw new Error(`file_path must be inside ${base} (set HUBFLUENCER_INPUT_DIR to change). Refusing to read ${requested}.`);
|
|
31212
|
+
}
|
|
31213
|
+
let target;
|
|
31214
|
+
try {
|
|
31215
|
+
target = await realpath(requested);
|
|
31216
|
+
} catch (e) {
|
|
31217
|
+
throw new Error(`Cannot read ${requested}: ${e instanceof Error ? e.message : String(e)}`);
|
|
31218
|
+
}
|
|
31023
31219
|
if (target !== base && !target.startsWith(base + sep2)) {
|
|
31024
|
-
throw new Error(`file_path must
|
|
31220
|
+
throw new Error(`file_path must resolve inside ${base} (set HUBFLUENCER_INPUT_DIR to change). Refusing to read ${target}.`);
|
|
31025
31221
|
}
|
|
31026
31222
|
const ext = extname(target).slice(1).toLowerCase();
|
|
31027
31223
|
const mime = extToMime[ext];
|
|
@@ -31029,21 +31225,57 @@ async function resolveReadPath(filePath, extToMime, maxBytes) {
|
|
|
31029
31225
|
const allowed = Object.keys(extToMime).map((e) => `.${e}`).join(", ");
|
|
31030
31226
|
throw new Error(`Unsupported file type ".${ext}". Allowed: ${allowed}.`);
|
|
31031
31227
|
}
|
|
31032
|
-
let
|
|
31228
|
+
let handle;
|
|
31033
31229
|
try {
|
|
31034
|
-
|
|
31230
|
+
handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
31231
|
+
const [openedStat, targetStat, currentTarget] = await Promise.all([
|
|
31232
|
+
handle.stat(),
|
|
31233
|
+
stat(target),
|
|
31234
|
+
realpath(requested)
|
|
31235
|
+
]);
|
|
31236
|
+
if (currentTarget !== target)
|
|
31237
|
+
throw new Error("path changed while opening");
|
|
31238
|
+
const st = openedStat;
|
|
31035
31239
|
if (!st.isFile())
|
|
31036
31240
|
throw new Error("not a regular file");
|
|
31037
|
-
|
|
31241
|
+
if (st.dev !== targetStat.dev || st.ino !== targetStat.ino) {
|
|
31242
|
+
throw new Error("opened file does not match the validated target");
|
|
31243
|
+
}
|
|
31244
|
+
if (st.size <= 0)
|
|
31245
|
+
throw new Error(`${target} is empty.`);
|
|
31246
|
+
if (st.size > maxBytes) {
|
|
31247
|
+
throw new Error(`${target} is ${st.size} bytes — over the ${maxBytes}-byte cap for this asset type.`);
|
|
31248
|
+
}
|
|
31249
|
+
return { path: target, ext, mime, size: st.size, handle };
|
|
31038
31250
|
} catch (e) {
|
|
31251
|
+
if (handle)
|
|
31252
|
+
await closeReadHandle(handle).catch(() => {
|
|
31253
|
+
return;
|
|
31254
|
+
});
|
|
31039
31255
|
throw new Error(`Cannot read ${target}: ${e instanceof Error ? e.message : String(e)}`);
|
|
31040
31256
|
}
|
|
31041
|
-
|
|
31042
|
-
|
|
31043
|
-
|
|
31044
|
-
|
|
31257
|
+
}
|
|
31258
|
+
async function prepareImageUpload(filePath, maxBytes = MAX_IMAGE_BYTES) {
|
|
31259
|
+
const file = await openReadPath(filePath, IMAGE_EXT_MIME, maxBytes);
|
|
31260
|
+
try {
|
|
31261
|
+
file.bytes = await readValidatedBytes(file);
|
|
31262
|
+
return file;
|
|
31263
|
+
} catch (error2) {
|
|
31264
|
+
await closeReadHandle(file.handle);
|
|
31265
|
+
throw error2;
|
|
31266
|
+
}
|
|
31267
|
+
}
|
|
31268
|
+
function closePreparedUploadFile(file) {
|
|
31269
|
+
return closeReadHandle(file.handle);
|
|
31270
|
+
}
|
|
31271
|
+
async function resolveReadPath(filePath, extToMime, maxBytes) {
|
|
31272
|
+
const file = await openReadPath(filePath, extToMime, maxBytes);
|
|
31273
|
+
try {
|
|
31274
|
+
const { handle: _, ...resolved } = file;
|
|
31275
|
+
return resolved;
|
|
31276
|
+
} finally {
|
|
31277
|
+
await closeReadHandle(file.handle);
|
|
31045
31278
|
}
|
|
31046
|
-
return { path: target, ext, mime, size };
|
|
31047
31279
|
}
|
|
31048
31280
|
async function resolveVideoReadPath(filePath) {
|
|
31049
31281
|
return resolveReadPath(filePath, VIDEO_EXT_MIME, MAX_VIDEO_BYTES);
|
|
@@ -31060,7 +31292,7 @@ async function putToPresignedUrl(url, body, contentType, timeoutMs = 300000, con
|
|
|
31060
31292
|
fetchBody = body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
|
|
31061
31293
|
fetchInit = { method: "PUT", headers, body: fetchBody };
|
|
31062
31294
|
} else {
|
|
31063
|
-
stream =
|
|
31295
|
+
stream = validatedByteStream(body);
|
|
31064
31296
|
headers["content-length"] = String(contentLength ?? body.size);
|
|
31065
31297
|
fetchInit = {
|
|
31066
31298
|
method: "PUT",
|
|
@@ -31070,19 +31302,35 @@ async function putToPresignedUrl(url, body, contentType, timeoutMs = 300000, con
|
|
|
31070
31302
|
};
|
|
31071
31303
|
}
|
|
31072
31304
|
fetchInit.signal = AbortSignal.timeout(timeoutMs);
|
|
31305
|
+
const closeStream = async () => {
|
|
31306
|
+
if (!stream || stream.closed)
|
|
31307
|
+
return;
|
|
31308
|
+
await new Promise((resolve3, reject) => {
|
|
31309
|
+
stream?.once("close", resolve3);
|
|
31310
|
+
stream?.once("error", reject);
|
|
31311
|
+
stream?.destroy();
|
|
31312
|
+
});
|
|
31313
|
+
};
|
|
31073
31314
|
let resp;
|
|
31074
31315
|
try {
|
|
31075
31316
|
resp = await fetch(url, fetchInit);
|
|
31076
31317
|
} catch (e) {
|
|
31077
|
-
|
|
31318
|
+
await closeStream().catch(() => {
|
|
31319
|
+
return;
|
|
31320
|
+
});
|
|
31078
31321
|
if (e instanceof Error && (e.name === "TimeoutError" || e.name === "AbortError")) {
|
|
31079
31322
|
throw markRetryable(`Upload PUT timed out after ${timeoutMs / 1000}s.`);
|
|
31080
31323
|
}
|
|
31081
31324
|
throw e instanceof Error ? markRetryable(`Upload PUT failed: ${e.message}`) : e;
|
|
31082
31325
|
}
|
|
31326
|
+
await closeStream();
|
|
31083
31327
|
if (!resp.ok) {
|
|
31084
31328
|
const text = await resp.text().catch(() => "");
|
|
31085
|
-
|
|
31329
|
+
const error2 = new Error(`Upload PUT rejected (HTTP ${resp.status})${text ? `: ${text.slice(0, 200)}` : ""}.`);
|
|
31330
|
+
if (isRetryableStatus(resp.status)) {
|
|
31331
|
+
Object.assign(error2, { retryable: true });
|
|
31332
|
+
}
|
|
31333
|
+
throw error2;
|
|
31086
31334
|
}
|
|
31087
31335
|
return resp.headers.get("etag") ?? undefined;
|
|
31088
31336
|
}
|
|
@@ -31090,31 +31338,35 @@ function markRetryable(message) {
|
|
|
31090
31338
|
return Object.assign(new Error(message), { retryable: true });
|
|
31091
31339
|
}
|
|
31092
31340
|
async function uploadVideoFile(client, slug, filePath, opts = {}) {
|
|
31093
|
-
const file = await
|
|
31341
|
+
const file = await openReadPath(filePath, VIDEO_EXT_MIME, MAX_VIDEO_BYTES);
|
|
31094
31342
|
const filename = basename(file.path);
|
|
31095
31343
|
const fit_mode = opts.fitMode === "cover" ? "cover" : undefined;
|
|
31096
31344
|
const product_description = opts.productDescription;
|
|
31097
|
-
|
|
31098
|
-
|
|
31345
|
+
try {
|
|
31346
|
+
if (file.size >= MULTIPART_THRESHOLD) {
|
|
31347
|
+
return await uploadVideoMultipart(client, slug, file, {
|
|
31348
|
+
filename,
|
|
31349
|
+
fit_mode,
|
|
31350
|
+
product_description
|
|
31351
|
+
}, opts.onProgress);
|
|
31352
|
+
}
|
|
31353
|
+
const buf = await readValidatedBytes(file);
|
|
31354
|
+
const presign = await client.post(`/editor/${slug}/uploads/presign`, {
|
|
31099
31355
|
filename,
|
|
31356
|
+
mime_type: file.mime,
|
|
31357
|
+
size_bytes: file.size,
|
|
31100
31358
|
fit_mode,
|
|
31101
31359
|
product_description
|
|
31102
|
-
}
|
|
31360
|
+
});
|
|
31361
|
+
const { upload_id, presigned_url } = presign.data;
|
|
31362
|
+
await opts.onProgress?.(0, 1, `uploading ${filename}`);
|
|
31363
|
+
await putToPresignedUrl(presigned_url, buf, file.mime);
|
|
31364
|
+
await opts.onProgress?.(1, 1, `uploaded ${filename}, confirming`);
|
|
31365
|
+
const confirmed = await client.post(`/editor/${slug}/uploads/${upload_id}/confirm`);
|
|
31366
|
+
return { upload_id, status: confirmed.data?.status ?? "processing" };
|
|
31367
|
+
} finally {
|
|
31368
|
+
await closeReadHandle(file.handle);
|
|
31103
31369
|
}
|
|
31104
|
-
const presign = await client.post(`/editor/${slug}/uploads/presign`, {
|
|
31105
|
-
filename,
|
|
31106
|
-
mime_type: file.mime,
|
|
31107
|
-
size_bytes: file.size,
|
|
31108
|
-
fit_mode,
|
|
31109
|
-
product_description
|
|
31110
|
-
});
|
|
31111
|
-
const { upload_id, presigned_url } = presign.data;
|
|
31112
|
-
const buf = await readFile(file.path);
|
|
31113
|
-
await opts.onProgress?.(0, 1, `uploading ${filename}`);
|
|
31114
|
-
await putToPresignedUrl(presigned_url, buf, file.mime);
|
|
31115
|
-
await opts.onProgress?.(1, 1, `uploaded ${filename}, confirming`);
|
|
31116
|
-
const confirmed = await client.post(`/editor/${slug}/uploads/${upload_id}/confirm`);
|
|
31117
|
-
return { upload_id, status: confirmed.data?.status ?? "processing" };
|
|
31118
31370
|
}
|
|
31119
31371
|
async function uploadPartWithRetry(client, slug, ids, part_number, chunk) {
|
|
31120
31372
|
for (let attempt = 1;; attempt++) {
|
|
@@ -31130,7 +31382,8 @@ async function uploadPartWithRetry(client, slug, ids, part_number, chunk) {
|
|
|
31130
31382
|
}
|
|
31131
31383
|
return etag;
|
|
31132
31384
|
} catch (e) {
|
|
31133
|
-
|
|
31385
|
+
const status = e?.status;
|
|
31386
|
+
if (attempt <= PART_RETRIES && (isRetryableNetworkError(e) || typeof status === "number" && isRetryableStatus(status))) {
|
|
31134
31387
|
await sleep3(backoffDelayMs(attempt, DEFAULT_RETRY));
|
|
31135
31388
|
continue;
|
|
31136
31389
|
}
|
|
@@ -31147,18 +31400,17 @@ async function uploadVideoMultipart(client, slug, file, meta2, onProgress) {
|
|
|
31147
31400
|
product_description: meta2.product_description
|
|
31148
31401
|
});
|
|
31149
31402
|
const { upload_id, s3_upload_id, parts_count, part_size } = init.data;
|
|
31150
|
-
const plan = planMultipartParts(file.size, part_size);
|
|
31151
|
-
if (plan.length !== parts_count) {
|
|
31152
|
-
throw new Error(`Multipart plan mismatch: server expects ${parts_count} parts, computed ${plan.length} for ${file.size} bytes at ${part_size}/part.`);
|
|
31153
|
-
}
|
|
31154
|
-
await onProgress?.(0, plan.length, `uploading ${meta2.filename} in ${plan.length} parts`);
|
|
31155
|
-
const fh = await open(file.path, "r");
|
|
31156
|
-
const parts = [];
|
|
31157
31403
|
try {
|
|
31404
|
+
const plan = planMultipartParts(file.size, part_size);
|
|
31405
|
+
if (plan.length !== parts_count) {
|
|
31406
|
+
throw new Error(`Multipart plan mismatch: server expects ${parts_count} parts, computed ${plan.length} for ${file.size} bytes at ${part_size}/part.`);
|
|
31407
|
+
}
|
|
31408
|
+
await onProgress?.(0, plan.length, `uploading ${meta2.filename} in ${plan.length} parts`);
|
|
31409
|
+
const parts = [];
|
|
31158
31410
|
for (const { part_number, offset, len } of plan) {
|
|
31159
31411
|
const chunk = Buffer.alloc(len);
|
|
31160
|
-
const
|
|
31161
|
-
assertFullRead(
|
|
31412
|
+
const filled = await fillPositionalRead(file.handle, chunk, 0, len, offset);
|
|
31413
|
+
assertFullRead(filled, len, part_number, offset);
|
|
31162
31414
|
const etag = await uploadPartWithRetry(client, slug, { upload_id, s3_upload_id }, part_number, chunk);
|
|
31163
31415
|
parts.push({ part_number, etag });
|
|
31164
31416
|
await onProgress?.(part_number, plan.length, `uploaded part ${part_number}/${plan.length}`);
|
|
@@ -31173,73 +31425,95 @@ async function uploadVideoMultipart(client, slug, file, meta2, onProgress) {
|
|
|
31173
31425
|
});
|
|
31174
31426
|
} catch {}
|
|
31175
31427
|
throw e;
|
|
31176
|
-
} finally {
|
|
31177
|
-
await fh.close();
|
|
31178
31428
|
}
|
|
31179
31429
|
}
|
|
31180
31430
|
async function uploadImageFile(client, presignPath, filePath, maxBytes = MAX_IMAGE_BYTES) {
|
|
31181
|
-
const
|
|
31182
|
-
const
|
|
31183
|
-
|
|
31184
|
-
|
|
31185
|
-
|
|
31186
|
-
|
|
31187
|
-
|
|
31188
|
-
|
|
31189
|
-
|
|
31431
|
+
const ownsFile = typeof filePath === "string";
|
|
31432
|
+
const file = ownsFile ? await openReadPath(filePath, IMAGE_EXT_MIME, maxBytes) : filePath;
|
|
31433
|
+
try {
|
|
31434
|
+
const buf = file.bytes ?? await readValidatedBytes(file);
|
|
31435
|
+
const presign = await client.post(presignPath, {
|
|
31436
|
+
mime_type: file.mime,
|
|
31437
|
+
size_bytes: file.size
|
|
31438
|
+
});
|
|
31439
|
+
const { presigned_url, s3_key } = presign.data;
|
|
31440
|
+
await putToPresignedUrl(presigned_url, buf, file.mime);
|
|
31441
|
+
return { s3_key };
|
|
31442
|
+
} finally {
|
|
31443
|
+
if (ownsFile)
|
|
31444
|
+
await closeReadHandle(file.handle);
|
|
31445
|
+
}
|
|
31190
31446
|
}
|
|
31191
31447
|
async function uploadShortPoster(client, slug, filePath) {
|
|
31192
|
-
const file = await
|
|
31193
|
-
|
|
31194
|
-
|
|
31195
|
-
|
|
31196
|
-
|
|
31197
|
-
|
|
31448
|
+
const file = await openReadPath(filePath, IMAGE_EXT_MIME, MAX_IMAGE_BYTES);
|
|
31449
|
+
try {
|
|
31450
|
+
const buf = await readValidatedBytes(file);
|
|
31451
|
+
const presign = await client.post(`/shorts/${slug}/poster/presign`, { content_type: file.mime });
|
|
31452
|
+
const { upload_url, s3_key } = presign;
|
|
31453
|
+
await putToPresignedUrl(upload_url, buf, file.mime);
|
|
31454
|
+
return { s3_key };
|
|
31455
|
+
} finally {
|
|
31456
|
+
await closeReadHandle(file.handle);
|
|
31457
|
+
}
|
|
31198
31458
|
}
|
|
31199
31459
|
async function uploadCatalogAsset(client, filePath, opts = {}) {
|
|
31200
|
-
const file = await
|
|
31201
|
-
|
|
31202
|
-
|
|
31203
|
-
|
|
31204
|
-
|
|
31205
|
-
if (file.mime.startsWith("video/")) {
|
|
31206
|
-
const duration3 = opts.durationSeconds;
|
|
31207
|
-
if (typeof duration3 !== "number" || !Number.isFinite(duration3) || duration3 <= 0 || duration3 > MAX_CATALOG_VIDEO_DURATION_SECONDS) {
|
|
31208
|
-
throw new Error(`Catalog video uploads require durationSeconds to be greater than 0 and no more than ${MAX_CATALOG_VIDEO_DURATION_SECONDS} seconds.`);
|
|
31460
|
+
const file = await openReadPath(filePath, CATALOG_EXT_MIME, MAX_VIDEO_BYTES);
|
|
31461
|
+
try {
|
|
31462
|
+
const cap = catalogMaxBytes(file.mime);
|
|
31463
|
+
if (file.size > cap) {
|
|
31464
|
+
throw new Error(`${file.path} is ${file.size} bytes — over the ${cap}-byte cap for ${file.mime} catalog uploads.`);
|
|
31209
31465
|
}
|
|
31466
|
+
if (file.mime.startsWith("video/")) {
|
|
31467
|
+
const duration3 = opts.durationSeconds;
|
|
31468
|
+
if (typeof duration3 !== "number" || !Number.isFinite(duration3) || duration3 <= 0 || duration3 > MAX_CATALOG_VIDEO_DURATION_SECONDS) {
|
|
31469
|
+
throw new Error(`Catalog video uploads require durationSeconds to be greater than 0 and no more than ${MAX_CATALOG_VIDEO_DURATION_SECONDS} seconds.`);
|
|
31470
|
+
}
|
|
31471
|
+
}
|
|
31472
|
+
const bufferedBytes = file.size < MULTIPART_THRESHOLD ? await readValidatedBytes(file) : undefined;
|
|
31473
|
+
const filename = basename(file.path);
|
|
31474
|
+
const presign = await client.post("/assets/presign", {
|
|
31475
|
+
filename,
|
|
31476
|
+
mime_type: file.mime,
|
|
31477
|
+
size_bytes: file.size
|
|
31478
|
+
});
|
|
31479
|
+
const { asset_id, presigned_url } = presign.data;
|
|
31480
|
+
if (file.size >= MULTIPART_THRESHOLD) {
|
|
31481
|
+
for (let attempt = 1;; attempt++) {
|
|
31482
|
+
try {
|
|
31483
|
+
await putToPresignedUrl(presigned_url, { handle: file.handle, size: file.size }, file.mime, 300000, file.size);
|
|
31484
|
+
break;
|
|
31485
|
+
} catch (error2) {
|
|
31486
|
+
if (attempt >= DEFAULT_RETRY.attempts || !isRetryableNetworkError(error2)) {
|
|
31487
|
+
throw error2;
|
|
31488
|
+
}
|
|
31489
|
+
await sleep3(backoffDelayMs(attempt, DEFAULT_RETRY));
|
|
31490
|
+
}
|
|
31491
|
+
}
|
|
31492
|
+
} else {
|
|
31493
|
+
await putToPresignedUrl(presigned_url, bufferedBytes, file.mime);
|
|
31494
|
+
}
|
|
31495
|
+
const confirmBody = {};
|
|
31496
|
+
if (typeof opts.description === "string" && opts.description.trim() !== "")
|
|
31497
|
+
confirmBody.description = opts.description;
|
|
31498
|
+
if (typeof opts.width === "number")
|
|
31499
|
+
confirmBody.width = opts.width;
|
|
31500
|
+
if (typeof opts.height === "number")
|
|
31501
|
+
confirmBody.height = opts.height;
|
|
31502
|
+
if (typeof opts.durationSeconds === "number")
|
|
31503
|
+
confirmBody.duration_seconds = opts.durationSeconds;
|
|
31504
|
+
const confirmed = await client.post(`/assets/${asset_id}/confirm`, confirmBody);
|
|
31505
|
+
const data = confirmed.data ?? {};
|
|
31506
|
+
return {
|
|
31507
|
+
asset_id,
|
|
31508
|
+
status: typeof data.status === "string" ? data.status : "ready",
|
|
31509
|
+
media_type: typeof data.media_type === "string" ? data.media_type : "",
|
|
31510
|
+
mime_type: typeof data.mime_type === "string" ? data.mime_type : file.mime,
|
|
31511
|
+
filename: typeof data.filename === "string" ? data.filename : filename,
|
|
31512
|
+
size_bytes: typeof data.size_bytes === "number" ? data.size_bytes : file.size
|
|
31513
|
+
};
|
|
31514
|
+
} finally {
|
|
31515
|
+
await closeReadHandle(file.handle);
|
|
31210
31516
|
}
|
|
31211
|
-
const filename = basename(file.path);
|
|
31212
|
-
const presign = await client.post("/assets/presign", {
|
|
31213
|
-
filename,
|
|
31214
|
-
mime_type: file.mime,
|
|
31215
|
-
size_bytes: file.size
|
|
31216
|
-
});
|
|
31217
|
-
const { asset_id, presigned_url } = presign.data;
|
|
31218
|
-
if (file.size >= MULTIPART_THRESHOLD) {
|
|
31219
|
-
await putToPresignedUrl(presigned_url, { filePath: file.path, size: file.size }, file.mime, 300000, file.size);
|
|
31220
|
-
} else {
|
|
31221
|
-
const buf = await readFile(file.path);
|
|
31222
|
-
await putToPresignedUrl(presigned_url, buf, file.mime);
|
|
31223
|
-
}
|
|
31224
|
-
const confirmBody = {};
|
|
31225
|
-
if (typeof opts.description === "string" && opts.description.trim() !== "")
|
|
31226
|
-
confirmBody.description = opts.description;
|
|
31227
|
-
if (typeof opts.width === "number")
|
|
31228
|
-
confirmBody.width = opts.width;
|
|
31229
|
-
if (typeof opts.height === "number")
|
|
31230
|
-
confirmBody.height = opts.height;
|
|
31231
|
-
if (typeof opts.durationSeconds === "number")
|
|
31232
|
-
confirmBody.duration_seconds = opts.durationSeconds;
|
|
31233
|
-
const confirmed = await client.post(`/assets/${asset_id}/confirm`, confirmBody);
|
|
31234
|
-
const data = confirmed.data ?? {};
|
|
31235
|
-
return {
|
|
31236
|
-
asset_id,
|
|
31237
|
-
status: typeof data.status === "string" ? data.status : "ready",
|
|
31238
|
-
media_type: typeof data.media_type === "string" ? data.media_type : "",
|
|
31239
|
-
mime_type: typeof data.mime_type === "string" ? data.mime_type : file.mime,
|
|
31240
|
-
filename: typeof data.filename === "string" ? data.filename : filename,
|
|
31241
|
-
size_bytes: typeof data.size_bytes === "number" ? data.size_bytes : file.size
|
|
31242
|
-
};
|
|
31243
31517
|
}
|
|
31244
31518
|
|
|
31245
31519
|
// src/index.ts
|
|
@@ -31248,6 +31522,37 @@ async function fetchStatus(client, kind, slug, deadline) {
|
|
|
31248
31522
|
const res = await client.get(path, undefined, deadline);
|
|
31249
31523
|
return normalizeStatus(kind, slug, asRecord(res).data);
|
|
31250
31524
|
}
|
|
31525
|
+
async function editorAutopilotQuote(client, slug, overrides = {}) {
|
|
31526
|
+
try {
|
|
31527
|
+
const params = new URLSearchParams;
|
|
31528
|
+
if (overrides.language)
|
|
31529
|
+
params.set("language", overrides.language);
|
|
31530
|
+
if (overrides.product_subject)
|
|
31531
|
+
params.set("product_subject", overrides.product_subject);
|
|
31532
|
+
if (overrides.product_prompt)
|
|
31533
|
+
params.set("product_prompt", overrides.product_prompt);
|
|
31534
|
+
const query = params.toString();
|
|
31535
|
+
const response = await client.get(`/editor/${slug}/autopilot/cost${query ? `?${query}` : ""}`);
|
|
31536
|
+
const cost = asRecord(asRecord(response).data);
|
|
31537
|
+
const estimated_credits = typeof cost.total === "number" ? cost.total : null;
|
|
31538
|
+
const available_credits = typeof cost.available_credits === "number" ? cost.available_credits : null;
|
|
31539
|
+
return {
|
|
31540
|
+
estimated_credits,
|
|
31541
|
+
available_credits,
|
|
31542
|
+
affordable: estimated_credits !== null && available_credits !== null && available_credits >= estimated_credits
|
|
31543
|
+
};
|
|
31544
|
+
} catch (error2) {
|
|
31545
|
+
const apiError = error2;
|
|
31546
|
+
if (typeof apiError.status === "number" && apiError.status >= 400 && apiError.status < 500 && apiError.status !== 429) {
|
|
31547
|
+
throw error2;
|
|
31548
|
+
}
|
|
31549
|
+
return {
|
|
31550
|
+
estimated_credits: null,
|
|
31551
|
+
available_credits: null,
|
|
31552
|
+
affordable: false
|
|
31553
|
+
};
|
|
31554
|
+
}
|
|
31555
|
+
}
|
|
31251
31556
|
function isRecompositeInProgressConflict(e) {
|
|
31252
31557
|
const err = e;
|
|
31253
31558
|
if (err?.status !== 409)
|
|
@@ -31453,26 +31758,26 @@ var WRITE = {
|
|
|
31453
31758
|
var SERVER_INSTRUCTIONS = `Hubfluencer turns a prompt — or a whole product page — into finished, post-ready vertical videos and image carousels, and can run the surrounding content operation (plan → draft → review → schedule → measure → repeat).
|
|
31454
31759
|
|
|
31455
31760
|
MAKE ONE ASSET (simplest)
|
|
31456
|
-
- make_video({ prompt }) creates, generates, and returns a finished MP4 — it picks a single-clip short or a multi-scene editor ad from the prompt (override with kind:"short"|"editor").
|
|
31761
|
+
- make_video({ prompt }) creates, generates, and returns a finished MP4 — it picks a single-clip short or a multi-scene editor ad from the prompt (override with kind:"short"|"editor"). create_editor_ad creates/configures/prices an editor and launches only with max_credits; create_short creates a short draft; create_slider makes an image carousel; create_tracking_video burns a CV overlay onto a local clip.
|
|
31457
31762
|
- Granular (full control): create a draft, then drive the pipeline step by step (create_editor_draft → generate_scenario → apply_scenario → review/edit prompts (set_segment_prompt) → generate_segment(s) → generate_voice + generate_music → render; regenerate_segment re-rolls a completed scene as a new version). Poll with get_status / wait_for_completion; fetch with download_result (a slider has no MP4 — read its slides with get_slider).
|
|
31458
31763
|
|
|
31459
31764
|
DELEGATE THE CONTENT OPERATION (the full loop — steps 1-3 are $0)
|
|
31460
31765
|
1. ONBOARD a product: extract_product_page(url) pulls facts + imagery (SSRF-hardened, 0cr); import_product_image saves its picture/logo. Persist identity once with create_product_profile + create_brand_profile so every later draft inherits it instead of re-describing the brand.
|
|
31461
31766
|
2. PLAN ($0, free quota — not credits): plan_campaign proposes angles/formats + a per-item credit estimate; generate_hook_variations writes hook options. create_campaign(url | product_profile_id) does 1-2 end-to-end and returns a pack of editable DRAFTS.
|
|
31462
31767
|
3. PACK & MATERIALIZE ($0): create_campaign_pack / add_pack_variations assemble items; materialize_pack_item turns each into a 0-credit draft (editor_ad/short → a video project, carousel → a slider). list_campaign_packs lists packs; get_campaign_pack({ id }) shows every item's draft slug + credit estimate.
|
|
31463
|
-
4. GENERATE per draft (CREDITS — a separate, explicit step):
|
|
31768
|
+
4. GENERATE per draft (CREDITS — a separate, explicit step): start_autopilot({slug}) quotes free; after approval, start_autopilot({slug,max_credits:approved_cap}) launches. Run generate_short/generate_slider for approved short/carousel drafts. Nothing above this line spends video credits.
|
|
31464
31769
|
5. REVIEW: create_review_link mints a no-login page where a human approves/comments (list_review_links / revoke_review_link manage them). Gate spend or posting on approval.
|
|
31465
31770
|
6. CALENDAR (reminder-only): schedule_post / list_scheduled_posts / reschedule_post / cancel_scheduled_post stage a calendar that pushes REMINDERS to the user — it does NOT auto-publish (posting to TikTok/IG stays a human step in the app). After the user posts the presigned export, call mark_scheduled_posted.
|
|
31466
31771
|
7. MEASURE → DOUBLE DOWN: record_performance logs views/engagement (tag creatives via set_item_attributes); get_recommendations gives a CAUTIOUS, non-causal read of your own best hook/format (present its confidence + note as-is, never upgrade them); make_more_like_winner spins fresh hooks from a proven item — feed a winner back into add_pack_variations → step 4.
|
|
31467
31772
|
8. RECURRING: create_series + plan_series_episodes → materialize_episode drafts each episode; get_series_dashboard shows upcoming/drafted/published lanes; mark_episode_posted closes a recurring show's loop.
|
|
31468
31773
|
|
|
31469
31774
|
CREDITS (spent server-side; each tool prices before charging — see its description)
|
|
31470
|
-
- Free (0 credits): every draft create/edit, reorder, all slider re-composites, and the whole onboard/plan/pack/materialize path (steps 1-3). get_credits shows the balance.
|
|
31471
|
-
- Charged: short render 15; editor AI scene 5 (4 in a batch of ≥3); voice 3; music 5; editor render 0 (auto-charges ungenerated scenes); slider 1/slide; tracking render 1 (refunded on failure). make_video
|
|
31775
|
+
- Free (0 credits): every draft create/edit, reorder, all slider re-composites, short re-renders (rerender_short — applies text/style/CTA/end-card edits over a short's already-paid footage), and the whole onboard/plan/pack/materialize path (steps 1-3). get_credits shows the balance.
|
|
31776
|
+
- Charged: short render 15; editor AI scene 5 (4 in a batch of ≥3); voice 3; music 5; editor render 0 (auto-charges ungenerated scenes); slider 1/slide; tracking render 1 (refunded on failure). make_video supports dry_run/max_credits; create_editor_ad/start_autopilot require max_credits before launching.
|
|
31472
31777
|
- AI ASSISTS (all generate_*/enhance/suggest/plan/hook tools) draw a FREE daily quota (20/day), NOT credits. On 429, set auto_unlock:true to spend 1 credit for +10 assists (retried once), or write the copy yourself and use the set_* tools.
|
|
31473
31778
|
|
|
31474
31779
|
WAITING & RESUMING
|
|
31475
|
-
- Renders run async and can take minutes. wait_for_completion polls a budget capped at 280s; if it returns terminal=false / timed_out, just CALL IT AGAIN with the same slug. make_video returns the slug + a resume hint on timeout.
|
|
31780
|
+
- Renders run async and can take minutes. wait_for_completion polls a budget capped at 280s; if it returns terminal=false / timed_out, just CALL IT AGAIN with the same slug. make_video returns the slug + a resume hint on timeout. A terminal result is not always ready: an editor can go terminal in a needs-action stage (editing_required = stale after edits, regenerate the flagged scenes/audio; insufficient_credits = batch parked, top up / reduce scope; idle = draft with nothing generating, start generation first) — act on those instead of re-polling.
|
|
31476
31781
|
- Creates and charged actions are idempotency-keyed: a transport retry is safe; a genuine re-run after a terminal FAILURE restarts rather than replaying the dead attempt.
|
|
31477
31782
|
|
|
31478
31783
|
LOCAL FILES (sandboxed)
|
|
@@ -31520,7 +31825,7 @@ async function pollSegmentToTerminal(client, slug, segmentId, extra, budgetMs, i
|
|
|
31520
31825
|
}
|
|
31521
31826
|
registerTool("make_video", {
|
|
31522
31827
|
title: "Make a video from a prompt (one shot)",
|
|
31523
|
-
description: "The simplest path: give a prompt, get a finished MP4. Creates the project (free), PRICES it against " + "your live credit balance, then — only if it's affordable and within max_credits — starts generation, " + "polls to completion (emitting progress), and (if save_path is given) downloads the result. " + "Spends credits (15 for a short; a multi-scene editor ad ~28). Pass dry_run:true to preview the cost " + "WITHOUT charging (returns {estimated_credits, available_credits, slug}); pass max_credits to cap the " + "spend. kind defaults to 'auto' — a multi-scene editor ad for ad/promo/story briefs, a single-clip short " + "for simple/short ones; the chosen kind is reported back as kind_inferred. If it returns terminal=false " + "the render is still running — call wait_for_completion with the returned slug to finish. " + "BE PROACTIVE WITH BRANDING: pass headline (the on-screen TITLE) and subheadline (secondary title) plus " + "music_vibe so a one-shot short isn't bare. creative_format + visual_language apply to BOTH kinds (editor and " + "shorts); headline/subheadline/music_vibe/text_* and the conversion graphics are SHORTS-only and ignored for " + "editor; theme applies to editor (genre overlay) and is legacy-only for shorts (used when visual_language is unset). " + "For richer branding — a product image, brand logo, or " + "closing card — drive the granular path instead: create_short + set_short_product/set_short_poster, or " + "create_editor_draft + set_product/set_logo/set_closing_image, then start_autopilot/generate_short.",
|
|
31828
|
+
description: "The simplest path: give a prompt, get a finished MP4. Creates the project (free), PRICES it against " + "your live credit balance, then — only if it's affordable and within max_credits — starts generation, " + "polls to completion (emitting progress), and (if save_path is given) downloads the result. " + "Spends credits (15 for a short; a multi-scene editor ad ~28). Pass dry_run:true to preview the cost " + "WITHOUT charging (returns {estimated_credits, available_credits, slug}); pass max_credits to cap the " + "spend. kind defaults to 'auto' — a multi-scene editor ad for ad/promo/story briefs, a single-clip short " + "for simple/short ones; the chosen kind is reported back as kind_inferred. If it returns terminal=false " + "the render is still running — call wait_for_completion with the returned slug to finish. " + "BE PROACTIVE WITH BRANDING: pass headline (the on-screen TITLE) and subheadline (secondary title) plus " + "music_vibe so a one-shot short isn't bare. Always set cta_text — a short without a CTA doesn't convert " + "(plus offer_text only if there's a real deal). Note: blank cta_text → the server renders a neutral " + "localized ask (e.g. 'Learn more'); a truly CTA-less short is not currently supported. " + "Keep headline ≈4 words (≤40 chars). Set visual_language " + "explicitly (kinetic_creator is a strong default). If headline/subheadline/text_beats are left blank the " + "server auto-writes the headline + caption beats at generate time; pass skip_auto_text:true for a " + "deliberately bare clip. creative_format + visual_language apply to BOTH kinds (editor and " + "shorts); headline/subheadline/music_vibe/text_* and the conversion graphics are SHORTS-only and ignored for " + "editor; theme applies to editor (genre overlay) and is legacy-only for shorts (used when visual_language is unset). " + "For richer branding — a product image, brand logo, or " + "closing card — drive the granular path instead: create_short + set_short_product/set_short_poster, or " + "create_editor_draft + set_product/set_logo/set_closing_image, then start_autopilot/generate_short. " + "For a SHORT, iterating after the first render is FREE: update_short (copy/style/CTA/end-card) → " + "rerender_short re-renders over the already-paid footage for 0 credits.",
|
|
31524
31829
|
inputSchema: {
|
|
31525
31830
|
prompt: exports_external.string().describe("What the ad/video should be about (min 10 chars)"),
|
|
31526
31831
|
kind: exports_external.string().optional().describe("'short' (fast, 1 clip), 'editor' (multi-scene), or 'auto' (default — inferred)"),
|
|
@@ -31529,16 +31834,17 @@ registerTool("make_video", {
|
|
|
31529
31834
|
language: exports_external.enum(LANGUAGES).optional().describe('Language (default "en"). Bare ISO-639-1 code only — no region/script subtag ' + '(use "pt", never "pt-BR"/"en-US"). Drives scenario, narration, voice, and captions.'),
|
|
31530
31835
|
aspect: exports_external.string().optional().describe("Aspect ratio for editor: 9:16 (default), 16:9, or 1:1"),
|
|
31531
31836
|
voice_id: exports_external.string().optional().describe("Preferred narration voice id for editor ads (see list_voices); shorts have no voiceover"),
|
|
31532
|
-
headline: exports_external.string().optional().describe("SHORTS only: the on-screen TITLE overlay
|
|
31837
|
+
headline: exports_external.string().max(80).optional().describe("SHORTS only: the on-screen TITLE overlay. ≈4 words; ≤40 chars hits hardest; hard cap 80 — " + "longer hooks wrap past the safe area. Set this so the short isn't bare."),
|
|
31533
31838
|
subheadline: exports_external.string().optional().describe("SHORTS only: the secondary title / supporting line (≤200)"),
|
|
31534
31839
|
text_beats: exports_external.array(exports_external.string()).optional().describe("SHORTS only: caption beats shown sequentially instead of a static subheadline (≤8, each ≤120 chars)."),
|
|
31535
31840
|
headline_color: exports_external.string().optional().describe("SHORTS only: headline hex color, e.g. #ffffff"),
|
|
31536
31841
|
subheadline_color: exports_external.string().optional().describe("SHORTS only: subheadline hex color, e.g. #ffffff"),
|
|
31537
31842
|
accent_color: exports_external.string().optional().describe("SHORTS only: 6-digit brand accent hex color, e.g. #09EFBE"),
|
|
31538
31843
|
offer_text: exports_external.string().optional().describe("SHORTS only: optional offer chip, e.g. -40% (≤16 chars)"),
|
|
31539
|
-
cta_text: exports_external.string().optional().describe("SHORTS only:
|
|
31540
|
-
badge_text: exports_external.string().optional().describe("SHORTS only: optional badge stamp, e.g. BEST SELLER (≤24 chars)"),
|
|
31541
|
-
star_rating: exports_external.number().int().min(0).max(5).optional().describe("SHORTS only: optional 0..5 star rating under the subheadline"),
|
|
31844
|
+
cta_text: exports_external.string().optional().describe("SHORTS only: CTA pill, e.g. Shop now (≤24 chars). Always set it — a short without a CTA doesn't convert."),
|
|
31845
|
+
badge_text: exports_external.string().optional().describe("SHORTS only: optional badge stamp, e.g. BEST SELLER (≤24 chars). Only badges you can " + "substantiate — fabricated social proof is deceptive advertising."),
|
|
31846
|
+
star_rating: exports_external.number().int().min(0).max(5).optional().describe("SHORTS only: optional 0..5 star rating under the subheadline. Only ratings you can " + "substantiate — fabricated social proof is deceptive advertising."),
|
|
31847
|
+
skip_auto_text: exports_external.boolean().optional().describe("SHORTS only: when headline/subheadline/text_beats are blank the server auto-writes the copy at " + "generate time; pass true to skip that and render a deliberately bare clip (default false)."),
|
|
31542
31848
|
text_position: exports_external.enum(["top", "center", "bottom"]).optional().describe("SHORTS only: title overlay position (default bottom)"),
|
|
31543
31849
|
text_animation: exports_external.enum([
|
|
31544
31850
|
"auto",
|
|
@@ -31559,7 +31865,7 @@ registerTool("make_video", {
|
|
|
31559
31865
|
save_path: exports_external.string().optional().describe("Optional .mp4 path to download to (confined to HUBFLUENCER_OUTPUT_DIR or cwd)"),
|
|
31560
31866
|
max_wait_seconds: exports_external.number().optional().describe("Block budget seconds (default 240, capped 10–280)"),
|
|
31561
31867
|
dry_run: exports_external.boolean().optional().describe("Preview only: create a free draft, price it, and STOP before spending credits. " + "Returns {estimated_credits, available_credits, slug}. Resume with generate_short / start_autopilot."),
|
|
31562
|
-
max_credits: exports_external.number().optional().describe("Spend cap: refuse to start (no charge) if the priced estimate exceeds this. Returns the estimate.")
|
|
31868
|
+
max_credits: exports_external.number().int().nonnegative().optional().describe("Spend cap: refuse to start (no charge) if the priced estimate exceeds this. Returns the estimate.")
|
|
31563
31869
|
},
|
|
31564
31870
|
outputSchema: makeVideoOutput,
|
|
31565
31871
|
annotations: { title: "Make a video", ...WRITE, idempotentHint: false }
|
|
@@ -31622,7 +31928,7 @@ registerTool("make_video", {
|
|
|
31622
31928
|
creative_format: args.creative_format,
|
|
31623
31929
|
visual_language: args.visual_language,
|
|
31624
31930
|
theme: args.theme
|
|
31625
|
-
}, idemKey("make-short", args.prompt, args.language ?? "", args.headline ?? "", args.subheadline ?? "", JSON.stringify(args.text_beats ?? []), args.headline_color ?? "", args.subheadline_color ?? "", args.accent_color ?? "", args.offer_text ?? "", args.cta_text ?? "", args.badge_text ?? "", String(args.star_rating ?? ""), args.text_position ?? "", args.text_animation ?? "", args.font_family ?? "", args.music_vibe ?? "", JSON.stringify(args.music_instruments ?? []), args.creative_format ?? "", args.visual_language ?? "", args.theme ?? ""));
|
|
31931
|
+
}, idemKey("make-short", args.prompt, args.language ?? "", args.headline ?? "", args.subheadline ?? "", JSON.stringify(args.text_beats ?? []), args.headline_color ?? "", args.subheadline_color ?? "", args.accent_color ?? "", args.offer_text ?? "", args.cta_text ?? "", args.badge_text ?? "", String(args.star_rating ?? ""), args.text_position ?? "", args.text_animation ?? "", args.font_family ?? "", args.music_vibe ?? "", JSON.stringify(args.music_instruments ?? []), args.creative_format ?? "", args.visual_language ?? "", args.theme ?? "", args.skip_auto_text ? "bare" : "auto-copy"));
|
|
31626
31932
|
slug = created.data.slug;
|
|
31627
31933
|
} else {
|
|
31628
31934
|
const created = await client.post("/editor", {
|
|
@@ -31646,11 +31952,12 @@ registerTool("make_video", {
|
|
|
31646
31952
|
estimated_credits = typeof cost.total === "number" ? cost.total : null;
|
|
31647
31953
|
available_credits = typeof cost.available_credits === "number" ? cost.available_credits : null;
|
|
31648
31954
|
} catch {}
|
|
31649
|
-
const resume = kind === "short" ? `generate_short({ slug: "${slug}" })` : `start_autopilot({ slug: "${slug}" })`;
|
|
31955
|
+
const resume = kind === "short" ? `generate_short({ slug: "${slug}"${args.skip_auto_text ? ", skip_auto_text: true" : ""} })` : `start_autopilot({ slug: "${slug}", max_credits: ${estimated_credits ?? "<approved cap>"} })`;
|
|
31650
31956
|
const affordable = available_credits === null || estimated_credits === null || available_credits >= estimated_credits;
|
|
31651
31957
|
const overCap = args.max_credits != null && estimated_credits != null && estimated_credits > args.max_credits;
|
|
31652
|
-
|
|
31653
|
-
|
|
31958
|
+
const editorPriceUnavailableWithoutCap = kind === "editor" && estimated_credits === null && args.max_credits == null;
|
|
31959
|
+
if (args.dry_run || editorPriceUnavailableWithoutCap || overCap || !affordable) {
|
|
31960
|
+
const note = args.dry_run ? `Dry run — created a free draft and priced it (${estimated_credits ?? "?"} credits, have ${available_credits ?? "?"}). To run it: ${resume}.` : editorPriceUnavailableWithoutCap ? `Pricing is temporarily unavailable, so the editor was left as a free draft and nothing was charged. Retry the quote, then run start_autopilot({ slug: "${slug}", max_credits: <approved cap> }).` : overCap ? `Estimated ${estimated_credits} credits exceeds max_credits ${args.max_credits}; nothing charged. Raise max_credits, or run ${resume}.` : `Not enough credits (needs ${estimated_credits ?? "?"}, have ${available_credits ?? "?"}); nothing charged. Top up, then run ${resume}.`;
|
|
31654
31961
|
return ok({
|
|
31655
31962
|
slug,
|
|
31656
31963
|
kind,
|
|
@@ -31668,9 +31975,13 @@ registerTool("make_video", {
|
|
|
31668
31975
|
startState = startStateDiscriminator(kind, asRecord(cur).data);
|
|
31669
31976
|
} catch {}
|
|
31670
31977
|
if (kind === "short") {
|
|
31671
|
-
await client.post(`/shorts/${slug}/generate`, undefined, `gen-short:${slug}:${startState}`);
|
|
31978
|
+
await client.post(`/shorts/${slug}/generate`, args.skip_auto_text ? { skip_auto_text: true } : undefined, `gen-short:${slug}:${startState}${args.skip_auto_text ? ":bare" : ""}`);
|
|
31672
31979
|
} else {
|
|
31673
|
-
|
|
31980
|
+
const approvedCap = args.max_credits ?? estimated_credits;
|
|
31981
|
+
if (approvedCap === null) {
|
|
31982
|
+
throw new Error("Editor autopilot launch requires max_credits or a live quote");
|
|
31983
|
+
}
|
|
31984
|
+
await client.post(`/editor/${slug}/autopilot`, { max_credits: approvedCap }, makeVideoAutopilotKey(slug, args, startState));
|
|
31674
31985
|
}
|
|
31675
31986
|
await reportProgress(extra, 0, `started ${kind} ${slug}`);
|
|
31676
31987
|
const status = await pollToTerminal(client, kind, slug, extra, budgetMs, 15000);
|
|
@@ -31754,11 +32065,11 @@ registerTool("list_projects", {
|
|
|
31754
32065
|
}));
|
|
31755
32066
|
registerTool("create_short", {
|
|
31756
32067
|
title: "Create a short (draft)",
|
|
31757
|
-
description: "Creates a short draft from a product prompt (min 10 chars). A short is a 12s vertical (two 6s AI " + "segments + an on-screen title overlay + music; 14s when
|
|
32068
|
+
description: "Creates a short draft from a product prompt (min 10 chars). A short is a 12s vertical (two 6s AI " + "segments + an on-screen title overlay + music; 14s when it ends on a poster or brand-lockup end card). " + "Returns the slug, costs 0 credits. Follow with generate_short to render. Iteration is FREE after that " + "first generate: the 15 credits bought the footage + music, and every later text/style/CTA/end-card " + "tweak re-renders over them for 0 credits (update_short → rerender_short). " + "BE PROACTIVE: don't ship a bare clip — set a headline (the on-screen TITLE) and subheadline (secondary " + "title), and pick a music_vibe plus visual_language that fit the brand. " + "Always set cta_text — a short without a CTA doesn't convert (plus offer_text only if there's a real " + "deal). Note: blank cta_text → the server renders a neutral localized ask (e.g. 'Learn more'); a truly " + "CTA-less short is not currently supported. " + "Keep headline ≈4 words (≤40 chars). Set visual_language explicitly (kinetic_creator is a strong " + "default). If headline/subheadline/text_beats are left blank, the server auto-writes the headline + " + "caption beats at generate time (pass skip_auto_text:true to generate_short for a deliberately bare clip). " + "To brand it further, attach a product image " + "(set_short_product) or an end-card poster (set_short_poster) before generate_short. (Shorts have no logo " + "overlay — that's an editor-only feature; use create_editor_ad for logo branding.)",
|
|
31758
32069
|
inputSchema: {
|
|
31759
32070
|
product_prompt: exports_external.string().min(10).describe("What the short should be about"),
|
|
31760
32071
|
language: exports_external.enum(LANGUAGES).optional().describe('Language code, e.g. "en" (default). Bare ISO-639-1 code only — no region/script subtag like "en-US".'),
|
|
31761
|
-
headline: exports_external.string().max(
|
|
32072
|
+
headline: exports_external.string().max(80).optional().describe("The on-screen TITLE composited over the short (poster text overlay). ≈4 words; ≤40 chars hits " + "hardest; hard cap 80 — longer hooks wrap past the safe area. Set this."),
|
|
31762
32073
|
subheadline: exports_external.string().max(200).optional().describe("The SECONDARY title / supporting line under the headline. ≤200 chars."),
|
|
31763
32074
|
creative_format: exports_external.enum(CREATIVE_FORMATS).optional().describe("Optional structure: problem_solution, mistake_fix, myth_vs_reality, before_after, proof_demo, product_reveal. Omit for Auto."),
|
|
31764
32075
|
visual_language: exports_external.enum(VISUAL_LANGUAGES).optional().describe("Visual language for Veo direction and render styling. Good default: kinetic_creator."),
|
|
@@ -31781,10 +32092,13 @@ registerTool("create_short", {
|
|
|
31781
32092
|
subheadline_color: exports_external.string().optional().describe("Subheadline hex color, e.g. #ffffff"),
|
|
31782
32093
|
accent_color: exports_external.string().optional().describe("6-digit brand accent hex color, e.g. #09EFBE"),
|
|
31783
32094
|
offer_text: exports_external.string().max(16).optional().describe('Optional offer chip, e.g. "-40%" or "2 FOR 1"'),
|
|
31784
|
-
cta_text: exports_external.string().max(24).optional().describe(
|
|
31785
|
-
badge_text: exports_external.string().max(24).optional().describe('Optional badge stamp, e.g. "BEST SELLER"'),
|
|
31786
|
-
star_rating: exports_external.number().int().min(0).max(5).optional().describe("Optional 0..5 star rating under the subheadline"),
|
|
32095
|
+
cta_text: exports_external.string().max(24).optional().describe(`CTA pill, e.g. "Shop now". Always set it — a short without a CTA doesn't convert.`),
|
|
32096
|
+
badge_text: exports_external.string().max(24).optional().describe('Optional badge stamp, e.g. "BEST SELLER". Only badges you can substantiate — fabricated social ' + "proof is deceptive advertising."),
|
|
32097
|
+
star_rating: exports_external.number().int().min(0).max(5).optional().describe("Optional 0..5 star rating under the subheadline. Only ratings you can substantiate — fabricated " + "social proof is deceptive advertising."),
|
|
31787
32098
|
text_beats: exports_external.array(exports_external.string().max(120)).max(8).optional().describe("Optional caption beats shown sequentially instead of the static subheadline."),
|
|
32099
|
+
closing_claim: exports_external.string().max(80).optional().describe("End-card claim shown on the closing slate (≤80 chars) — a rephrase of the promise, not a new " + "claim. Falls back to the headline when unset."),
|
|
32100
|
+
brand_name: exports_external.string().max(40).optional().describe("Brand/product display name (≤40 chars) shown big on the closing brand-lockup slate when no " + "poster is set. Server falls back to your brand profile's name when unset."),
|
|
32101
|
+
end_card: exports_external.enum(["auto", "none"]).optional().describe('"auto" (default): end on the poster, or on a brand-lockup slate (brand_name + claim + CTA pill) ' + 'when no poster is set — a 14s render. "none": no end card at all (12s, for deliberate ' + "loop-style shorts)."),
|
|
31788
32102
|
brand_profile_id: exports_external.number().int().optional().describe("Seed brand defaults — colors/font/CTA — from a saved brand profile (id from " + "list_brand_profiles). Its creative format, visual language, font, accent, and default CTA fill " + "any blank short fields and a snapshot is frozen onto the short. Must be one of your own profiles.")
|
|
31789
32103
|
},
|
|
31790
32104
|
outputSchema: createDraftOutput,
|
|
@@ -31831,23 +32145,29 @@ registerTool("create_short", {
|
|
|
31831
32145
|
body.star_rating = args.star_rating;
|
|
31832
32146
|
if (args.text_beats !== undefined)
|
|
31833
32147
|
body.text_beats = args.text_beats;
|
|
32148
|
+
if (args.closing_claim !== undefined)
|
|
32149
|
+
body.closing_claim = args.closing_claim;
|
|
32150
|
+
if (args.brand_name !== undefined)
|
|
32151
|
+
body.brand_name = args.brand_name;
|
|
32152
|
+
if (args.end_card !== undefined)
|
|
32153
|
+
body.end_card = args.end_card;
|
|
31834
32154
|
if (args.brand_profile_id !== undefined)
|
|
31835
32155
|
body.brand_profile_id = args.brand_profile_id;
|
|
31836
|
-
const res = await client.post("/shorts", body, idemKey("create-short", args.product_prompt, args.language ?? "", args.headline ?? "", args.subheadline ?? "", args.creative_format ?? "", args.visual_language ?? "", args.theme ?? "", args.music_vibe ?? "", JSON.stringify(args.music_instruments ?? []), args.text_position ?? "", args.text_animation ?? "", args.font_family ?? "", args.headline_color ?? "", args.subheadline_color ?? "", args.accent_color ?? "", args.offer_text ?? "", args.cta_text ?? "", args.badge_text ?? "", String(args.star_rating ?? ""), JSON.stringify(args.text_beats ?? []), String(args.brand_profile_id ?? "")));
|
|
32156
|
+
const res = await client.post("/shorts", body, idemKey("create-short", args.product_prompt, args.language ?? "", args.headline ?? "", args.subheadline ?? "", args.creative_format ?? "", args.visual_language ?? "", args.theme ?? "", args.music_vibe ?? "", JSON.stringify(args.music_instruments ?? []), args.text_position ?? "", args.text_animation ?? "", args.font_family ?? "", args.headline_color ?? "", args.subheadline_color ?? "", args.accent_color ?? "", args.offer_text ?? "", args.cta_text ?? "", args.badge_text ?? "", String(args.star_rating ?? ""), JSON.stringify(args.text_beats ?? []), args.closing_claim ?? "", args.brand_name ?? "", args.end_card ?? "", String(args.brand_profile_id ?? "")));
|
|
31837
32157
|
return ok({
|
|
31838
32158
|
slug: res.data.slug,
|
|
31839
32159
|
kind: "short",
|
|
31840
|
-
next: "set_short_product / set_short_poster (optional branding)
|
|
32160
|
+
next: "set_short_product / set_short_poster (optional branding); generate_hook_variations to test hook angles before you spend; then generate_short. After that first render, hook/CTA/style iteration is free: update_short → rerender_short (0 credits)."
|
|
31841
32161
|
});
|
|
31842
32162
|
}));
|
|
31843
32163
|
registerTool("update_short", {
|
|
31844
32164
|
title: "Update a short draft (0 credits)",
|
|
31845
|
-
description: "Patches an existing short draft — only the fields you pass change; omitted fields are left untouched. " + "0 credits, no re-render (
|
|
32165
|
+
description: "Patches an existing short draft — only the fields you pass change; omitted fields are left untouched. " + "0 credits, no re-render by itself. APPLYING the edit: changed only text/style/CTA/end-card fields " + "(headline, subheadline, text_beats, cta_text, offer_text, badge_text, star_rating, colors, font, " + "position, animation, closing_claim, brand_name, poster_includes_lockup, end_card) on an " + "already-generated short? " + "rerender_short applies them for 0 credits. Footage/music-affecting edits (product_prompt, product " + "image, creative_format, visual_language, theme, music_vibe, music_instruments, language) need " + "generate_short (15 credits) again. Use this to edit the brief, copy, " + "styling, or the optional conversion graphics after create_short. " + "Always set cta_text — a short without a CTA doesn't convert (plus offer_text only if there's a real " + "deal). Note: blank cta_text → the server renders a neutral localized ask (e.g. 'Learn more'); a truly " + "CTA-less short is not currently supported. " + "Keep headline ≈4 words (≤40 chars). Set visual_language explicitly (kinetic_creator is a strong " + "default). " + "CLEARING a field: the conversion graphics (offer_text, cta_text, badge_text, star_rating) and " + 'text_beats are OPT-IN and OFF by default — pass "" (or 0 for star_rating, [] for text_beats) to REMOVE ' + "one that was set. The four conversion graphics are user-supplied only and never written by the AI copy " + "assist; text_beats (like headline/subheadline) IS rewritten by generate_short_text, so hand-crafted " + "beats don't survive a later assist call. " + "404 for an unknown/foreign slug.",
|
|
31846
32166
|
inputSchema: {
|
|
31847
32167
|
slug: exports_external.string().describe("Short slug (from create_short)"),
|
|
31848
32168
|
product_prompt: exports_external.string().min(10).optional().describe("Rewrite the brief (what the short is about, ≥10 chars); drives the next generate_short."),
|
|
31849
32169
|
language: exports_external.enum(LANGUAGES).optional().describe('Language code, e.g. "en". Bare ISO-639-1 code only — no region/script subtag like "en-US".'),
|
|
31850
|
-
headline: exports_external.string().max(
|
|
32170
|
+
headline: exports_external.string().max(80).optional().describe("On-screen TITLE overlay. ≈4 words; ≤40 chars hits hardest; hard cap 80 — longer hooks wrap " + "past the safe area."),
|
|
31851
32171
|
subheadline: exports_external.string().max(200).optional().describe("Secondary title / supporting line. ≤200 chars."),
|
|
31852
32172
|
creative_format: exports_external.enum(CREATIVE_FORMATS).optional().describe("Structure: problem_solution, mistake_fix, myth_vs_reality, before_after, proof_demo, product_reveal."),
|
|
31853
32173
|
visual_language: exports_external.enum(VISUAL_LANGUAGES).optional().describe("Visual language for Veo direction and render styling."),
|
|
@@ -31870,10 +32190,14 @@ registerTool("update_short", {
|
|
|
31870
32190
|
subheadline_color: exports_external.string().optional().describe("Subheadline hex color, e.g. #ffffff"),
|
|
31871
32191
|
accent_color: exports_external.string().optional().describe("6-digit brand accent hex color, e.g. #09EFBE"),
|
|
31872
32192
|
offer_text: exports_external.string().max(16).optional().describe('Offer chip (opt-in), e.g. "-40%". Pass "" to remove it.'),
|
|
31873
|
-
cta_text: exports_external.string().max(24).optional().describe(
|
|
31874
|
-
badge_text: exports_external.string().max(24).optional().describe('Badge stamp (opt-in), e.g. "BEST SELLER". Pass "" to remove it.'),
|
|
31875
|
-
star_rating: exports_external.number().int().min(0).max(5).optional().describe("0..5 star rating under the subheadline (opt-in). Pass 0 to remove it."),
|
|
32193
|
+
cta_text: exports_external.string().max(24).optional().describe(`CTA pill, e.g. "Shop now". Always set it — a short without a CTA doesn't convert. Pass "" to remove it.`),
|
|
32194
|
+
badge_text: exports_external.string().max(24).optional().describe('Badge stamp (opt-in), e.g. "BEST SELLER". Only badges you can substantiate — fabricated social ' + 'proof is deceptive advertising. Pass "" to remove it.'),
|
|
32195
|
+
star_rating: exports_external.number().int().min(0).max(5).optional().describe("0..5 star rating under the subheadline (opt-in). Only ratings you can substantiate — fabricated " + "social proof is deceptive advertising. Pass 0 to remove it."),
|
|
31876
32196
|
text_beats: exports_external.array(exports_external.string().max(120)).max(8).optional().describe("Caption beats shown sequentially instead of the static subheadline. Pass [] to clear."),
|
|
32197
|
+
closing_claim: exports_external.string().max(80).optional().describe("End-card claim shown on the closing slate (≤80 chars) — a rephrase of the promise, not a new " + 'claim. Falls back to the headline when unset. Pass "" to remove it.'),
|
|
32198
|
+
brand_name: exports_external.string().max(40).optional().describe("Brand/product display name (≤40 chars) shown big on the closing brand-lockup slate when no " + `poster is set. Server falls back to your brand profile's name when unset. Pass "" to remove it.`),
|
|
32199
|
+
end_card: exports_external.enum(["auto", "none"]).optional().describe('"auto" (default): end on the poster, or on a brand-lockup slate (brand_name + claim + CTA pill) ' + 'when no poster is set — a 14s render. "none": no end card at all (12s, for deliberate ' + "loop-style shorts)."),
|
|
32200
|
+
poster_includes_lockup: exports_external.boolean().optional().describe("Set true if your poster already contains brand name/CTA/badges — suppresses the overlaid " + "claim/CTA so they don't collide (default false)."),
|
|
31877
32201
|
brand_profile_id: exports_external.number().int().optional().describe("Seed brand defaults — colors/font/CTA — from a saved brand profile (id from " + "list_brand_profiles). Best set at create_short time (that path also freezes a brand snapshot); " + "on update it sets the linked profile. Must be one of your own profiles.")
|
|
31878
32202
|
},
|
|
31879
32203
|
annotations: {
|
|
@@ -31923,10 +32247,22 @@ registerTool("update_short", {
|
|
|
31923
32247
|
body.star_rating = args.star_rating;
|
|
31924
32248
|
if (args.text_beats !== undefined)
|
|
31925
32249
|
body.text_beats = args.text_beats;
|
|
32250
|
+
if (args.closing_claim !== undefined)
|
|
32251
|
+
body.closing_claim = args.closing_claim;
|
|
32252
|
+
if (args.brand_name !== undefined)
|
|
32253
|
+
body.brand_name = args.brand_name;
|
|
32254
|
+
if (args.end_card !== undefined)
|
|
32255
|
+
body.end_card = args.end_card;
|
|
32256
|
+
if (args.poster_includes_lockup !== undefined)
|
|
32257
|
+
body.poster_includes_lockup = args.poster_includes_lockup;
|
|
31926
32258
|
if (args.brand_profile_id !== undefined)
|
|
31927
32259
|
body.brand_profile_id = args.brand_profile_id;
|
|
31928
32260
|
const res = await client.patch(`/shorts/${args.slug}`, body);
|
|
31929
|
-
|
|
32261
|
+
const data = asRecord(res).data ?? res;
|
|
32262
|
+
return ok({
|
|
32263
|
+
...asRecord(data),
|
|
32264
|
+
next: "Saved. Only text/style/CTA/end-card changed on an already-generated short? " + `rerender_short({ slug: "${args.slug}" }) applies it for 0 credits. Footage/music edits ` + "(prompt, product image, format, visual_language, theme, music_*, language) need " + "generate_short (15 credits)."
|
|
32265
|
+
});
|
|
31930
32266
|
}));
|
|
31931
32267
|
async function getTracking(client, slug, deadline) {
|
|
31932
32268
|
const res = await client.get(`/tracking/${slug}`, undefined, deadline);
|
|
@@ -32066,12 +32402,15 @@ registerTool("create_tracking_video", {
|
|
|
32066
32402
|
}));
|
|
32067
32403
|
registerTool("generate_short", {
|
|
32068
32404
|
title: "Generate (render) a short",
|
|
32069
|
-
description: "Deducts 15 credits and starts the render pipeline for an existing short. Safe to call again: a " + "duplicate while a render is in flight is reported as in-progress (no double charge), and a failed " + "short can be re-generated. Then poll with get_status or wait_for_completion (kind=short).",
|
|
32070
|
-
inputSchema: {
|
|
32405
|
+
description: "Deducts 15 credits and starts the render pipeline for an existing short — this RE-ROLLS the footage " + "and music (a fresh AI generation). For text/style/CTA/end-card tweaks on an already-generated short, " + "use rerender_short instead: it re-applies the overlay over the existing footage for 0 credits. " + "Safe to call again: a " + "duplicate while a render is in flight is reported as in-progress (no double charge), and a failed " + "short can be re-generated. If the draft's headline/subheadline/text_beats are all blank the server " + "auto-writes the headline + caption beats before rendering (pass skip_auto_text:true for a deliberately " + "bare clip). Then poll with get_status or wait_for_completion (kind=short).",
|
|
32406
|
+
inputSchema: {
|
|
32407
|
+
slug: exports_external.string().describe("Short slug from create_short"),
|
|
32408
|
+
skip_auto_text: exports_external.boolean().optional().describe("When the draft has no headline/subheadline/text_beats the server auto-writes the copy; pass " + "true to skip that and render a deliberately bare clip (default false).")
|
|
32409
|
+
},
|
|
32071
32410
|
annotations: { title: "Generate short", ...WRITE }
|
|
32072
32411
|
}, tool(async (args, client) => {
|
|
32073
32412
|
try {
|
|
32074
|
-
const res = await client.post(`/shorts/${args.slug}/generate
|
|
32413
|
+
const res = await client.post(`/shorts/${args.slug}/generate`, args.skip_auto_text ? { skip_auto_text: true } : undefined);
|
|
32075
32414
|
const status = normalizeStatus("short", args.slug, asRecord(res).data);
|
|
32076
32415
|
return ok(status);
|
|
32077
32416
|
} catch (e) {
|
|
@@ -32089,6 +32428,44 @@ registerTool("generate_short", {
|
|
|
32089
32428
|
throw e;
|
|
32090
32429
|
}
|
|
32091
32430
|
}));
|
|
32431
|
+
registerTool("rerender_short", {
|
|
32432
|
+
title: "Re-render a short (FREE — 0 credits)",
|
|
32433
|
+
description: "FREE re-render: applies the short's current text/style/CTA/end-card state over the already-paid " + "footage and music — 0 credits. Use after update_short for copy/style/CTA tweaks. Footage or music " + "changes need generate_short (15 credits) instead. Edits that add/remove the end card (poster, " + "end_card) can shift the video between 12s and 14s; when the pinned music bed is shorter than the " + "export it crossfades into a repeated continuation, then fades at the export end, without regeneration. " + "Requires a completed generation to re-render over " + "(422 short_not_ready otherwise — run generate_short once first); while any generation/render is in " + "flight it reports in-progress (409) rather than duplicating. Then poll with get_status or " + "wait_for_completion (kind=short). A failed free re-render leaves the delivered video intact — retry " + "rerender_short, it stays free (the status fields latest_render_free / last_free_rerender_failed " + "report this when the server sends them).",
|
|
32434
|
+
inputSchema: {
|
|
32435
|
+
slug: exports_external.string().describe("Short slug (from create_short)")
|
|
32436
|
+
},
|
|
32437
|
+
annotations: { title: "Re-render short", ...WRITE, idempotentHint: false }
|
|
32438
|
+
}, tool(async (args, client) => {
|
|
32439
|
+
try {
|
|
32440
|
+
const res = await client.post(`/shorts/${args.slug}/rerender`, undefined, idemKey("rerender-short", args.slug, randomUUID()));
|
|
32441
|
+
const status = normalizeStatus("short", args.slug, asRecord(res).data);
|
|
32442
|
+
return ok({
|
|
32443
|
+
...status,
|
|
32444
|
+
next: `wait_for_completion({ slug: "${args.slug}", kind: "short" }) — or poll get_status. A re-render is fast (no AI generation).`
|
|
32445
|
+
});
|
|
32446
|
+
} catch (e) {
|
|
32447
|
+
const he = e;
|
|
32448
|
+
if (he.status === 409) {
|
|
32449
|
+
return ok({
|
|
32450
|
+
kind: "short",
|
|
32451
|
+
slug: args.slug,
|
|
32452
|
+
stage: "processing",
|
|
32453
|
+
terminal: false,
|
|
32454
|
+
ready: false,
|
|
32455
|
+
video_url: null,
|
|
32456
|
+
error: null,
|
|
32457
|
+
next: `A render is already running — wait_for_completion({ slug: "${args.slug}", kind: "short" }).`
|
|
32458
|
+
});
|
|
32459
|
+
}
|
|
32460
|
+
if (he.status === 422 && he.code === "short_not_ready") {
|
|
32461
|
+
return fail("This short has no completed footage + music to re-render over (short_not_ready). " + `Run generate_short({ slug: "${args.slug}" }) once (15 credits) — after that first render, every ` + "text/style/CTA/end-card tweak re-renders free with rerender_short.");
|
|
32462
|
+
}
|
|
32463
|
+
if (he.status === 422 && he.code === "short_paid_regeneration_required") {
|
|
32464
|
+
return fail("A footage or music input changed after the last paid render " + "(short_paid_regeneration_required). The pinned assets no longer match the draft. " + `Run generate_short({ slug: "${args.slug}" }) (15 credits) before using free re-renders again.`);
|
|
32465
|
+
}
|
|
32466
|
+
throw e;
|
|
32467
|
+
}
|
|
32468
|
+
}));
|
|
32092
32469
|
registerTool("generate_short_text", {
|
|
32093
32470
|
title: "Generate short overlay text (AI assist)",
|
|
32094
32471
|
description: "Generates editable headline, subheadline, and caption beats for an existing short draft. " + "CONSUMES 1 AI ASSIST (free daily quota) and spends no video credits. The server reads the saved " + "short fields, so update/create the draft with the current product prompt, creative_format, visual_language, " + "language, and seed copy first. On 429 set auto_unlock:true (1 credit -> +10 assists, retried once) or write the text yourself.",
|
|
@@ -32293,8 +32670,8 @@ registerTool("edit_slider_slide", {
|
|
|
32293
32670
|
}
|
|
32294
32671
|
}));
|
|
32295
32672
|
registerTool("create_editor_ad", {
|
|
32296
|
-
title: "Create a multi-scene editor ad
|
|
32297
|
-
description: "Creates an editor project
|
|
32673
|
+
title: "Create, configure, and price a multi-scene editor ad",
|
|
32674
|
+
description: "Creates an editor project, attaches any supplied local product/logo/closing assets BEFORE generation, " + "and prices autopilot (server-orchestrated " + "scenario → segments → narration → voice → music → render). Each AI-generated scene is a fixed 8 seconds. " + "Without max_credits it STOPS after the free draft/configuration and returns the live estimate. It starts " + "the paid pipeline only when max_credits is supplied and covers the estimate. Then poll with get_status / " + "wait_for_completion (kind=editor). Prefer make_video for a prompt-only one-shot. " + "Supplying assets here is safer than adding them after autopilot because the scenario and scenes are grounded " + "in the real product from the start. (Editor ads carry their copy in-scene/narration, not as a " + "title overlay; for an on-screen title/subtitle use a short with headline/subheadline instead.) " + "For a mascot or hero product that must stay visually identical with no human presenter, set " + "cast_mode:'product_only' together with product_image_path. Presenter-led casting still belongs on " + "create_editor_draft because it requires a separately configured presenter.",
|
|
32298
32675
|
inputSchema: {
|
|
32299
32676
|
product_prompt: exports_external.string().min(10).describe("Brief for the ad (min 10 chars)"),
|
|
32300
32677
|
product_subject: exports_external.string().max(300).optional().describe('The concrete product or main subject the video must be about (e.g. "Bordeaux red wine, dark green ' + 'bottle with a gold label"). Distinct from the freeform brief: hard-grounds the AI scenario + narration ' + "so it cannot invent an unrelated product. Strongly recommended for ads."),
|
|
@@ -32304,42 +32681,139 @@ registerTool("create_editor_ad", {
|
|
|
32304
32681
|
visual_language: exports_external.enum(VISUAL_LANGUAGES).optional().describe("Visual style direction and render look. Good default: kinetic_creator. When set it drives the look and a theme of 'none' is ignored."),
|
|
32305
32682
|
theme: exports_external.string().optional().describe('Visual theme / genre overlay (default "realistic"). One of: none (no imposed style — segments follow your prompts literally), realistic, cinematic, anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, gaming, retro_80s, minimalist, cyberpunk.'),
|
|
32306
32683
|
voice_id: exports_external.string().optional().describe("Preferred narration voice id (see list_voices); omit for the default voice"),
|
|
32307
|
-
export_aspect_ratio: exports_external.enum(["9:16", "16:9", "1:1"]).optional().describe('Aspect ratio (default "9:16")')
|
|
32684
|
+
export_aspect_ratio: exports_external.enum(["9:16", "16:9", "1:1"]).optional().describe('Aspect ratio (default "9:16")'),
|
|
32685
|
+
product_image_path: exports_external.string().optional().describe("Local product image (.jpg/.jpeg/.png, ≤8 MiB) to attach before autopilot."),
|
|
32686
|
+
product_description: exports_external.string().max(500).optional().describe("Exact product/character description used with product_image_path."),
|
|
32687
|
+
closing_image_path: exports_external.string().optional().describe("Local closing-card image (.jpg/.jpeg/.png, ≤20 MiB)."),
|
|
32688
|
+
logo_path: exports_external.string().optional().describe("Local logo (.jpg/.jpeg/.png, ≤1 MiB) to overlay."),
|
|
32689
|
+
logo_treatment: exports_external.enum(["intro", "outro", "both", "none"]).optional().describe('When the logo bumper plays: "intro", "outro", "both" (default), or "none". Requires logo_path.'),
|
|
32690
|
+
logo_position: exports_external.enum(["top-left", "top-right", "bottom-left", "bottom-right"]).optional().describe('Logo corner: "top-left", "top-right" (default), "bottom-left", or "bottom-right". Requires logo_path.'),
|
|
32691
|
+
logo_duration_seconds: exports_external.number().min(0.5).max(3).optional().describe("Logo bumper duration in seconds, 0.5–3 (default 1.5). Requires logo_path."),
|
|
32692
|
+
cast_mode: exports_external.literal("product_only").optional().describe("Lock every AI scene to the supplied product/mascot reference with no people. Requires product_image_path."),
|
|
32693
|
+
max_credits: exports_external.number().int().nonnegative().optional().describe("Explicit spend cap. Omit to create/configure/price only; no credits are charged.")
|
|
32308
32694
|
},
|
|
32309
32695
|
outputSchema: createEditorOutput,
|
|
32310
32696
|
annotations: { title: "Create editor ad", ...WRITE, idempotentHint: true }
|
|
32311
32697
|
}, tool(async (args, client) => {
|
|
32312
|
-
const
|
|
32313
|
-
|
|
32314
|
-
|
|
32315
|
-
|
|
32316
|
-
|
|
32317
|
-
|
|
32318
|
-
|
|
32319
|
-
|
|
32320
|
-
|
|
32321
|
-
|
|
32322
|
-
|
|
32323
|
-
|
|
32324
|
-
|
|
32325
|
-
|
|
32326
|
-
|
|
32327
|
-
|
|
32328
|
-
|
|
32329
|
-
|
|
32330
|
-
|
|
32331
|
-
|
|
32332
|
-
|
|
32698
|
+
const prepared = [];
|
|
32699
|
+
let productImage;
|
|
32700
|
+
let closingImage;
|
|
32701
|
+
let logo;
|
|
32702
|
+
try {
|
|
32703
|
+
const langErr = validateLanguage(args.language);
|
|
32704
|
+
if (langErr)
|
|
32705
|
+
return fail(langErr);
|
|
32706
|
+
const promptErr = validateProductPrompt(args.product_prompt);
|
|
32707
|
+
if (promptErr)
|
|
32708
|
+
return fail(promptErr);
|
|
32709
|
+
if (args.cast_mode === "product_only" && !args.product_image_path) {
|
|
32710
|
+
return fail("cast_mode product_only requires product_image_path so identity can be locked before generation.");
|
|
32711
|
+
}
|
|
32712
|
+
if ((args.logo_treatment !== undefined || args.logo_position !== undefined || args.logo_duration_seconds !== undefined) && !args.logo_path) {
|
|
32713
|
+
return fail("logo_treatment/logo_position/logo_duration_seconds require logo_path (the logo image they style). Provide logo_path or drop these settings.");
|
|
32714
|
+
}
|
|
32715
|
+
if (args.product_description !== undefined && !args.product_image_path) {
|
|
32716
|
+
return fail("product_description requires product_image_path (the product image it describes). Provide product_image_path or drop product_description.");
|
|
32717
|
+
}
|
|
32718
|
+
if (args.product_image_path) {
|
|
32719
|
+
productImage = await prepareImageUpload(args.product_image_path, MAX_PRODUCT_IMAGE_BYTES);
|
|
32720
|
+
prepared.push(productImage);
|
|
32721
|
+
}
|
|
32722
|
+
if (args.closing_image_path) {
|
|
32723
|
+
closingImage = await prepareImageUpload(args.closing_image_path, MAX_CLOSING_IMAGE_BYTES);
|
|
32724
|
+
prepared.push(closingImage);
|
|
32725
|
+
}
|
|
32726
|
+
if (args.logo_path) {
|
|
32727
|
+
logo = await prepareImageUpload(args.logo_path, MAX_LOGO_BYTES);
|
|
32728
|
+
prepared.push(logo);
|
|
32729
|
+
}
|
|
32730
|
+
const created = await client.post("/editor", {
|
|
32731
|
+
language: args.language ?? "en",
|
|
32732
|
+
product_prompt: args.product_prompt,
|
|
32733
|
+
product_subject: args.product_subject,
|
|
32734
|
+
project_intent: args.project_intent,
|
|
32735
|
+
creative_format: args.creative_format,
|
|
32736
|
+
visual_language: args.visual_language,
|
|
32737
|
+
theme: args.theme,
|
|
32738
|
+
voice_id: args.voice_id,
|
|
32739
|
+
export_aspect_ratio: args.export_aspect_ratio,
|
|
32740
|
+
cast_mode: args.cast_mode
|
|
32741
|
+
}, editorAdCreateKey({
|
|
32742
|
+
...args,
|
|
32743
|
+
product_image_identity: productImage ? preparedUploadIdentity(productImage) : undefined,
|
|
32744
|
+
closing_image_identity: closingImage ? preparedUploadIdentity(closingImage) : undefined,
|
|
32745
|
+
logo_identity: logo ? preparedUploadIdentity(logo) : undefined
|
|
32746
|
+
}));
|
|
32747
|
+
const slug = created.data.slug;
|
|
32748
|
+
let configured = {};
|
|
32749
|
+
if (args.product_image_path || args.closing_image_path || args.logo_path) {
|
|
32750
|
+
const current = await client.get(`/editor/${slug}`);
|
|
32751
|
+
configured = asRecord(asRecord(current).data);
|
|
32752
|
+
}
|
|
32753
|
+
if (productImage && !configured.product_url) {
|
|
32754
|
+
const { s3_key } = await uploadImageFile(client, `/editor/${slug}/product/presign`, productImage, MAX_PRODUCT_IMAGE_BYTES);
|
|
32755
|
+
await client.post(`/editor/${slug}/product/confirm`, {
|
|
32756
|
+
s3_key,
|
|
32757
|
+
product_description: args.product_description
|
|
32758
|
+
});
|
|
32759
|
+
}
|
|
32760
|
+
if (closingImage && !configured.closing_image_url) {
|
|
32761
|
+
const { s3_key } = await uploadImageFile(client, `/editor/${slug}/closing-image/presign`, closingImage, MAX_CLOSING_IMAGE_BYTES);
|
|
32762
|
+
await client.post(`/editor/${slug}/closing-image/confirm`, {
|
|
32763
|
+
s3_key
|
|
32764
|
+
});
|
|
32765
|
+
}
|
|
32766
|
+
if (logo && !configured.logo_url) {
|
|
32767
|
+
const { s3_key } = await uploadImageFile(client, `/editor/${slug}/logo/presign`, logo, MAX_LOGO_BYTES);
|
|
32768
|
+
await client.post(`/editor/${slug}/logo/confirm`, { s3_key });
|
|
32769
|
+
}
|
|
32770
|
+
const logoSettings = {};
|
|
32771
|
+
if (args.logo_treatment !== undefined)
|
|
32772
|
+
logoSettings.logo_treatment = args.logo_treatment;
|
|
32773
|
+
if (args.logo_position !== undefined)
|
|
32774
|
+
logoSettings.logo_position = args.logo_position;
|
|
32775
|
+
if (args.logo_duration_seconds !== undefined)
|
|
32776
|
+
logoSettings.logo_duration_seconds = args.logo_duration_seconds;
|
|
32777
|
+
if (args.logo_path && Object.keys(logoSettings).length > 0)
|
|
32778
|
+
await client.patch(`/editor/${slug}/logo`, logoSettings);
|
|
32779
|
+
const quote = await editorAutopilotQuote(client, slug);
|
|
32780
|
+
const overCap = args.max_credits !== undefined && quote.estimated_credits !== null && quote.estimated_credits > args.max_credits;
|
|
32781
|
+
if (args.max_credits === undefined || quote.estimated_credits === null || overCap || !quote.affordable) {
|
|
32782
|
+
const note = args.max_credits === undefined ? `Free draft configured. Estimated ${quote.estimated_credits ?? "?"} credits (have ${quote.available_credits ?? "?"}); nothing charged. Relaunch with start_autopilot({ slug: "${slug}", max_credits: ${quote.estimated_credits ?? "<approved cap>"} }).` : quote.estimated_credits === null ? "Pricing is temporarily unavailable, so the paid pipeline was not started. Retry the quote later." : overCap ? `Estimated ${quote.estimated_credits} credits exceeds max_credits ${args.max_credits}; nothing charged.` : `Not enough credits (needs ${quote.estimated_credits}, have ${quote.available_credits ?? "?"}); nothing charged.`;
|
|
32783
|
+
return ok({
|
|
32784
|
+
slug,
|
|
32785
|
+
kind: "editor",
|
|
32786
|
+
...quote,
|
|
32787
|
+
charged: false,
|
|
32788
|
+
note,
|
|
32789
|
+
next: "start_autopilot"
|
|
32790
|
+
});
|
|
32791
|
+
}
|
|
32792
|
+
const started = await client.post(`/editor/${slug}/autopilot`, { max_credits: args.max_credits }, editorAdAutopilotKey(slug, args));
|
|
32793
|
+
const status = normalizeStatus("editor", slug, asRecord(started).data);
|
|
32794
|
+
return ok({
|
|
32795
|
+
slug,
|
|
32796
|
+
kind: "editor",
|
|
32797
|
+
status,
|
|
32798
|
+
...quote,
|
|
32799
|
+
charged: true,
|
|
32800
|
+
next: "wait_for_completion"
|
|
32801
|
+
});
|
|
32802
|
+
} finally {
|
|
32803
|
+
await Promise.all(prepared.map(closePreparedUploadFile));
|
|
32804
|
+
}
|
|
32333
32805
|
}));
|
|
32334
32806
|
registerTool("start_autopilot", {
|
|
32335
32807
|
title: "Start autopilot on an existing editor draft",
|
|
32336
|
-
description: "Starts server-orchestrated autopilot (scenario → segments → narration → voice → music → render) on an " + "EXISTING editor project/draft.
|
|
32808
|
+
description: "Starts server-orchestrated autopilot (scenario → segments → narration → voice → music → render) on an " + "EXISTING editor project/draft. Without max_credits it returns the live quote and charges nothing. Supply an " + "explicit max_credits cap to authorize launch; the tool refuses if pricing is unavailable, the estimate exceeds " + "the cap, or the balance is insufficient. Each authorized call is a genuine start attempt (like tapping Launch in the app): " + "if a run is already in progress for this slug the server returns already_running — it never double-starts " + "or double-charges. Once finished, failed, or cancelled, calling again relaunches it (resume after a top-up, raising your spend cap, or a mid-pipeline failure). Then poll with " + 'wait_for_completion({ slug, kind: "editor" }).',
|
|
32337
32809
|
inputSchema: {
|
|
32338
32810
|
slug: exports_external.string().describe("Editor project slug"),
|
|
32339
32811
|
language: exports_external.enum(LANGUAGES).optional().describe('Optional language override applied before launch. Bare ISO-639-1 code only, e.g. "fr" — no ' + "region/script subtag. Drives scenario, narration, voice, and captions."),
|
|
32340
32812
|
product_subject: exports_external.string().max(300).optional().describe("Optional locked product / main subject applied before launch so the AI cannot invent an " + "unrelated product."),
|
|
32341
|
-
product_prompt: exports_external.string().optional().describe("Optional freeform brief to set/replace before launch.")
|
|
32813
|
+
product_prompt: exports_external.string().optional().describe("Optional freeform brief to set/replace before launch."),
|
|
32814
|
+
max_credits: exports_external.number().int().nonnegative().optional().describe("Explicit spend cap. Omit to quote only; no credits are charged.")
|
|
32342
32815
|
},
|
|
32816
|
+
outputSchema: createEditorOutput,
|
|
32343
32817
|
annotations: { title: "Start autopilot", ...WRITE, idempotentHint: false }
|
|
32344
32818
|
}, tool(async (args, client) => {
|
|
32345
32819
|
const langErr = validateLanguage(args.language);
|
|
@@ -32356,12 +32830,25 @@ registerTool("start_autopilot", {
|
|
|
32356
32830
|
overrides.product_subject = args.product_subject;
|
|
32357
32831
|
if (args.product_prompt !== undefined)
|
|
32358
32832
|
overrides.product_prompt = args.product_prompt;
|
|
32359
|
-
const
|
|
32833
|
+
const quote = await editorAutopilotQuote(client, slug, overrides);
|
|
32834
|
+
const overCap = args.max_credits !== undefined && quote.estimated_credits !== null && quote.estimated_credits > args.max_credits;
|
|
32835
|
+
if (args.max_credits === undefined || quote.estimated_credits === null || overCap || !quote.affordable) {
|
|
32836
|
+
return ok({
|
|
32837
|
+
slug,
|
|
32838
|
+
kind: "editor",
|
|
32839
|
+
...quote,
|
|
32840
|
+
charged: false,
|
|
32841
|
+
note: args.max_credits === undefined ? `Quote only: estimated ${quote.estimated_credits ?? "?"} credits (have ${quote.available_credits ?? "?"}). Pass max_credits to authorize launch.` : quote.estimated_credits === null ? "Pricing is unavailable; launch refused because the spend cap cannot be enforced." : overCap ? `Estimated ${quote.estimated_credits} credits exceeds max_credits ${args.max_credits}; nothing charged.` : `Not enough credits (needs ${quote.estimated_credits}, have ${quote.available_credits ?? "?"}); nothing charged.`
|
|
32842
|
+
});
|
|
32843
|
+
}
|
|
32844
|
+
const started = await client.post(`/editor/${slug}/autopilot`, { ...overrides, max_credits: args.max_credits }, idemKey("autopilot", slug, randomUUID()));
|
|
32360
32845
|
const status = normalizeStatus("editor", slug, asRecord(started).data);
|
|
32361
32846
|
return ok({
|
|
32362
32847
|
slug,
|
|
32363
32848
|
kind: "editor",
|
|
32364
32849
|
status,
|
|
32850
|
+
...quote,
|
|
32851
|
+
charged: true,
|
|
32365
32852
|
next: "wait_for_completion"
|
|
32366
32853
|
});
|
|
32367
32854
|
}));
|
|
@@ -32383,7 +32870,7 @@ registerTool("unlock_ai_assists", {
|
|
|
32383
32870
|
}, tool(async (_args, client) => ok(await client.post("/ai-assists/unlock", undefined, await unlockKey(client)))));
|
|
32384
32871
|
registerTool("create_editor_draft", {
|
|
32385
32872
|
title: "Create an editor draft (no autopilot)",
|
|
32386
|
-
description: "Creates an editor project from a product prompt and stops (NO autopilot). Costs 0 credits. Returns the " + "slug. Use this to drive the pipeline step by step (vs create_editor_ad which
|
|
32873
|
+
description: "Creates an editor project from a product prompt and stops (NO autopilot). Costs 0 credits. Returns the " + "slug. Use this to drive the pipeline step by step (vs create_editor_ad which configures, quotes, and can launch autopilot with a spend cap). " + "Each AI-generated scene renders to a fixed 8 seconds (uploaded clips keep their own length). " + "product_prompt is optional content: send 10–5000 chars, or omit it entirely (empty is treated as omit).",
|
|
32387
32874
|
inputSchema: {
|
|
32388
32875
|
product_prompt: exports_external.string().min(10).max(5000).optional().describe("Brief for the ad — 10–5000 chars, or omit entirely"),
|
|
32389
32876
|
product_subject: exports_external.string().max(300).optional().describe("The concrete product / main subject the video must be about. Hard-grounds the AI so it cannot " + "invent an unrelated product. Recommended for ads."),
|
|
@@ -32885,7 +33372,7 @@ async function pollUploadToReady(client, slug, uploadId, extra, budgetMs, interv
|
|
|
32885
33372
|
}
|
|
32886
33373
|
registerTool("upload_video", {
|
|
32887
33374
|
title: "Upload a local video clip into an editor project",
|
|
32888
|
-
description: "Uploads a local video FILE (your own footage) into an editor project and, by default, adds it to the timeline " + "as a finished scene. Unlike an AI-generated scene (a fixed 8 seconds), an uploaded clip keeps its own native " + "duration. Runs presign → PUT (resumable multipart for large files) → confirm, then polls until the " + "upload is processed (ready). file_path is a LOCAL path confined to HUBFLUENCER_INPUT_DIR (or cwd). Accepted: " + VIDEO_EXTS.map((e) => `.${e}`).join(", ") + " (≤500 MB, ≤5 min). Costs 0 credits. If add_to_timeline is false (default true) the clip is uploaded but not " + "placed — call add_segment_from_upload later.",
|
|
33375
|
+
description: "Uploads a local video FILE (your own footage) into an editor project and, by default, adds it to the timeline " + "as a finished scene. Unlike an AI-generated scene (a fixed 8 seconds), an uploaded clip keeps its own native " + "duration. Runs presign → PUT (resumable multipart for large files) → confirm, then polls until the " + "upload is processed (ready). file_path is a LOCAL path confined to HUBFLUENCER_INPUT_DIR (or cwd). Accepted: " + VIDEO_EXTS.map((e) => `.${e}`).join(", ") + " (≤500 MB decimal = 500,000,000 bytes, ≤5 min). Costs 0 credits. If add_to_timeline is false (default true) the clip is uploaded but not " + "placed — call add_segment_from_upload later.",
|
|
32889
33376
|
inputSchema: {
|
|
32890
33377
|
slug: exports_external.string().describe("Editor project slug"),
|
|
32891
33378
|
file_path: exports_external.string().describe("Local video file path (inside HUBFLUENCER_INPUT_DIR or cwd)"),
|
|
@@ -32958,7 +33445,7 @@ registerTool("add_segment_from_upload", {
|
|
|
32958
33445
|
}));
|
|
32959
33446
|
registerTool("use_asset_for_segment", {
|
|
32960
33447
|
title: "Reuse a clip/asset for a specific scene (0 credits)",
|
|
32961
|
-
description: "Sets a specific scene's video from an existing asset — either a ready upload (upload_id) OR a completed scene " + "in the same project (source_segment_id). Provide EXACTLY ONE. 0 credits. Use it to reuse one uploaded clip " + "across scenes, or to swap a generated scene for your own footage.",
|
|
33448
|
+
description: "Sets a specific scene's video from an existing asset — either a ready upload (upload_id) OR a completed scene " + "in the same project (source_segment_id). Provide EXACTLY ONE. 0 credits. Use it to reuse one uploaded clip " + "across scenes, or to swap a generated scene for your own footage. The response includes continuity impact and " + "current narration/voice/music staleness. If the replacement duration differs, regenerate stale audio before render.",
|
|
32962
33449
|
inputSchema: {
|
|
32963
33450
|
slug: exports_external.string().describe("Editor project slug"),
|
|
32964
33451
|
segment_id: exports_external.union([exports_external.number(), exports_external.string()]).describe("Target scene id (from get_editor)"),
|
|
@@ -32978,11 +33465,40 @@ registerTool("use_asset_for_segment", {
|
|
|
32978
33465
|
}
|
|
32979
33466
|
const body = hasUpload ? { upload_id: args.upload_id } : { source_segment_id: args.source_segment_id };
|
|
32980
33467
|
const res = await client.post(`/editor/${args.slug}/segments/${String(args.segment_id)}/use-asset`, body);
|
|
32981
|
-
|
|
33468
|
+
const segment = asRecord(asRecord(res).data ?? res);
|
|
33469
|
+
const impact = asRecord(segment.impact);
|
|
33470
|
+
const staleSegmentIds = Array.isArray(impact.stale_video_segment_ids) ? impact.stale_video_segment_ids : [];
|
|
33471
|
+
const requiresRegeneration = impact.requires_regeneration === true || staleSegmentIds.length > 0;
|
|
33472
|
+
let timeline;
|
|
33473
|
+
try {
|
|
33474
|
+
const current = await client.get(`/editor/${args.slug}`);
|
|
33475
|
+
const editor = asRecord(asRecord(current).data);
|
|
33476
|
+
timeline = {
|
|
33477
|
+
narration_stale: editor.narration_stale === true,
|
|
33478
|
+
voice_stale: editor.voice_stale === true,
|
|
33479
|
+
music_stale: editor.music_stale === true,
|
|
33480
|
+
stale_segment_ids: staleSegmentIds,
|
|
33481
|
+
requires_regeneration: requiresRegeneration
|
|
33482
|
+
};
|
|
33483
|
+
} catch {
|
|
33484
|
+
timeline = {
|
|
33485
|
+
narration_stale: null,
|
|
33486
|
+
voice_stale: null,
|
|
33487
|
+
music_stale: null,
|
|
33488
|
+
stale_segment_ids: staleSegmentIds,
|
|
33489
|
+
requires_regeneration: requiresRegeneration,
|
|
33490
|
+
enrichment_unavailable: true
|
|
33491
|
+
};
|
|
33492
|
+
}
|
|
33493
|
+
return ok({
|
|
33494
|
+
...segment,
|
|
33495
|
+
timeline,
|
|
33496
|
+
next: timeline.requires_regeneration || timeline.narration_stale || timeline.voice_stale || timeline.music_stale ? "Regenerate the reported stale scenes/audio before render." : timeline.enrichment_unavailable ? "Scene swap succeeded, but audio staleness could not be verified (enrichment fetch failed) — call get_status before render to confirm." : "Timeline is current and can be rendered."
|
|
33497
|
+
});
|
|
32982
33498
|
}));
|
|
32983
33499
|
registerTool("set_product", {
|
|
32984
33500
|
title: "Attach a product image (from a local file)",
|
|
32985
|
-
description: "Uploads a local product image and attaches it to the project as the product. Accepted: " + IMAGE_EXTS.map((e) => `.${e}`).join(", ") + " (≤
|
|
33501
|
+
description: "Uploads a local product image and attaches it to the project as the product. Accepted: " + IMAGE_EXTS.map((e) => `.${e}`).join(", ") + " (≤8 MiB), local path confined to HUBFLUENCER_INPUT_DIR (or cwd). 0 credits. The FIRST product added " + "defaults every pending AI scene to feature it 'throughout' (change with set_product_placement). Optional " + "description (≤500 chars) guides how it's woven into scenes.",
|
|
32986
33502
|
inputSchema: {
|
|
32987
33503
|
slug: exports_external.string().describe("Editor project slug"),
|
|
32988
33504
|
file_path: exports_external.string().describe("Local product image path (.jpg/.jpeg/.png)"),
|
|
@@ -32990,7 +33506,7 @@ registerTool("set_product", {
|
|
|
32990
33506
|
},
|
|
32991
33507
|
annotations: { title: "Set product", ...WRITE, idempotentHint: false }
|
|
32992
33508
|
}, tool(async (args, client) => {
|
|
32993
|
-
const { s3_key } = await uploadImageFile(client, `/editor/${args.slug}/product/presign`, args.file_path);
|
|
33509
|
+
const { s3_key } = await uploadImageFile(client, `/editor/${args.slug}/product/presign`, args.file_path, MAX_PRODUCT_IMAGE_BYTES);
|
|
32994
33510
|
const res = await client.post(`/editor/${args.slug}/product/confirm`, { s3_key, product_description: args.description });
|
|
32995
33511
|
return ok(asRecord(res).data ?? res);
|
|
32996
33512
|
}));
|
|
@@ -33012,7 +33528,7 @@ registerTool("set_product_placement", {
|
|
|
33012
33528
|
}));
|
|
33013
33529
|
registerTool("set_closing_image", {
|
|
33014
33530
|
title: "Set the closing-card image (0 credits)",
|
|
33015
|
-
description: "Sets the optional ~2s end-card image. Either upload a local file (file_path; .jpg/.jpeg/.png ≤20
|
|
33531
|
+
description: "Sets the optional ~2s end-card image. Either upload a local file (file_path; .jpg/.jpeg/.png ≤20 MiB, confined " + "to HUBFLUENCER_INPUT_DIR/cwd) OR reuse the project's product image (from_product:true — a 0-credit server-side " + "copy, no upload). If a closing image already exists, pass overwrite:true to replace it (else " + "editor_closing_image_exists).",
|
|
33016
33532
|
inputSchema: {
|
|
33017
33533
|
slug: exports_external.string().describe("Editor project slug"),
|
|
33018
33534
|
file_path: exports_external.string().optional().describe("Local image to upload as the end card"),
|
|
@@ -33032,13 +33548,13 @@ registerTool("set_closing_image", {
|
|
|
33032
33548
|
if (!args.file_path) {
|
|
33033
33549
|
return fail("Provide file_path (a local image) or from_product:true.");
|
|
33034
33550
|
}
|
|
33035
|
-
const { s3_key } = await uploadImageFile(client, `/editor/${args.slug}/closing-image/presign`, args.file_path);
|
|
33551
|
+
const { s3_key } = await uploadImageFile(client, `/editor/${args.slug}/closing-image/presign`, args.file_path, MAX_CLOSING_IMAGE_BYTES);
|
|
33036
33552
|
const res = await client.post(`/editor/${args.slug}/closing-image/confirm`, { s3_key });
|
|
33037
33553
|
return ok(asRecord(res).data ?? res);
|
|
33038
33554
|
}));
|
|
33039
33555
|
registerTool("set_logo", {
|
|
33040
33556
|
title: "Set a brand logo overlay (0 credits)",
|
|
33041
|
-
description: "Uploads a local brand logo and overlays it on the video. Accepted: .png/.jpg/.jpeg (≤
|
|
33557
|
+
description: "Uploads a local brand logo and overlays it on the video. Accepted: .png/.jpg/.jpeg (≤1 MiB; PNG keeps " + "transparency), confined to HUBFLUENCER_INPUT_DIR/cwd. 0 credits. Optional placement controls: treatment, " + "position, duration_seconds.",
|
|
33042
33558
|
inputSchema: {
|
|
33043
33559
|
slug: exports_external.string().describe("Editor project slug"),
|
|
33044
33560
|
file_path: exports_external.string().describe("Local logo image (.png/.jpg/.jpeg)"),
|
|
@@ -33048,7 +33564,7 @@ registerTool("set_logo", {
|
|
|
33048
33564
|
},
|
|
33049
33565
|
annotations: { title: "Set logo", ...WRITE, idempotentHint: false }
|
|
33050
33566
|
}, tool(async (args, client) => {
|
|
33051
|
-
const { s3_key } = await uploadImageFile(client, `/editor/${args.slug}/logo/presign`, args.file_path);
|
|
33567
|
+
const { s3_key } = await uploadImageFile(client, `/editor/${args.slug}/logo/presign`, args.file_path, MAX_LOGO_BYTES);
|
|
33052
33568
|
const confirmed = await client.post(`/editor/${args.slug}/logo/confirm`, { s3_key });
|
|
33053
33569
|
let result = asRecord(confirmed).data ?? confirmed;
|
|
33054
33570
|
const settings = {};
|
|
@@ -33066,7 +33582,7 @@ registerTool("set_logo", {
|
|
|
33066
33582
|
}));
|
|
33067
33583
|
registerTool("set_short_product", {
|
|
33068
33584
|
title: "Attach a product image to a short (0 credits)",
|
|
33069
|
-
description: "Uploads a local product image and attaches it to a SHORT so the product is woven into the footage. " + "Accepted: " + IMAGE_EXTS.map((e) => `.${e}`).join(", ") + " (≤20
|
|
33585
|
+
description: "Uploads a local product image and attaches it to a SHORT so the product is woven into the footage. " + "Accepted: " + IMAGE_EXTS.map((e) => `.${e}`).join(", ") + " (≤20 MiB = 20,971,520 bytes), local path confined to HUBFLUENCER_INPUT_DIR (or cwd). 0 credits. Optional description " + "(≤500 chars) guides how it's featured. Attach BEFORE generate_short so the product is part of the scenes. " + "(This is the shorts equivalent of set_product for editor projects.)",
|
|
33070
33586
|
inputSchema: {
|
|
33071
33587
|
slug: exports_external.string().describe("Short slug (from create_short)"),
|
|
33072
33588
|
file_path: exports_external.string().describe("Local product image path (.jpg/.jpeg/.png)"),
|
|
@@ -33084,15 +33600,19 @@ registerTool("set_short_product", {
|
|
|
33084
33600
|
}));
|
|
33085
33601
|
registerTool("set_short_poster", {
|
|
33086
33602
|
title: "Set a short's end-card poster image (0 credits)",
|
|
33087
|
-
description: "Uploads a local image as the SHORT's end-card poster — a closing still shown at the end (extends the " + "render from 12s to 14s). Accepted: " + IMAGE_EXTS.map((e) => `.${e}`).join(", ") + " (≤20
|
|
33603
|
+
description: "Uploads a local image as the SHORT's end-card poster — a closing still shown at the end (extends the " + "render from 12s to 14s). Accepted: " + IMAGE_EXTS.map((e) => `.${e}`).join(", ") + " (≤20 MiB = 20,971,520 bytes), confined to HUBFLUENCER_INPUT_DIR/cwd. 0 credits. There is one poster per short; calling " + "again replaces it. (Shorts have no logo overlay — for a brand logo use an editor project + set_logo.)",
|
|
33088
33604
|
inputSchema: {
|
|
33089
33605
|
slug: exports_external.string().describe("Short slug (from create_short)"),
|
|
33090
|
-
file_path: exports_external.string().describe("Local poster image (.jpg/.jpeg/.png)")
|
|
33606
|
+
file_path: exports_external.string().describe("Local poster image (.jpg/.jpeg/.png)"),
|
|
33607
|
+
poster_includes_lockup: exports_external.boolean().optional().describe("Set true if your poster already contains brand name/CTA/badges — suppresses the overlaid " + "claim/CTA so they don't collide (default false).")
|
|
33091
33608
|
},
|
|
33092
33609
|
annotations: { title: "Set short poster", ...WRITE, idempotentHint: false }
|
|
33093
33610
|
}, tool(async (args, client) => {
|
|
33094
33611
|
const { s3_key } = await uploadShortPoster(client, args.slug, args.file_path);
|
|
33095
|
-
const res = await client.post(`/shorts/${args.slug}/poster/confirm`, {
|
|
33612
|
+
const res = await client.post(`/shorts/${args.slug}/poster/confirm`, {
|
|
33613
|
+
s3_key,
|
|
33614
|
+
poster_includes_lockup: args.poster_includes_lockup ?? false
|
|
33615
|
+
});
|
|
33096
33616
|
return ok(asRecord(res).data ?? res);
|
|
33097
33617
|
}));
|
|
33098
33618
|
registerTool("get_status", {
|
|
@@ -33107,7 +33627,7 @@ registerTool("get_status", {
|
|
|
33107
33627
|
}));
|
|
33108
33628
|
registerTool("wait_for_completion", {
|
|
33109
33629
|
title: "Wait for a render to finish",
|
|
33110
|
-
description: "Polls status (emitting progress) until terminal
|
|
33630
|
+
description: "Polls status (emitting progress) until terminal or the wait budget is exhausted. " + "Generation can take several minutes; if it returns terminal=false, call again to keep waiting. " + "terminal=true does NOT always mean ready=true: an editor can go terminal in a needs-action stage " + '(stage "editing_required" — a delivered render is stale after edits, regenerate the reported scenes/audio; ' + 'stage "insufficient_credits" — a batch is parked, top up or reduce scope; ' + 'stage "idle" — a draft with nothing generating, start generation first) that only a further tool call ' + "can advance — do NOT just re-poll those. " + "Pass save_path to download the finished MP4 when it's ready (e.g. to resume a make_video that timed out mid-render). " + 'Works for kind:"slider" too, but a carousel has no MP4 — it just goes terminal when composited; read the slide ' + "images with get_slider (save_path is ignored for sliders).",
|
|
33111
33631
|
inputSchema: {
|
|
33112
33632
|
slug: exports_external.string(),
|
|
33113
33633
|
kind: kindSchema,
|
|
@@ -33682,7 +34202,7 @@ registerTool("materialize_episode", {
|
|
|
33682
34202
|
const episode = summarizeEpisode(asRecord(res).data);
|
|
33683
34203
|
return ok({
|
|
33684
34204
|
...episode,
|
|
33685
|
-
next: episode.draft_slug ? "
|
|
34205
|
+
next: episode.draft_slug ? "Price this draft with start_autopilot({ slug }); after approval launch it with start_autopilot({ slug, max_credits: approved_cap }). Carousel generation remains a separate paid generate_slider call." : "Draft created — review it with list_series_episodes."
|
|
33686
34206
|
});
|
|
33687
34207
|
}));
|
|
33688
34208
|
registerTool("get_series_dashboard", {
|
|
@@ -34032,7 +34552,7 @@ registerTool("get_asset_quota", {
|
|
|
34032
34552
|
}));
|
|
34033
34553
|
registerTool("upload_asset", {
|
|
34034
34554
|
title: "Upload a local file to your reusable asset catalog",
|
|
34035
|
-
description: "Uploads a local IMAGE or VIDEO into your reusable asset catalog so it can be reused across projects. " + `Allowed types: ${CATALOG_ASSET_EXTS.map((e) => `.${e}`).join(", ")} — images ≤20 MiB, videos ≤500 MB. ` + "Runs presign → PUT → confirm; the confirm HEADs the object and validates size/type, so a mismatch 422s. " + "For a VIDEO you MUST pass duration_seconds (≤60; the server rejects a video with no/over-long duration). " + "Spends 0 credits. Scope: video:generate. Rate limit: 30/min. Requires an ACTIVE SUBSCRIPTION (402 " + "subscription_required otherwise). The local path is confined to HUBFLUENCER_INPUT_DIR (default: cwd). " + "Returns the confirmed asset id + media type + status.",
|
|
34555
|
+
description: "Uploads a local IMAGE or VIDEO into your reusable asset catalog so it can be reused across projects. " + `Allowed types: ${CATALOG_ASSET_EXTS.map((e) => `.${e}`).join(", ")} — images ≤20 MiB (20,971,520 bytes), videos ≤500 MB decimal (500,000,000 bytes). ` + "Runs presign → PUT → confirm; the confirm HEADs the object and validates size/type, so a mismatch 422s. " + "For a VIDEO you MUST pass duration_seconds (≤60; the server rejects a video with no/over-long duration). " + "Spends 0 credits. Scope: video:generate. Rate limit: 30/min. Requires an ACTIVE SUBSCRIPTION (402 " + "subscription_required otherwise). The local path is confined to HUBFLUENCER_INPUT_DIR (default: cwd). " + "Returns the confirmed asset id + media type + status.",
|
|
34036
34556
|
inputSchema: {
|
|
34037
34557
|
file_path: exports_external.string().describe("Path to a local image/video, inside HUBFLUENCER_INPUT_DIR (default: cwd)"),
|
|
34038
34558
|
description: exports_external.string().optional().describe("Optional description stored with the asset"),
|