@hubfluencer/mcp 0.8.2 → 0.9.1
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 +23 -12
- package/dist/index.js +650 -179
- package/package.json +1 -1
- package/src/campaign.ts +1 -1
- package/src/client.ts +4 -1
- package/src/core.ts +309 -15
- package/src/index.ts +580 -89
- package/src/output-schemas.ts +13 -2
- package/src/uploads.ts +401 -163
package/dist/index.js
CHANGED
|
@@ -29488,13 +29488,13 @@ 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
|
|
29491
|
+
const stage2 = d.stage ?? "unknown";
|
|
29492
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
|
};
|
|
@@ -29522,29 +29522,128 @@ function normalizeStatus(kind, slug, data) {
|
|
|
29522
29522
|
};
|
|
29523
29523
|
}
|
|
29524
29524
|
if (kind === "slider") {
|
|
29525
|
-
const
|
|
29525
|
+
const stage2 = d.status ?? "unknown";
|
|
29526
29526
|
return {
|
|
29527
29527
|
kind,
|
|
29528
29528
|
slug,
|
|
29529
|
-
stage,
|
|
29530
|
-
terminal:
|
|
29531
|
-
ready:
|
|
29529
|
+
stage: stage2,
|
|
29530
|
+
terminal: stage2 === "completed" || stage2 === "failed",
|
|
29531
|
+
ready: stage2 === "completed",
|
|
29532
29532
|
video_url: null,
|
|
29533
29533
|
error: d.error_message ?? null
|
|
29534
29534
|
};
|
|
29535
29535
|
}
|
|
29536
29536
|
const autopilot = d.autopilot_status ?? "unknown";
|
|
29537
29537
|
const renderStatus = latest.status ?? null;
|
|
29538
|
-
const
|
|
29539
|
-
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 === "pending" || 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 deliveredArtifactSupersedesOrchestrationFailure = renderStatus === "completed" && Boolean(videoUrl) && renderFresh && !stale && !workActive && failedSegments.length === 0 && !audioFailed;
|
|
29593
|
+
const ready = deliveredArtifactSupersedesOrchestrationFailure;
|
|
29594
|
+
const autopilotFailed = autopilot === "failed" || autopilot === "cancelled";
|
|
29595
|
+
const renderFailed = renderStatus === "failed";
|
|
29596
|
+
const failed = autopilotFailed && !deliveredArtifactSupersedesOrchestrationFailure || renderFailed || scenarioApplyFailed && !deliveredArtifactSupersedesOrchestrationFailure || scenarioFailed && !deliveredArtifactSupersedesOrchestrationFailure || narrationFailed && !deliveredArtifactSupersedesOrchestrationFailure || batchFailed && !deliveredArtifactSupersedesOrchestrationFailure || failedSegments.length > 0 || audioFailed;
|
|
29597
|
+
const terminal = ready || failed || batchInsufficientCredits || !workActive;
|
|
29598
|
+
let stage = autopilot;
|
|
29599
|
+
if (ready)
|
|
29600
|
+
stage = "completed";
|
|
29601
|
+
else if (autopilot === "failed")
|
|
29602
|
+
stage = "failed";
|
|
29603
|
+
else if (autopilot === "cancelled")
|
|
29604
|
+
stage = "cancelled";
|
|
29605
|
+
else if (renderFailed)
|
|
29606
|
+
stage = "render_failed";
|
|
29607
|
+
else if (scenarioApplyFailed)
|
|
29608
|
+
stage = "scenario_apply_failed";
|
|
29609
|
+
else if (scenarioFailed)
|
|
29610
|
+
stage = "scenario_failed";
|
|
29611
|
+
else if (narrationFailed)
|
|
29612
|
+
stage = "narration_failed";
|
|
29613
|
+
else if (batchFailed)
|
|
29614
|
+
stage = "batch_failed";
|
|
29615
|
+
else if (failedSegments.length > 0)
|
|
29616
|
+
stage = "segment_failed";
|
|
29617
|
+
else if (audioFailed)
|
|
29618
|
+
stage = "audio_failed";
|
|
29619
|
+
else if (batchInsufficientCredits)
|
|
29620
|
+
stage = "insufficient_credits";
|
|
29621
|
+
else if (activeSegments.length > 0)
|
|
29622
|
+
stage = "segment_processing";
|
|
29623
|
+
else if (batchActive)
|
|
29624
|
+
stage = "segment_generation";
|
|
29625
|
+
else if (renderActive)
|
|
29626
|
+
stage = "rendering";
|
|
29627
|
+
else if (scenarioActive)
|
|
29628
|
+
stage = "scenario_processing";
|
|
29629
|
+
else if (narrationActive)
|
|
29630
|
+
stage = "narration_processing";
|
|
29631
|
+
else if (audioActive)
|
|
29632
|
+
stage = "audio_processing";
|
|
29633
|
+
else if (!workActive && stale)
|
|
29634
|
+
stage = "editing_required";
|
|
29635
|
+
const segmentError = failedSegments.map((segment) => segment.error_message).find((value) => typeof value === "string" && value.length > 0);
|
|
29540
29636
|
return {
|
|
29541
29637
|
kind,
|
|
29542
29638
|
slug,
|
|
29543
|
-
stage
|
|
29639
|
+
stage,
|
|
29544
29640
|
terminal,
|
|
29545
29641
|
ready,
|
|
29546
29642
|
video_url: videoUrl,
|
|
29547
|
-
error: d.autopilot_error_message ?? null
|
|
29643
|
+
error: ready ? null : 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),
|
|
29644
|
+
stale,
|
|
29645
|
+
active_segment_ids: activeSegments.map((segment) => segment.id).filter((id) => typeof id === "number" || typeof id === "string"),
|
|
29646
|
+
stale_segment_ids: staleSegments.map((segment) => segment.id).filter((id) => typeof id === "number" || typeof id === "string")
|
|
29548
29647
|
};
|
|
29549
29648
|
}
|
|
29550
29649
|
function startStateDiscriminator(kind, data) {
|
|
@@ -29574,10 +29673,10 @@ function makeVideoAutopilotKey(slug, a, startState) {
|
|
|
29574
29673
|
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);
|
|
29575
29674
|
}
|
|
29576
29675
|
function editorAdCreateKey(a) {
|
|
29577
|
-
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 ?? "");
|
|
29676
|
+
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 ?? "");
|
|
29578
29677
|
}
|
|
29579
29678
|
function editorAdAutopilotKey(slug, a) {
|
|
29580
|
-
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 ?? "");
|
|
29679
|
+
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));
|
|
29581
29680
|
}
|
|
29582
29681
|
function sliderCreateKey(a) {
|
|
29583
29682
|
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 ?? "");
|
|
@@ -29587,7 +29686,14 @@ function addSegmentKey(slug, index, nonce) {
|
|
|
29587
29686
|
}
|
|
29588
29687
|
function resolveSavePath(savePath) {
|
|
29589
29688
|
const base = resolve(process.env.HUBFLUENCER_OUTPUT_DIR || process.cwd());
|
|
29590
|
-
|
|
29689
|
+
let target;
|
|
29690
|
+
if (isAbsolute(savePath)) {
|
|
29691
|
+
target = resolve(savePath);
|
|
29692
|
+
} else {
|
|
29693
|
+
const workspaceCandidate = resolve(process.cwd(), savePath);
|
|
29694
|
+
const workspaceCandidateInsideBase = workspaceCandidate === base || workspaceCandidate.startsWith(base + sep);
|
|
29695
|
+
target = workspaceCandidateInsideBase ? workspaceCandidate : resolve(base, savePath);
|
|
29696
|
+
}
|
|
29591
29697
|
if (!target.toLowerCase().endsWith(".mp4")) {
|
|
29592
29698
|
throw new Error("save_path must end in .mp4");
|
|
29593
29699
|
}
|
|
@@ -30027,7 +30133,7 @@ async function runCreateCampaign(client, args, log = () => {}) {
|
|
|
30027
30133
|
items,
|
|
30028
30134
|
summary,
|
|
30029
30135
|
steps,
|
|
30030
|
-
next: `get_campaign_pack({ id: ${campaignPackId} }) to review the drafts; then
|
|
30136
|
+
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.`,
|
|
30031
30137
|
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."
|
|
30032
30138
|
};
|
|
30033
30139
|
}
|
|
@@ -30127,7 +30233,7 @@ function readStoredCredentials() {
|
|
|
30127
30233
|
// package.json
|
|
30128
30234
|
var package_default = {
|
|
30129
30235
|
name: "@hubfluencer/mcp",
|
|
30130
|
-
version: "0.
|
|
30236
|
+
version: "0.9.1",
|
|
30131
30237
|
description: "Model Context Protocol server for Hubfluencer — let AI agents generate post-ready shorts and editor ads.",
|
|
30132
30238
|
license: "MIT",
|
|
30133
30239
|
author: "Monocursive <contact@monocursive.com>",
|
|
@@ -30293,7 +30399,7 @@ class HubfluencerClient {
|
|
|
30293
30399
|
if (e instanceof Error && (e.name === "TimeoutError" || e.name === "AbortError")) {
|
|
30294
30400
|
throw new Error(`Request to ${method} ${path} timed out after ${attemptTimeoutMs}ms.`);
|
|
30295
30401
|
}
|
|
30296
|
-
throw e instanceof Error ? new Error(`Network error on ${method} ${path}: ${e.message}`) : e;
|
|
30402
|
+
throw e instanceof Error ? Object.assign(new Error(`Network error on ${method} ${path}: ${e.message}`), { retryable: isRetryableNetworkError(e) }) : e;
|
|
30297
30403
|
}
|
|
30298
30404
|
if (attempt < attempts && isRetryableStatus(res.status) && !deadlinePassed(deadline)) {
|
|
30299
30405
|
await res.text().catch(() => {});
|
|
@@ -30437,6 +30543,9 @@ var getStatusOutput = exports_external.object({
|
|
|
30437
30543
|
ready: exports_external.boolean().optional(),
|
|
30438
30544
|
video_url: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
|
|
30439
30545
|
error: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
|
|
30546
|
+
stale: exports_external.boolean().optional(),
|
|
30547
|
+
active_segment_ids: exports_external.array(exports_external.union([exports_external.number(), exports_external.string()])).optional(),
|
|
30548
|
+
stale_segment_ids: exports_external.array(exports_external.union([exports_external.number(), exports_external.string()])).optional(),
|
|
30440
30549
|
latest_render_free: exports_external.boolean().optional(),
|
|
30441
30550
|
last_free_rerender_failed: exports_external.boolean().optional()
|
|
30442
30551
|
}).passthrough();
|
|
@@ -30448,6 +30557,9 @@ var waitForCompletionOutput = exports_external.object({
|
|
|
30448
30557
|
ready: exports_external.boolean().optional(),
|
|
30449
30558
|
video_url: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
|
|
30450
30559
|
error: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
|
|
30560
|
+
stale: exports_external.boolean().optional(),
|
|
30561
|
+
active_segment_ids: exports_external.array(exports_external.union([exports_external.number(), exports_external.string()])).optional(),
|
|
30562
|
+
stale_segment_ids: exports_external.array(exports_external.union([exports_external.number(), exports_external.string()])).optional(),
|
|
30451
30563
|
latest_render_free: exports_external.boolean().optional(),
|
|
30452
30564
|
last_free_rerender_failed: exports_external.boolean().optional(),
|
|
30453
30565
|
saved_to: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
|
|
@@ -30511,6 +30623,11 @@ var createEditorOutput = exports_external.object({
|
|
|
30511
30623
|
slug: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
|
|
30512
30624
|
kind: exports_external.string().optional(),
|
|
30513
30625
|
status: projectRow.optional(),
|
|
30626
|
+
estimated_credits: exports_external.union([exports_external.number(), exports_external.null()]).optional(),
|
|
30627
|
+
available_credits: exports_external.union([exports_external.number(), exports_external.null()]).optional(),
|
|
30628
|
+
affordable: exports_external.boolean().optional(),
|
|
30629
|
+
charged: exports_external.boolean().optional(),
|
|
30630
|
+
note: exports_external.string().optional(),
|
|
30514
30631
|
next: exports_external.string().optional()
|
|
30515
30632
|
}).passthrough();
|
|
30516
30633
|
var makeVideoOutput = exports_external.object({
|
|
@@ -30522,8 +30639,8 @@ var makeVideoOutput = exports_external.object({
|
|
|
30522
30639
|
ready: exports_external.boolean().optional(),
|
|
30523
30640
|
video_url: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
|
|
30524
30641
|
error: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
|
|
30525
|
-
estimated_credits: exports_external.
|
|
30526
|
-
available_credits: exports_external.
|
|
30642
|
+
estimated_credits: exports_external.union([exports_external.number(), exports_external.null()]).optional(),
|
|
30643
|
+
available_credits: exports_external.union([exports_external.number(), exports_external.null()]).optional(),
|
|
30527
30644
|
affordable: exports_external.boolean().optional(),
|
|
30528
30645
|
charged: exports_external.boolean().optional(),
|
|
30529
30646
|
saved_to: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
|
|
@@ -30974,14 +31091,19 @@ function calendarScopeErrorText(status, code, message) {
|
|
|
30974
31091
|
}
|
|
30975
31092
|
|
|
30976
31093
|
// src/uploads.ts
|
|
30977
|
-
import {
|
|
30978
|
-
import {
|
|
31094
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
31095
|
+
import { constants } from "node:fs";
|
|
31096
|
+
import { open, realpath, stat } from "node:fs/promises";
|
|
30979
31097
|
import { basename, extname, isAbsolute as isAbsolute2, resolve as resolve2, sep as sep2 } from "node:path";
|
|
31098
|
+
import { Readable } from "node:stream";
|
|
30980
31099
|
var sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
30981
31100
|
var PART_RETRIES = 2;
|
|
30982
31101
|
var ALLOW_LOOPBACK_FETCH = /^http:\/\/(localhost|127\.0\.0\.1|\[?::1\]?)/.test(process.env.HUBFLUENCER_BASE_URL || "");
|
|
30983
31102
|
var MAX_VIDEO_BYTES = 500000000;
|
|
30984
31103
|
var MAX_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
31104
|
+
var MAX_PRODUCT_IMAGE_BYTES = 8 * 1024 * 1024;
|
|
31105
|
+
var MAX_LOGO_BYTES = 1 * 1024 * 1024;
|
|
31106
|
+
var MAX_CLOSING_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
30985
31107
|
var MULTIPART_THRESHOLD = 50 * 1024 * 1024;
|
|
30986
31108
|
var MAX_CATALOG_VIDEO_DURATION_SECONDS = 60;
|
|
30987
31109
|
var VIDEO_EXT_MIME = {
|
|
@@ -31005,6 +31127,53 @@ var CATALOG_ASSET_EXTS = Object.keys(CATALOG_EXT_MIME);
|
|
|
31005
31127
|
function catalogMaxBytes(mime) {
|
|
31006
31128
|
return mime.startsWith("video/") ? MAX_VIDEO_BYTES : MAX_IMAGE_BYTES;
|
|
31007
31129
|
}
|
|
31130
|
+
function preparedUploadIdentity(file) {
|
|
31131
|
+
if (!file.bytes) {
|
|
31132
|
+
throw new Error("Prepared upload has no retained byte snapshot.");
|
|
31133
|
+
}
|
|
31134
|
+
return `${file.size}:${createHash2("sha256").update(file.bytes).digest("hex")}`;
|
|
31135
|
+
}
|
|
31136
|
+
async function fillPositionalRead(handle, buffer, offset, length, position) {
|
|
31137
|
+
let filled = 0;
|
|
31138
|
+
while (filled < length) {
|
|
31139
|
+
const { bytesRead } = await handle.read(buffer, offset + filled, length - filled, position + filled);
|
|
31140
|
+
if (bytesRead === 0)
|
|
31141
|
+
break;
|
|
31142
|
+
filled += bytesRead;
|
|
31143
|
+
}
|
|
31144
|
+
return filled;
|
|
31145
|
+
}
|
|
31146
|
+
async function closeReadHandle(handle) {
|
|
31147
|
+
await handle.close();
|
|
31148
|
+
}
|
|
31149
|
+
async function readValidatedBytes(file) {
|
|
31150
|
+
const buffer = Buffer.allocUnsafe(file.size);
|
|
31151
|
+
let offset = 0;
|
|
31152
|
+
while (offset < file.size) {
|
|
31153
|
+
const { bytesRead } = await file.handle.read(buffer, offset, file.size - offset, offset);
|
|
31154
|
+
if (bytesRead === 0) {
|
|
31155
|
+
throw new Error(`Short read: got ${offset} of ${file.size} validated bytes (file changed under us?).`);
|
|
31156
|
+
}
|
|
31157
|
+
offset += bytesRead;
|
|
31158
|
+
}
|
|
31159
|
+
return buffer;
|
|
31160
|
+
}
|
|
31161
|
+
function validatedByteStream(file) {
|
|
31162
|
+
return Readable.from(async function* () {
|
|
31163
|
+
const chunkSize = 1024 * 1024;
|
|
31164
|
+
let offset = 0;
|
|
31165
|
+
while (offset < file.size) {
|
|
31166
|
+
const length = Math.min(chunkSize, file.size - offset);
|
|
31167
|
+
const chunk = Buffer.allocUnsafe(length);
|
|
31168
|
+
const filled = await fillPositionalRead(file.handle, chunk, 0, length, offset);
|
|
31169
|
+
if (filled !== length) {
|
|
31170
|
+
throw new Error(`Short stream read: got ${filled} of ${length} bytes at offset ${offset} (file changed under us?).`);
|
|
31171
|
+
}
|
|
31172
|
+
offset += filled;
|
|
31173
|
+
yield chunk;
|
|
31174
|
+
}
|
|
31175
|
+
}());
|
|
31176
|
+
}
|
|
31008
31177
|
function planMultipartParts(size, partSize) {
|
|
31009
31178
|
if (!Number.isFinite(size) || size <= 0) {
|
|
31010
31179
|
throw new Error(`size must be a positive number, got ${size}.`);
|
|
@@ -31027,14 +31196,29 @@ function assertFullRead(bytesRead, len, partNumber, offset) {
|
|
|
31027
31196
|
throw new Error(`Short read on part ${partNumber}: got ${bytesRead} of ${len} bytes at offset ${offset} (file changed under us?).`);
|
|
31028
31197
|
}
|
|
31029
31198
|
}
|
|
31030
|
-
async function
|
|
31199
|
+
async function openReadPath(filePath, extToMime, maxBytes) {
|
|
31031
31200
|
if (!filePath || typeof filePath !== "string") {
|
|
31032
31201
|
throw new Error("file_path is required.");
|
|
31033
31202
|
}
|
|
31034
|
-
const
|
|
31035
|
-
|
|
31203
|
+
const configuredBase = resolve2(process.env.HUBFLUENCER_INPUT_DIR || process.cwd());
|
|
31204
|
+
let base;
|
|
31205
|
+
try {
|
|
31206
|
+
base = await realpath(configuredBase);
|
|
31207
|
+
} catch (e) {
|
|
31208
|
+
throw new Error(`Cannot resolve input directory ${configuredBase}: ${e instanceof Error ? e.message : String(e)}`);
|
|
31209
|
+
}
|
|
31210
|
+
const requested = isAbsolute2(filePath) ? resolve2(filePath) : resolve2(configuredBase, filePath);
|
|
31211
|
+
if (requested !== configuredBase && !requested.startsWith(configuredBase + sep2)) {
|
|
31212
|
+
throw new Error(`file_path must be inside ${base} (set HUBFLUENCER_INPUT_DIR to change). Refusing to read ${requested}.`);
|
|
31213
|
+
}
|
|
31214
|
+
let target;
|
|
31215
|
+
try {
|
|
31216
|
+
target = await realpath(requested);
|
|
31217
|
+
} catch (e) {
|
|
31218
|
+
throw new Error(`Cannot read ${requested}: ${e instanceof Error ? e.message : String(e)}`);
|
|
31219
|
+
}
|
|
31036
31220
|
if (target !== base && !target.startsWith(base + sep2)) {
|
|
31037
|
-
throw new Error(`file_path must
|
|
31221
|
+
throw new Error(`file_path must resolve inside ${base} (set HUBFLUENCER_INPUT_DIR to change). Refusing to read ${target}.`);
|
|
31038
31222
|
}
|
|
31039
31223
|
const ext = extname(target).slice(1).toLowerCase();
|
|
31040
31224
|
const mime = extToMime[ext];
|
|
@@ -31042,21 +31226,57 @@ async function resolveReadPath(filePath, extToMime, maxBytes) {
|
|
|
31042
31226
|
const allowed = Object.keys(extToMime).map((e) => `.${e}`).join(", ");
|
|
31043
31227
|
throw new Error(`Unsupported file type ".${ext}". Allowed: ${allowed}.`);
|
|
31044
31228
|
}
|
|
31045
|
-
let
|
|
31229
|
+
let handle;
|
|
31046
31230
|
try {
|
|
31047
|
-
|
|
31231
|
+
handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
31232
|
+
const [openedStat, targetStat, currentTarget] = await Promise.all([
|
|
31233
|
+
handle.stat(),
|
|
31234
|
+
stat(target),
|
|
31235
|
+
realpath(requested)
|
|
31236
|
+
]);
|
|
31237
|
+
if (currentTarget !== target)
|
|
31238
|
+
throw new Error("path changed while opening");
|
|
31239
|
+
const st = openedStat;
|
|
31048
31240
|
if (!st.isFile())
|
|
31049
31241
|
throw new Error("not a regular file");
|
|
31050
|
-
|
|
31242
|
+
if (st.dev !== targetStat.dev || st.ino !== targetStat.ino) {
|
|
31243
|
+
throw new Error("opened file does not match the validated target");
|
|
31244
|
+
}
|
|
31245
|
+
if (st.size <= 0)
|
|
31246
|
+
throw new Error(`${target} is empty.`);
|
|
31247
|
+
if (st.size > maxBytes) {
|
|
31248
|
+
throw new Error(`${target} is ${st.size} bytes — over the ${maxBytes}-byte cap for this asset type.`);
|
|
31249
|
+
}
|
|
31250
|
+
return { path: target, ext, mime, size: st.size, handle };
|
|
31051
31251
|
} catch (e) {
|
|
31252
|
+
if (handle)
|
|
31253
|
+
await closeReadHandle(handle).catch(() => {
|
|
31254
|
+
return;
|
|
31255
|
+
});
|
|
31052
31256
|
throw new Error(`Cannot read ${target}: ${e instanceof Error ? e.message : String(e)}`);
|
|
31053
31257
|
}
|
|
31054
|
-
|
|
31055
|
-
|
|
31056
|
-
|
|
31057
|
-
|
|
31258
|
+
}
|
|
31259
|
+
async function prepareImageUpload(filePath, maxBytes = MAX_IMAGE_BYTES) {
|
|
31260
|
+
const file = await openReadPath(filePath, IMAGE_EXT_MIME, maxBytes);
|
|
31261
|
+
try {
|
|
31262
|
+
file.bytes = await readValidatedBytes(file);
|
|
31263
|
+
return file;
|
|
31264
|
+
} catch (error2) {
|
|
31265
|
+
await closeReadHandle(file.handle);
|
|
31266
|
+
throw error2;
|
|
31267
|
+
}
|
|
31268
|
+
}
|
|
31269
|
+
function closePreparedUploadFile(file) {
|
|
31270
|
+
return closeReadHandle(file.handle);
|
|
31271
|
+
}
|
|
31272
|
+
async function resolveReadPath(filePath, extToMime, maxBytes) {
|
|
31273
|
+
const file = await openReadPath(filePath, extToMime, maxBytes);
|
|
31274
|
+
try {
|
|
31275
|
+
const { handle: _, ...resolved } = file;
|
|
31276
|
+
return resolved;
|
|
31277
|
+
} finally {
|
|
31278
|
+
await closeReadHandle(file.handle);
|
|
31058
31279
|
}
|
|
31059
|
-
return { path: target, ext, mime, size };
|
|
31060
31280
|
}
|
|
31061
31281
|
async function resolveVideoReadPath(filePath) {
|
|
31062
31282
|
return resolveReadPath(filePath, VIDEO_EXT_MIME, MAX_VIDEO_BYTES);
|
|
@@ -31073,7 +31293,7 @@ async function putToPresignedUrl(url, body, contentType, timeoutMs = 300000, con
|
|
|
31073
31293
|
fetchBody = body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
|
|
31074
31294
|
fetchInit = { method: "PUT", headers, body: fetchBody };
|
|
31075
31295
|
} else {
|
|
31076
|
-
stream =
|
|
31296
|
+
stream = validatedByteStream(body);
|
|
31077
31297
|
headers["content-length"] = String(contentLength ?? body.size);
|
|
31078
31298
|
fetchInit = {
|
|
31079
31299
|
method: "PUT",
|
|
@@ -31083,19 +31303,35 @@ async function putToPresignedUrl(url, body, contentType, timeoutMs = 300000, con
|
|
|
31083
31303
|
};
|
|
31084
31304
|
}
|
|
31085
31305
|
fetchInit.signal = AbortSignal.timeout(timeoutMs);
|
|
31306
|
+
const closeStream = async () => {
|
|
31307
|
+
if (!stream || stream.closed)
|
|
31308
|
+
return;
|
|
31309
|
+
await new Promise((resolve3, reject) => {
|
|
31310
|
+
stream?.once("close", resolve3);
|
|
31311
|
+
stream?.once("error", reject);
|
|
31312
|
+
stream?.destroy();
|
|
31313
|
+
});
|
|
31314
|
+
};
|
|
31086
31315
|
let resp;
|
|
31087
31316
|
try {
|
|
31088
31317
|
resp = await fetch(url, fetchInit);
|
|
31089
31318
|
} catch (e) {
|
|
31090
|
-
|
|
31319
|
+
await closeStream().catch(() => {
|
|
31320
|
+
return;
|
|
31321
|
+
});
|
|
31091
31322
|
if (e instanceof Error && (e.name === "TimeoutError" || e.name === "AbortError")) {
|
|
31092
31323
|
throw markRetryable(`Upload PUT timed out after ${timeoutMs / 1000}s.`);
|
|
31093
31324
|
}
|
|
31094
31325
|
throw e instanceof Error ? markRetryable(`Upload PUT failed: ${e.message}`) : e;
|
|
31095
31326
|
}
|
|
31327
|
+
await closeStream();
|
|
31096
31328
|
if (!resp.ok) {
|
|
31097
31329
|
const text = await resp.text().catch(() => "");
|
|
31098
|
-
|
|
31330
|
+
const error2 = new Error(`Upload PUT rejected (HTTP ${resp.status})${text ? `: ${text.slice(0, 200)}` : ""}.`);
|
|
31331
|
+
if (isRetryableStatus(resp.status)) {
|
|
31332
|
+
Object.assign(error2, { retryable: true });
|
|
31333
|
+
}
|
|
31334
|
+
throw error2;
|
|
31099
31335
|
}
|
|
31100
31336
|
return resp.headers.get("etag") ?? undefined;
|
|
31101
31337
|
}
|
|
@@ -31103,31 +31339,35 @@ function markRetryable(message) {
|
|
|
31103
31339
|
return Object.assign(new Error(message), { retryable: true });
|
|
31104
31340
|
}
|
|
31105
31341
|
async function uploadVideoFile(client, slug, filePath, opts = {}) {
|
|
31106
|
-
const file = await
|
|
31342
|
+
const file = await openReadPath(filePath, VIDEO_EXT_MIME, MAX_VIDEO_BYTES);
|
|
31107
31343
|
const filename = basename(file.path);
|
|
31108
31344
|
const fit_mode = opts.fitMode === "cover" ? "cover" : undefined;
|
|
31109
31345
|
const product_description = opts.productDescription;
|
|
31110
|
-
|
|
31111
|
-
|
|
31346
|
+
try {
|
|
31347
|
+
if (file.size >= MULTIPART_THRESHOLD) {
|
|
31348
|
+
return await uploadVideoMultipart(client, slug, file, {
|
|
31349
|
+
filename,
|
|
31350
|
+
fit_mode,
|
|
31351
|
+
product_description
|
|
31352
|
+
}, opts.onProgress);
|
|
31353
|
+
}
|
|
31354
|
+
const buf = await readValidatedBytes(file);
|
|
31355
|
+
const presign = await client.post(`/editor/${slug}/uploads/presign`, {
|
|
31112
31356
|
filename,
|
|
31357
|
+
mime_type: file.mime,
|
|
31358
|
+
size_bytes: file.size,
|
|
31113
31359
|
fit_mode,
|
|
31114
31360
|
product_description
|
|
31115
|
-
}
|
|
31361
|
+
});
|
|
31362
|
+
const { upload_id, presigned_url } = presign.data;
|
|
31363
|
+
await opts.onProgress?.(0, 1, `uploading ${filename}`);
|
|
31364
|
+
await putToPresignedUrl(presigned_url, buf, file.mime);
|
|
31365
|
+
await opts.onProgress?.(1, 1, `uploaded ${filename}, confirming`);
|
|
31366
|
+
const confirmed = await client.post(`/editor/${slug}/uploads/${upload_id}/confirm`);
|
|
31367
|
+
return { upload_id, status: confirmed.data?.status ?? "processing" };
|
|
31368
|
+
} finally {
|
|
31369
|
+
await closeReadHandle(file.handle);
|
|
31116
31370
|
}
|
|
31117
|
-
const presign = await client.post(`/editor/${slug}/uploads/presign`, {
|
|
31118
|
-
filename,
|
|
31119
|
-
mime_type: file.mime,
|
|
31120
|
-
size_bytes: file.size,
|
|
31121
|
-
fit_mode,
|
|
31122
|
-
product_description
|
|
31123
|
-
});
|
|
31124
|
-
const { upload_id, presigned_url } = presign.data;
|
|
31125
|
-
const buf = await readFile(file.path);
|
|
31126
|
-
await opts.onProgress?.(0, 1, `uploading ${filename}`);
|
|
31127
|
-
await putToPresignedUrl(presigned_url, buf, file.mime);
|
|
31128
|
-
await opts.onProgress?.(1, 1, `uploaded ${filename}, confirming`);
|
|
31129
|
-
const confirmed = await client.post(`/editor/${slug}/uploads/${upload_id}/confirm`);
|
|
31130
|
-
return { upload_id, status: confirmed.data?.status ?? "processing" };
|
|
31131
31371
|
}
|
|
31132
31372
|
async function uploadPartWithRetry(client, slug, ids, part_number, chunk) {
|
|
31133
31373
|
for (let attempt = 1;; attempt++) {
|
|
@@ -31143,7 +31383,8 @@ async function uploadPartWithRetry(client, slug, ids, part_number, chunk) {
|
|
|
31143
31383
|
}
|
|
31144
31384
|
return etag;
|
|
31145
31385
|
} catch (e) {
|
|
31146
|
-
|
|
31386
|
+
const status = e?.status;
|
|
31387
|
+
if (attempt <= PART_RETRIES && (isRetryableNetworkError(e) || typeof status === "number" && isRetryableStatus(status))) {
|
|
31147
31388
|
await sleep3(backoffDelayMs(attempt, DEFAULT_RETRY));
|
|
31148
31389
|
continue;
|
|
31149
31390
|
}
|
|
@@ -31160,18 +31401,17 @@ async function uploadVideoMultipart(client, slug, file, meta2, onProgress) {
|
|
|
31160
31401
|
product_description: meta2.product_description
|
|
31161
31402
|
});
|
|
31162
31403
|
const { upload_id, s3_upload_id, parts_count, part_size } = init.data;
|
|
31163
|
-
const plan = planMultipartParts(file.size, part_size);
|
|
31164
|
-
if (plan.length !== parts_count) {
|
|
31165
|
-
throw new Error(`Multipart plan mismatch: server expects ${parts_count} parts, computed ${plan.length} for ${file.size} bytes at ${part_size}/part.`);
|
|
31166
|
-
}
|
|
31167
|
-
await onProgress?.(0, plan.length, `uploading ${meta2.filename} in ${plan.length} parts`);
|
|
31168
|
-
const fh = await open(file.path, "r");
|
|
31169
|
-
const parts = [];
|
|
31170
31404
|
try {
|
|
31405
|
+
const plan = planMultipartParts(file.size, part_size);
|
|
31406
|
+
if (plan.length !== parts_count) {
|
|
31407
|
+
throw new Error(`Multipart plan mismatch: server expects ${parts_count} parts, computed ${plan.length} for ${file.size} bytes at ${part_size}/part.`);
|
|
31408
|
+
}
|
|
31409
|
+
await onProgress?.(0, plan.length, `uploading ${meta2.filename} in ${plan.length} parts`);
|
|
31410
|
+
const parts = [];
|
|
31171
31411
|
for (const { part_number, offset, len } of plan) {
|
|
31172
31412
|
const chunk = Buffer.alloc(len);
|
|
31173
|
-
const
|
|
31174
|
-
assertFullRead(
|
|
31413
|
+
const filled = await fillPositionalRead(file.handle, chunk, 0, len, offset);
|
|
31414
|
+
assertFullRead(filled, len, part_number, offset);
|
|
31175
31415
|
const etag = await uploadPartWithRetry(client, slug, { upload_id, s3_upload_id }, part_number, chunk);
|
|
31176
31416
|
parts.push({ part_number, etag });
|
|
31177
31417
|
await onProgress?.(part_number, plan.length, `uploaded part ${part_number}/${plan.length}`);
|
|
@@ -31186,73 +31426,95 @@ async function uploadVideoMultipart(client, slug, file, meta2, onProgress) {
|
|
|
31186
31426
|
});
|
|
31187
31427
|
} catch {}
|
|
31188
31428
|
throw e;
|
|
31189
|
-
} finally {
|
|
31190
|
-
await fh.close();
|
|
31191
31429
|
}
|
|
31192
31430
|
}
|
|
31193
31431
|
async function uploadImageFile(client, presignPath, filePath, maxBytes = MAX_IMAGE_BYTES) {
|
|
31194
|
-
const
|
|
31195
|
-
const
|
|
31196
|
-
|
|
31197
|
-
|
|
31198
|
-
|
|
31199
|
-
|
|
31200
|
-
|
|
31201
|
-
|
|
31202
|
-
|
|
31432
|
+
const ownsFile = typeof filePath === "string";
|
|
31433
|
+
const file = ownsFile ? await openReadPath(filePath, IMAGE_EXT_MIME, maxBytes) : filePath;
|
|
31434
|
+
try {
|
|
31435
|
+
const buf = file.bytes ?? await readValidatedBytes(file);
|
|
31436
|
+
const presign = await client.post(presignPath, {
|
|
31437
|
+
mime_type: file.mime,
|
|
31438
|
+
size_bytes: file.size
|
|
31439
|
+
});
|
|
31440
|
+
const { presigned_url, s3_key } = presign.data;
|
|
31441
|
+
await putToPresignedUrl(presigned_url, buf, file.mime);
|
|
31442
|
+
return { s3_key };
|
|
31443
|
+
} finally {
|
|
31444
|
+
if (ownsFile)
|
|
31445
|
+
await closeReadHandle(file.handle);
|
|
31446
|
+
}
|
|
31203
31447
|
}
|
|
31204
31448
|
async function uploadShortPoster(client, slug, filePath) {
|
|
31205
|
-
const file = await
|
|
31206
|
-
|
|
31207
|
-
|
|
31208
|
-
|
|
31209
|
-
|
|
31210
|
-
|
|
31449
|
+
const file = await openReadPath(filePath, IMAGE_EXT_MIME, MAX_IMAGE_BYTES);
|
|
31450
|
+
try {
|
|
31451
|
+
const buf = await readValidatedBytes(file);
|
|
31452
|
+
const presign = await client.post(`/shorts/${slug}/poster/presign`, { content_type: file.mime });
|
|
31453
|
+
const { upload_url, s3_key } = presign;
|
|
31454
|
+
await putToPresignedUrl(upload_url, buf, file.mime);
|
|
31455
|
+
return { s3_key };
|
|
31456
|
+
} finally {
|
|
31457
|
+
await closeReadHandle(file.handle);
|
|
31458
|
+
}
|
|
31211
31459
|
}
|
|
31212
31460
|
async function uploadCatalogAsset(client, filePath, opts = {}) {
|
|
31213
|
-
const file = await
|
|
31214
|
-
|
|
31215
|
-
|
|
31216
|
-
|
|
31217
|
-
|
|
31218
|
-
if (file.mime.startsWith("video/")) {
|
|
31219
|
-
const duration3 = opts.durationSeconds;
|
|
31220
|
-
if (typeof duration3 !== "number" || !Number.isFinite(duration3) || duration3 <= 0 || duration3 > MAX_CATALOG_VIDEO_DURATION_SECONDS) {
|
|
31221
|
-
throw new Error(`Catalog video uploads require durationSeconds to be greater than 0 and no more than ${MAX_CATALOG_VIDEO_DURATION_SECONDS} seconds.`);
|
|
31461
|
+
const file = await openReadPath(filePath, CATALOG_EXT_MIME, MAX_VIDEO_BYTES);
|
|
31462
|
+
try {
|
|
31463
|
+
const cap = catalogMaxBytes(file.mime);
|
|
31464
|
+
if (file.size > cap) {
|
|
31465
|
+
throw new Error(`${file.path} is ${file.size} bytes — over the ${cap}-byte cap for ${file.mime} catalog uploads.`);
|
|
31222
31466
|
}
|
|
31467
|
+
if (file.mime.startsWith("video/")) {
|
|
31468
|
+
const duration3 = opts.durationSeconds;
|
|
31469
|
+
if (typeof duration3 !== "number" || !Number.isFinite(duration3) || duration3 <= 0 || duration3 > MAX_CATALOG_VIDEO_DURATION_SECONDS) {
|
|
31470
|
+
throw new Error(`Catalog video uploads require durationSeconds to be greater than 0 and no more than ${MAX_CATALOG_VIDEO_DURATION_SECONDS} seconds.`);
|
|
31471
|
+
}
|
|
31472
|
+
}
|
|
31473
|
+
const bufferedBytes = file.size < MULTIPART_THRESHOLD ? await readValidatedBytes(file) : undefined;
|
|
31474
|
+
const filename = basename(file.path);
|
|
31475
|
+
const presign = await client.post("/assets/presign", {
|
|
31476
|
+
filename,
|
|
31477
|
+
mime_type: file.mime,
|
|
31478
|
+
size_bytes: file.size
|
|
31479
|
+
});
|
|
31480
|
+
const { asset_id, presigned_url } = presign.data;
|
|
31481
|
+
if (file.size >= MULTIPART_THRESHOLD) {
|
|
31482
|
+
for (let attempt = 1;; attempt++) {
|
|
31483
|
+
try {
|
|
31484
|
+
await putToPresignedUrl(presigned_url, { handle: file.handle, size: file.size }, file.mime, 300000, file.size);
|
|
31485
|
+
break;
|
|
31486
|
+
} catch (error2) {
|
|
31487
|
+
if (attempt >= DEFAULT_RETRY.attempts || !isRetryableNetworkError(error2)) {
|
|
31488
|
+
throw error2;
|
|
31489
|
+
}
|
|
31490
|
+
await sleep3(backoffDelayMs(attempt, DEFAULT_RETRY));
|
|
31491
|
+
}
|
|
31492
|
+
}
|
|
31493
|
+
} else {
|
|
31494
|
+
await putToPresignedUrl(presigned_url, bufferedBytes, file.mime);
|
|
31495
|
+
}
|
|
31496
|
+
const confirmBody = {};
|
|
31497
|
+
if (typeof opts.description === "string" && opts.description.trim() !== "")
|
|
31498
|
+
confirmBody.description = opts.description;
|
|
31499
|
+
if (typeof opts.width === "number")
|
|
31500
|
+
confirmBody.width = opts.width;
|
|
31501
|
+
if (typeof opts.height === "number")
|
|
31502
|
+
confirmBody.height = opts.height;
|
|
31503
|
+
if (typeof opts.durationSeconds === "number")
|
|
31504
|
+
confirmBody.duration_seconds = opts.durationSeconds;
|
|
31505
|
+
const confirmed = await client.post(`/assets/${asset_id}/confirm`, confirmBody);
|
|
31506
|
+
const data = confirmed.data ?? {};
|
|
31507
|
+
return {
|
|
31508
|
+
asset_id,
|
|
31509
|
+
status: typeof data.status === "string" ? data.status : "ready",
|
|
31510
|
+
media_type: typeof data.media_type === "string" ? data.media_type : "",
|
|
31511
|
+
mime_type: typeof data.mime_type === "string" ? data.mime_type : file.mime,
|
|
31512
|
+
filename: typeof data.filename === "string" ? data.filename : filename,
|
|
31513
|
+
size_bytes: typeof data.size_bytes === "number" ? data.size_bytes : file.size
|
|
31514
|
+
};
|
|
31515
|
+
} finally {
|
|
31516
|
+
await closeReadHandle(file.handle);
|
|
31223
31517
|
}
|
|
31224
|
-
const filename = basename(file.path);
|
|
31225
|
-
const presign = await client.post("/assets/presign", {
|
|
31226
|
-
filename,
|
|
31227
|
-
mime_type: file.mime,
|
|
31228
|
-
size_bytes: file.size
|
|
31229
|
-
});
|
|
31230
|
-
const { asset_id, presigned_url } = presign.data;
|
|
31231
|
-
if (file.size >= MULTIPART_THRESHOLD) {
|
|
31232
|
-
await putToPresignedUrl(presigned_url, { filePath: file.path, size: file.size }, file.mime, 300000, file.size);
|
|
31233
|
-
} else {
|
|
31234
|
-
const buf = await readFile(file.path);
|
|
31235
|
-
await putToPresignedUrl(presigned_url, buf, file.mime);
|
|
31236
|
-
}
|
|
31237
|
-
const confirmBody = {};
|
|
31238
|
-
if (typeof opts.description === "string" && opts.description.trim() !== "")
|
|
31239
|
-
confirmBody.description = opts.description;
|
|
31240
|
-
if (typeof opts.width === "number")
|
|
31241
|
-
confirmBody.width = opts.width;
|
|
31242
|
-
if (typeof opts.height === "number")
|
|
31243
|
-
confirmBody.height = opts.height;
|
|
31244
|
-
if (typeof opts.durationSeconds === "number")
|
|
31245
|
-
confirmBody.duration_seconds = opts.durationSeconds;
|
|
31246
|
-
const confirmed = await client.post(`/assets/${asset_id}/confirm`, confirmBody);
|
|
31247
|
-
const data = confirmed.data ?? {};
|
|
31248
|
-
return {
|
|
31249
|
-
asset_id,
|
|
31250
|
-
status: typeof data.status === "string" ? data.status : "ready",
|
|
31251
|
-
media_type: typeof data.media_type === "string" ? data.media_type : "",
|
|
31252
|
-
mime_type: typeof data.mime_type === "string" ? data.mime_type : file.mime,
|
|
31253
|
-
filename: typeof data.filename === "string" ? data.filename : filename,
|
|
31254
|
-
size_bytes: typeof data.size_bytes === "number" ? data.size_bytes : file.size
|
|
31255
|
-
};
|
|
31256
31518
|
}
|
|
31257
31519
|
|
|
31258
31520
|
// src/index.ts
|
|
@@ -31261,6 +31523,59 @@ async function fetchStatus(client, kind, slug, deadline) {
|
|
|
31261
31523
|
const res = await client.get(path, undefined, deadline);
|
|
31262
31524
|
return normalizeStatus(kind, slug, asRecord(res).data);
|
|
31263
31525
|
}
|
|
31526
|
+
async function editorAutopilotQuote(client, slug, overrides = {}) {
|
|
31527
|
+
try {
|
|
31528
|
+
const params = new URLSearchParams;
|
|
31529
|
+
if (overrides.language)
|
|
31530
|
+
params.set("language", overrides.language);
|
|
31531
|
+
if (overrides.product_subject)
|
|
31532
|
+
params.set("product_subject", overrides.product_subject);
|
|
31533
|
+
if (overrides.product_prompt)
|
|
31534
|
+
params.set("product_prompt", overrides.product_prompt);
|
|
31535
|
+
if (overrides.restart)
|
|
31536
|
+
params.set("restart", "true");
|
|
31537
|
+
const query = params.toString();
|
|
31538
|
+
const response = await client.get(`/editor/${slug}/autopilot/cost${query ? `?${query}` : ""}`);
|
|
31539
|
+
const cost = asRecord(asRecord(response).data);
|
|
31540
|
+
const estimated_credits = typeof cost.total === "number" ? cost.total : null;
|
|
31541
|
+
const available_credits = typeof cost.available_credits === "number" ? cost.available_credits : null;
|
|
31542
|
+
return {
|
|
31543
|
+
estimated_credits,
|
|
31544
|
+
available_credits,
|
|
31545
|
+
affordable: estimated_credits !== null && available_credits !== null && available_credits >= estimated_credits
|
|
31546
|
+
};
|
|
31547
|
+
} catch (error2) {
|
|
31548
|
+
const apiError = error2;
|
|
31549
|
+
if (typeof apiError.status === "number" && apiError.status >= 400 && apiError.status < 500 && apiError.status !== 429) {
|
|
31550
|
+
throw error2;
|
|
31551
|
+
}
|
|
31552
|
+
return {
|
|
31553
|
+
estimated_credits: null,
|
|
31554
|
+
available_credits: null,
|
|
31555
|
+
affordable: false
|
|
31556
|
+
};
|
|
31557
|
+
}
|
|
31558
|
+
}
|
|
31559
|
+
async function editorChargeReceipt(client, slug, action, listPriceCredits) {
|
|
31560
|
+
try {
|
|
31561
|
+
const response = await client.get(`/editor/${slug}`);
|
|
31562
|
+
const editor = asRecord(asRecord(response).data);
|
|
31563
|
+
return {
|
|
31564
|
+
action,
|
|
31565
|
+
list_price_credits: listPriceCredits,
|
|
31566
|
+
project_spend: editor.project_spend ?? null,
|
|
31567
|
+
approved_cap: editor.autopilot_max_credits ?? null,
|
|
31568
|
+
approved_run_spend: editor.autopilot_credits_spent ?? null
|
|
31569
|
+
};
|
|
31570
|
+
} catch {
|
|
31571
|
+
return {
|
|
31572
|
+
action,
|
|
31573
|
+
list_price_credits: listPriceCredits,
|
|
31574
|
+
project_spend: null,
|
|
31575
|
+
enrichment_unavailable: true
|
|
31576
|
+
};
|
|
31577
|
+
}
|
|
31578
|
+
}
|
|
31264
31579
|
function isRecompositeInProgressConflict(e) {
|
|
31265
31580
|
const err = e;
|
|
31266
31581
|
if (err?.status !== 409)
|
|
@@ -31466,14 +31781,14 @@ var WRITE = {
|
|
|
31466
31781
|
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).
|
|
31467
31782
|
|
|
31468
31783
|
MAKE ONE ASSET (simplest)
|
|
31469
|
-
- 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").
|
|
31784
|
+
- 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.
|
|
31470
31785
|
- 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).
|
|
31471
31786
|
|
|
31472
31787
|
DELEGATE THE CONTENT OPERATION (the full loop — steps 1-3 are $0)
|
|
31473
31788
|
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.
|
|
31474
31789
|
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.
|
|
31475
31790
|
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.
|
|
31476
|
-
4. GENERATE per draft (CREDITS — a separate, explicit step):
|
|
31791
|
+
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.
|
|
31477
31792
|
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.
|
|
31478
31793
|
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.
|
|
31479
31794
|
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.
|
|
@@ -31481,11 +31796,11 @@ DELEGATE THE CONTENT OPERATION (the full loop — steps 1-3 are $0)
|
|
|
31481
31796
|
|
|
31482
31797
|
CREDITS (spent server-side; each tool prices before charging — see its description)
|
|
31483
31798
|
- 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.
|
|
31484
|
-
- 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
|
|
31799
|
+
- 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.
|
|
31485
31800
|
- 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.
|
|
31486
31801
|
|
|
31487
31802
|
WAITING & RESUMING
|
|
31488
|
-
- 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.
|
|
31803
|
+
- 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.
|
|
31489
31804
|
- 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.
|
|
31490
31805
|
|
|
31491
31806
|
LOCAL FILES (sandboxed)
|
|
@@ -31573,7 +31888,7 @@ registerTool("make_video", {
|
|
|
31573
31888
|
save_path: exports_external.string().optional().describe("Optional .mp4 path to download to (confined to HUBFLUENCER_OUTPUT_DIR or cwd)"),
|
|
31574
31889
|
max_wait_seconds: exports_external.number().optional().describe("Block budget seconds (default 240, capped 10–280)"),
|
|
31575
31890
|
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."),
|
|
31576
|
-
max_credits: exports_external.number().optional().describe("Spend cap: refuse to start (no charge) if the priced estimate exceeds this. Returns the estimate.")
|
|
31891
|
+
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.")
|
|
31577
31892
|
},
|
|
31578
31893
|
outputSchema: makeVideoOutput,
|
|
31579
31894
|
annotations: { title: "Make a video", ...WRITE, idempotentHint: false }
|
|
@@ -31660,11 +31975,12 @@ registerTool("make_video", {
|
|
|
31660
31975
|
estimated_credits = typeof cost.total === "number" ? cost.total : null;
|
|
31661
31976
|
available_credits = typeof cost.available_credits === "number" ? cost.available_credits : null;
|
|
31662
31977
|
} catch {}
|
|
31663
|
-
const resume = kind === "short" ? `generate_short({ slug: "${slug}"${args.skip_auto_text ? ", skip_auto_text: true" : ""} })` : `start_autopilot({ slug: "${slug}" })`;
|
|
31978
|
+
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>"} })`;
|
|
31664
31979
|
const affordable = available_credits === null || estimated_credits === null || available_credits >= estimated_credits;
|
|
31665
31980
|
const overCap = args.max_credits != null && estimated_credits != null && estimated_credits > args.max_credits;
|
|
31666
|
-
|
|
31667
|
-
|
|
31981
|
+
const editorPriceUnavailableWithoutCap = kind === "editor" && estimated_credits === null && args.max_credits == null;
|
|
31982
|
+
if (args.dry_run || editorPriceUnavailableWithoutCap || overCap || !affordable) {
|
|
31983
|
+
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}.`;
|
|
31668
31984
|
return ok({
|
|
31669
31985
|
slug,
|
|
31670
31986
|
kind,
|
|
@@ -31684,7 +32000,11 @@ registerTool("make_video", {
|
|
|
31684
32000
|
if (kind === "short") {
|
|
31685
32001
|
await client.post(`/shorts/${slug}/generate`, args.skip_auto_text ? { skip_auto_text: true } : undefined, `gen-short:${slug}:${startState}${args.skip_auto_text ? ":bare" : ""}`);
|
|
31686
32002
|
} else {
|
|
31687
|
-
|
|
32003
|
+
const approvedCap = args.max_credits ?? estimated_credits;
|
|
32004
|
+
if (approvedCap === null) {
|
|
32005
|
+
throw new Error("Editor autopilot launch requires max_credits or a live quote");
|
|
32006
|
+
}
|
|
32007
|
+
await client.post(`/editor/${slug}/autopilot`, { max_credits: approvedCap }, makeVideoAutopilotKey(slug, args, startState));
|
|
31688
32008
|
}
|
|
31689
32009
|
await reportProgress(extra, 0, `started ${kind} ${slug}`);
|
|
31690
32010
|
const status = await pollToTerminal(client, kind, slug, extra, budgetMs, 15000);
|
|
@@ -32133,7 +32453,7 @@ registerTool("generate_short", {
|
|
|
32133
32453
|
}));
|
|
32134
32454
|
registerTool("rerender_short", {
|
|
32135
32455
|
title: "Re-render a short (FREE — 0 credits)",
|
|
32136
|
-
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; the pinned music bed is
|
|
32456
|
+
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).",
|
|
32137
32457
|
inputSchema: {
|
|
32138
32458
|
slug: exports_external.string().describe("Short slug (from create_short)")
|
|
32139
32459
|
},
|
|
@@ -32373,8 +32693,8 @@ registerTool("edit_slider_slide", {
|
|
|
32373
32693
|
}
|
|
32374
32694
|
}));
|
|
32375
32695
|
registerTool("create_editor_ad", {
|
|
32376
|
-
title: "Create a multi-scene editor ad
|
|
32377
|
-
description: "Creates an editor project
|
|
32696
|
+
title: "Create, configure, and price a multi-scene editor ad",
|
|
32697
|
+
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.",
|
|
32378
32698
|
inputSchema: {
|
|
32379
32699
|
product_prompt: exports_external.string().min(10).describe("Brief for the ad (min 10 chars)"),
|
|
32380
32700
|
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."),
|
|
@@ -32384,42 +32704,140 @@ registerTool("create_editor_ad", {
|
|
|
32384
32704
|
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."),
|
|
32385
32705
|
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.'),
|
|
32386
32706
|
voice_id: exports_external.string().optional().describe("Preferred narration voice id (see list_voices); omit for the default voice"),
|
|
32387
|
-
export_aspect_ratio: exports_external.enum(["9:16", "16:9", "1:1"]).optional().describe('Aspect ratio (default "9:16")')
|
|
32707
|
+
export_aspect_ratio: exports_external.enum(["9:16", "16:9", "1:1"]).optional().describe('Aspect ratio (default "9:16")'),
|
|
32708
|
+
product_image_path: exports_external.string().optional().describe("Local product image (.jpg/.jpeg/.png, ≤8 MiB) to attach before autopilot."),
|
|
32709
|
+
product_description: exports_external.string().max(500).optional().describe("Exact product/character description used with product_image_path."),
|
|
32710
|
+
closing_image_path: exports_external.string().optional().describe("Local closing-card image (.jpg/.jpeg/.png, ≤20 MiB)."),
|
|
32711
|
+
logo_path: exports_external.string().optional().describe("Local logo (.jpg/.jpeg/.png, ≤1 MiB) to overlay."),
|
|
32712
|
+
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.'),
|
|
32713
|
+
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.'),
|
|
32714
|
+
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."),
|
|
32715
|
+
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."),
|
|
32716
|
+
max_credits: exports_external.number().int().nonnegative().optional().describe("Explicit spend cap. Omit to create/configure/price only; no credits are charged.")
|
|
32388
32717
|
},
|
|
32389
32718
|
outputSchema: createEditorOutput,
|
|
32390
32719
|
annotations: { title: "Create editor ad", ...WRITE, idempotentHint: true }
|
|
32391
32720
|
}, tool(async (args, client) => {
|
|
32392
|
-
const
|
|
32393
|
-
|
|
32394
|
-
|
|
32395
|
-
|
|
32396
|
-
|
|
32397
|
-
|
|
32398
|
-
|
|
32399
|
-
|
|
32400
|
-
|
|
32401
|
-
|
|
32402
|
-
|
|
32403
|
-
|
|
32404
|
-
|
|
32405
|
-
|
|
32406
|
-
|
|
32407
|
-
|
|
32408
|
-
|
|
32409
|
-
|
|
32410
|
-
|
|
32411
|
-
|
|
32412
|
-
|
|
32721
|
+
const prepared = [];
|
|
32722
|
+
let productImage;
|
|
32723
|
+
let closingImage;
|
|
32724
|
+
let logo;
|
|
32725
|
+
try {
|
|
32726
|
+
const langErr = validateLanguage(args.language);
|
|
32727
|
+
if (langErr)
|
|
32728
|
+
return fail(langErr);
|
|
32729
|
+
const promptErr = validateProductPrompt(args.product_prompt);
|
|
32730
|
+
if (promptErr)
|
|
32731
|
+
return fail(promptErr);
|
|
32732
|
+
if (args.cast_mode === "product_only" && !args.product_image_path) {
|
|
32733
|
+
return fail("cast_mode product_only requires product_image_path so identity can be locked before generation.");
|
|
32734
|
+
}
|
|
32735
|
+
if ((args.logo_treatment !== undefined || args.logo_position !== undefined || args.logo_duration_seconds !== undefined) && !args.logo_path) {
|
|
32736
|
+
return fail("logo_treatment/logo_position/logo_duration_seconds require logo_path (the logo image they style). Provide logo_path or drop these settings.");
|
|
32737
|
+
}
|
|
32738
|
+
if (args.product_description !== undefined && !args.product_image_path) {
|
|
32739
|
+
return fail("product_description requires product_image_path (the product image it describes). Provide product_image_path or drop product_description.");
|
|
32740
|
+
}
|
|
32741
|
+
if (args.product_image_path) {
|
|
32742
|
+
productImage = await prepareImageUpload(args.product_image_path, MAX_PRODUCT_IMAGE_BYTES);
|
|
32743
|
+
prepared.push(productImage);
|
|
32744
|
+
}
|
|
32745
|
+
if (args.closing_image_path) {
|
|
32746
|
+
closingImage = await prepareImageUpload(args.closing_image_path, MAX_CLOSING_IMAGE_BYTES);
|
|
32747
|
+
prepared.push(closingImage);
|
|
32748
|
+
}
|
|
32749
|
+
if (args.logo_path) {
|
|
32750
|
+
logo = await prepareImageUpload(args.logo_path, MAX_LOGO_BYTES);
|
|
32751
|
+
prepared.push(logo);
|
|
32752
|
+
}
|
|
32753
|
+
const created = await client.post("/editor", {
|
|
32754
|
+
language: args.language ?? "en",
|
|
32755
|
+
product_prompt: args.product_prompt,
|
|
32756
|
+
product_subject: args.product_subject,
|
|
32757
|
+
project_intent: args.project_intent,
|
|
32758
|
+
creative_format: args.creative_format,
|
|
32759
|
+
visual_language: args.visual_language,
|
|
32760
|
+
theme: args.theme,
|
|
32761
|
+
voice_id: args.voice_id,
|
|
32762
|
+
export_aspect_ratio: args.export_aspect_ratio,
|
|
32763
|
+
cast_mode: args.cast_mode
|
|
32764
|
+
}, editorAdCreateKey({
|
|
32765
|
+
...args,
|
|
32766
|
+
product_image_identity: productImage ? preparedUploadIdentity(productImage) : undefined,
|
|
32767
|
+
closing_image_identity: closingImage ? preparedUploadIdentity(closingImage) : undefined,
|
|
32768
|
+
logo_identity: logo ? preparedUploadIdentity(logo) : undefined
|
|
32769
|
+
}));
|
|
32770
|
+
const slug = created.data.slug;
|
|
32771
|
+
let configured = {};
|
|
32772
|
+
if (args.product_image_path || args.closing_image_path || args.logo_path) {
|
|
32773
|
+
const current = await client.get(`/editor/${slug}`);
|
|
32774
|
+
configured = asRecord(asRecord(current).data);
|
|
32775
|
+
}
|
|
32776
|
+
if (productImage && !configured.product_url) {
|
|
32777
|
+
const { s3_key } = await uploadImageFile(client, `/editor/${slug}/product/presign`, productImage, MAX_PRODUCT_IMAGE_BYTES);
|
|
32778
|
+
await client.post(`/editor/${slug}/product/confirm`, {
|
|
32779
|
+
s3_key,
|
|
32780
|
+
product_description: args.product_description
|
|
32781
|
+
});
|
|
32782
|
+
}
|
|
32783
|
+
if (closingImage && !configured.closing_image_url) {
|
|
32784
|
+
const { s3_key } = await uploadImageFile(client, `/editor/${slug}/closing-image/presign`, closingImage, MAX_CLOSING_IMAGE_BYTES);
|
|
32785
|
+
await client.post(`/editor/${slug}/closing-image/confirm`, {
|
|
32786
|
+
s3_key
|
|
32787
|
+
});
|
|
32788
|
+
}
|
|
32789
|
+
if (logo && !configured.logo_url) {
|
|
32790
|
+
const { s3_key } = await uploadImageFile(client, `/editor/${slug}/logo/presign`, logo, MAX_LOGO_BYTES);
|
|
32791
|
+
await client.post(`/editor/${slug}/logo/confirm`, { s3_key });
|
|
32792
|
+
}
|
|
32793
|
+
const logoSettings = {};
|
|
32794
|
+
if (args.logo_treatment !== undefined)
|
|
32795
|
+
logoSettings.logo_treatment = args.logo_treatment;
|
|
32796
|
+
if (args.logo_position !== undefined)
|
|
32797
|
+
logoSettings.logo_position = args.logo_position;
|
|
32798
|
+
if (args.logo_duration_seconds !== undefined)
|
|
32799
|
+
logoSettings.logo_duration_seconds = args.logo_duration_seconds;
|
|
32800
|
+
if (args.logo_path && Object.keys(logoSettings).length > 0)
|
|
32801
|
+
await client.patch(`/editor/${slug}/logo`, logoSettings);
|
|
32802
|
+
const quote = await editorAutopilotQuote(client, slug);
|
|
32803
|
+
const overCap = args.max_credits !== undefined && quote.estimated_credits !== null && quote.estimated_credits > args.max_credits;
|
|
32804
|
+
if (args.max_credits === undefined || quote.estimated_credits === null || overCap || !quote.affordable) {
|
|
32805
|
+
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.`;
|
|
32806
|
+
return ok({
|
|
32807
|
+
slug,
|
|
32808
|
+
kind: "editor",
|
|
32809
|
+
...quote,
|
|
32810
|
+
charged: false,
|
|
32811
|
+
note,
|
|
32812
|
+
next: "start_autopilot"
|
|
32813
|
+
});
|
|
32814
|
+
}
|
|
32815
|
+
const started = await client.post(`/editor/${slug}/autopilot`, { max_credits: args.max_credits }, editorAdAutopilotKey(slug, args));
|
|
32816
|
+
const status = normalizeStatus("editor", slug, asRecord(started).data);
|
|
32817
|
+
return ok({
|
|
32818
|
+
slug,
|
|
32819
|
+
kind: "editor",
|
|
32820
|
+
status,
|
|
32821
|
+
...quote,
|
|
32822
|
+
charged: true,
|
|
32823
|
+
next: "wait_for_completion"
|
|
32824
|
+
});
|
|
32825
|
+
} finally {
|
|
32826
|
+
await Promise.all(prepared.map(closePreparedUploadFile));
|
|
32827
|
+
}
|
|
32413
32828
|
}));
|
|
32414
32829
|
registerTool("start_autopilot", {
|
|
32415
32830
|
title: "Start autopilot on an existing editor draft",
|
|
32416
|
-
description: "Starts server-orchestrated autopilot (scenario → segments → narration → voice → music → render) on an " + "EXISTING editor project/draft.
|
|
32831
|
+
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. Failed or cancelled runs resume normally. For a completed project, set restart:true to quote and build a fresh creative run; omitting it only resumes pending edits. Then poll with " + 'wait_for_completion({ slug, kind: "editor" }).',
|
|
32417
32832
|
inputSchema: {
|
|
32418
32833
|
slug: exports_external.string().describe("Editor project slug"),
|
|
32419
32834
|
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."),
|
|
32420
32835
|
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."),
|
|
32421
|
-
product_prompt: exports_external.string().optional().describe("Optional freeform brief to set/replace before launch.")
|
|
32836
|
+
product_prompt: exports_external.string().optional().describe("Optional freeform brief to set/replace before launch."),
|
|
32837
|
+
max_credits: exports_external.number().int().nonnegative().optional().describe("Explicit spend cap. Omit to quote only; no credits are charged."),
|
|
32838
|
+
restart: exports_external.boolean().optional().describe("Set true only on a completed project to quote/build a fresh creative run. The delivered timeline stays available until replacement apply.")
|
|
32422
32839
|
},
|
|
32840
|
+
outputSchema: createEditorOutput,
|
|
32423
32841
|
annotations: { title: "Start autopilot", ...WRITE, idempotentHint: false }
|
|
32424
32842
|
}, tool(async (args, client) => {
|
|
32425
32843
|
const langErr = validateLanguage(args.language);
|
|
@@ -32436,12 +32854,27 @@ registerTool("start_autopilot", {
|
|
|
32436
32854
|
overrides.product_subject = args.product_subject;
|
|
32437
32855
|
if (args.product_prompt !== undefined)
|
|
32438
32856
|
overrides.product_prompt = args.product_prompt;
|
|
32439
|
-
|
|
32857
|
+
if (args.restart)
|
|
32858
|
+
overrides.restart = true;
|
|
32859
|
+
const quote = await editorAutopilotQuote(client, slug, overrides);
|
|
32860
|
+
const overCap = args.max_credits !== undefined && quote.estimated_credits !== null && quote.estimated_credits > args.max_credits;
|
|
32861
|
+
if (args.max_credits === undefined || quote.estimated_credits === null || overCap || !quote.affordable) {
|
|
32862
|
+
return ok({
|
|
32863
|
+
slug,
|
|
32864
|
+
kind: "editor",
|
|
32865
|
+
...quote,
|
|
32866
|
+
charged: false,
|
|
32867
|
+
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.`
|
|
32868
|
+
});
|
|
32869
|
+
}
|
|
32870
|
+
const started = await client.post(`/editor/${slug}/autopilot`, { ...overrides, max_credits: args.max_credits }, idemKey("autopilot", slug, randomUUID()));
|
|
32440
32871
|
const status = normalizeStatus("editor", slug, asRecord(started).data);
|
|
32441
32872
|
return ok({
|
|
32442
32873
|
slug,
|
|
32443
32874
|
kind: "editor",
|
|
32444
32875
|
status,
|
|
32876
|
+
...quote,
|
|
32877
|
+
charged: true,
|
|
32445
32878
|
next: "wait_for_completion"
|
|
32446
32879
|
});
|
|
32447
32880
|
}));
|
|
@@ -32463,7 +32896,7 @@ registerTool("unlock_ai_assists", {
|
|
|
32463
32896
|
}, tool(async (_args, client) => ok(await client.post("/ai-assists/unlock", undefined, await unlockKey(client)))));
|
|
32464
32897
|
registerTool("create_editor_draft", {
|
|
32465
32898
|
title: "Create an editor draft (no autopilot)",
|
|
32466
|
-
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
|
|
32899
|
+
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).",
|
|
32467
32900
|
inputSchema: {
|
|
32468
32901
|
product_prompt: exports_external.string().min(10).max(5000).optional().describe("Brief for the ad — 10–5000 chars, or omit entirely"),
|
|
32469
32902
|
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."),
|
|
@@ -32703,7 +33136,10 @@ registerTool("regenerate_segment", {
|
|
|
32703
33136
|
}
|
|
32704
33137
|
}, tool(async (args, client) => {
|
|
32705
33138
|
const res = await client.post(`/editor/${args.slug}/segments/${String(args.segment_id)}/regenerate`, args.prompt !== undefined ? { prompt: args.prompt } : undefined, undefined);
|
|
32706
|
-
return ok(
|
|
33139
|
+
return ok({
|
|
33140
|
+
...asRecord(asRecord(res).data ?? res),
|
|
33141
|
+
charge_receipt: await editorChargeReceipt(client, args.slug, "scene_regeneration", 4)
|
|
33142
|
+
});
|
|
32707
33143
|
}));
|
|
32708
33144
|
registerTool("generate_all_segments", {
|
|
32709
33145
|
title: "Generate all pending segments (sequential)",
|
|
@@ -32820,7 +33256,10 @@ registerTool("generate_voice", {
|
|
|
32820
33256
|
annotations: { title: "Generate voice", ...WRITE, idempotentHint: true }
|
|
32821
33257
|
}, tool(async (args, client) => {
|
|
32822
33258
|
const res = await client.post(`/editor/${args.slug}/generate-voice`, { voice_id: args.voice_id }, await voiceKey(client, args.slug, args.voice_id));
|
|
32823
|
-
return ok(
|
|
33259
|
+
return ok({
|
|
33260
|
+
...asRecord(asRecord(res).data ?? res),
|
|
33261
|
+
charge_receipt: await editorChargeReceipt(client, args.slug, "voice_generation", 3)
|
|
33262
|
+
});
|
|
32824
33263
|
}));
|
|
32825
33264
|
registerTool("generate_music", {
|
|
32826
33265
|
title: "Generate the background music (5 credits)",
|
|
@@ -32847,11 +33286,14 @@ registerTool("generate_music", {
|
|
|
32847
33286
|
if (args.instruments !== undefined)
|
|
32848
33287
|
body.instruments = args.instruments;
|
|
32849
33288
|
const res = await client.post(`/editor/${args.slug}/generate-music`, body, undefined);
|
|
32850
|
-
return ok(
|
|
33289
|
+
return ok({
|
|
33290
|
+
...asRecord(asRecord(res).data ?? res),
|
|
33291
|
+
charge_receipt: await editorChargeReceipt(client, args.slug, "music_generation", 5)
|
|
33292
|
+
});
|
|
32851
33293
|
}));
|
|
32852
33294
|
registerTool("render", {
|
|
32853
33295
|
title: "Render the final editor video (0 credits)",
|
|
32854
|
-
description: "Renders the final MP4 from the generated segments + voice + music. Costs 0 credits (any still-ungenerated " + "segments are auto-charged at generation cost first). Re-rendering reflects the current segments/voice/music (it does not replay a prior render). Returns the latest_render; " + 'pair with wait_for_completion({ slug, kind: "editor" }) then download_result. ' + "STALENESS: editing the scenario or narration AFTER generating voice/music marks those audio tracks stale, and render then 422s with editor_narration_stale / editor_voice_stale / editor_music_stale — regenerate the named track (generate_narration → generate_voice, and/or generate_music) before rendering.",
|
|
33296
|
+
description: "Renders the final MP4 from the generated segments + voice + music. Costs 0 credits (any still-ungenerated " + "segments are auto-charged at generation cost first). Render NEVER generates or charges for voice/music; a new " + "music charge comes only from generate_music or an already-running autopilot child job. Re-rendering reflects the current segments/voice/music (it does not replay a prior render). Returns the latest_render; " + 'pair with wait_for_completion({ slug, kind: "editor" }) then download_result. ' + "STALENESS: editing the scenario or narration AFTER generating voice/music marks those audio tracks stale, and render then 422s with editor_narration_stale / editor_voice_stale / editor_music_stale — regenerate the named track (generate_narration → generate_voice, and/or generate_music) before rendering.",
|
|
32855
33297
|
inputSchema: { slug: exports_external.string().describe("Editor project slug") },
|
|
32856
33298
|
annotations: { title: "Render", ...WRITE, idempotentHint: false }
|
|
32857
33299
|
}, tool(async (args, client) => {
|
|
@@ -32965,7 +33407,7 @@ async function pollUploadToReady(client, slug, uploadId, extra, budgetMs, interv
|
|
|
32965
33407
|
}
|
|
32966
33408
|
registerTool("upload_video", {
|
|
32967
33409
|
title: "Upload a local video clip into an editor project",
|
|
32968
|
-
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.",
|
|
33410
|
+
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.",
|
|
32969
33411
|
inputSchema: {
|
|
32970
33412
|
slug: exports_external.string().describe("Editor project slug"),
|
|
32971
33413
|
file_path: exports_external.string().describe("Local video file path (inside HUBFLUENCER_INPUT_DIR or cwd)"),
|
|
@@ -33038,7 +33480,7 @@ registerTool("add_segment_from_upload", {
|
|
|
33038
33480
|
}));
|
|
33039
33481
|
registerTool("use_asset_for_segment", {
|
|
33040
33482
|
title: "Reuse a clip/asset for a specific scene (0 credits)",
|
|
33041
|
-
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.",
|
|
33483
|
+
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.",
|
|
33042
33484
|
inputSchema: {
|
|
33043
33485
|
slug: exports_external.string().describe("Editor project slug"),
|
|
33044
33486
|
segment_id: exports_external.union([exports_external.number(), exports_external.string()]).describe("Target scene id (from get_editor)"),
|
|
@@ -33058,11 +33500,40 @@ registerTool("use_asset_for_segment", {
|
|
|
33058
33500
|
}
|
|
33059
33501
|
const body = hasUpload ? { upload_id: args.upload_id } : { source_segment_id: args.source_segment_id };
|
|
33060
33502
|
const res = await client.post(`/editor/${args.slug}/segments/${String(args.segment_id)}/use-asset`, body);
|
|
33061
|
-
|
|
33503
|
+
const segment = asRecord(asRecord(res).data ?? res);
|
|
33504
|
+
const impact = asRecord(segment.impact);
|
|
33505
|
+
const staleSegmentIds = Array.isArray(impact.stale_video_segment_ids) ? impact.stale_video_segment_ids : [];
|
|
33506
|
+
const requiresRegeneration = impact.requires_regeneration === true || staleSegmentIds.length > 0;
|
|
33507
|
+
let timeline;
|
|
33508
|
+
try {
|
|
33509
|
+
const current = await client.get(`/editor/${args.slug}`);
|
|
33510
|
+
const editor = asRecord(asRecord(current).data);
|
|
33511
|
+
timeline = {
|
|
33512
|
+
narration_stale: editor.narration_stale === true,
|
|
33513
|
+
voice_stale: editor.voice_stale === true,
|
|
33514
|
+
music_stale: editor.music_stale === true,
|
|
33515
|
+
stale_segment_ids: staleSegmentIds,
|
|
33516
|
+
requires_regeneration: requiresRegeneration
|
|
33517
|
+
};
|
|
33518
|
+
} catch {
|
|
33519
|
+
timeline = {
|
|
33520
|
+
narration_stale: null,
|
|
33521
|
+
voice_stale: null,
|
|
33522
|
+
music_stale: null,
|
|
33523
|
+
stale_segment_ids: staleSegmentIds,
|
|
33524
|
+
requires_regeneration: requiresRegeneration,
|
|
33525
|
+
enrichment_unavailable: true
|
|
33526
|
+
};
|
|
33527
|
+
}
|
|
33528
|
+
return ok({
|
|
33529
|
+
...segment,
|
|
33530
|
+
timeline,
|
|
33531
|
+
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."
|
|
33532
|
+
});
|
|
33062
33533
|
}));
|
|
33063
33534
|
registerTool("set_product", {
|
|
33064
33535
|
title: "Attach a product image (from a local file)",
|
|
33065
|
-
description: "Uploads a local product image and attaches it to the project as the product. Accepted: " + IMAGE_EXTS.map((e) => `.${e}`).join(", ") + " (≤
|
|
33536
|
+
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.",
|
|
33066
33537
|
inputSchema: {
|
|
33067
33538
|
slug: exports_external.string().describe("Editor project slug"),
|
|
33068
33539
|
file_path: exports_external.string().describe("Local product image path (.jpg/.jpeg/.png)"),
|
|
@@ -33070,7 +33541,7 @@ registerTool("set_product", {
|
|
|
33070
33541
|
},
|
|
33071
33542
|
annotations: { title: "Set product", ...WRITE, idempotentHint: false }
|
|
33072
33543
|
}, tool(async (args, client) => {
|
|
33073
|
-
const { s3_key } = await uploadImageFile(client, `/editor/${args.slug}/product/presign`, args.file_path);
|
|
33544
|
+
const { s3_key } = await uploadImageFile(client, `/editor/${args.slug}/product/presign`, args.file_path, MAX_PRODUCT_IMAGE_BYTES);
|
|
33074
33545
|
const res = await client.post(`/editor/${args.slug}/product/confirm`, { s3_key, product_description: args.description });
|
|
33075
33546
|
return ok(asRecord(res).data ?? res);
|
|
33076
33547
|
}));
|
|
@@ -33092,7 +33563,7 @@ registerTool("set_product_placement", {
|
|
|
33092
33563
|
}));
|
|
33093
33564
|
registerTool("set_closing_image", {
|
|
33094
33565
|
title: "Set the closing-card image (0 credits)",
|
|
33095
|
-
description: "Sets the optional ~2s end-card image. Either upload a local file (file_path; .jpg/.jpeg/.png ≤20
|
|
33566
|
+
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).",
|
|
33096
33567
|
inputSchema: {
|
|
33097
33568
|
slug: exports_external.string().describe("Editor project slug"),
|
|
33098
33569
|
file_path: exports_external.string().optional().describe("Local image to upload as the end card"),
|
|
@@ -33112,13 +33583,13 @@ registerTool("set_closing_image", {
|
|
|
33112
33583
|
if (!args.file_path) {
|
|
33113
33584
|
return fail("Provide file_path (a local image) or from_product:true.");
|
|
33114
33585
|
}
|
|
33115
|
-
const { s3_key } = await uploadImageFile(client, `/editor/${args.slug}/closing-image/presign`, args.file_path);
|
|
33586
|
+
const { s3_key } = await uploadImageFile(client, `/editor/${args.slug}/closing-image/presign`, args.file_path, MAX_CLOSING_IMAGE_BYTES);
|
|
33116
33587
|
const res = await client.post(`/editor/${args.slug}/closing-image/confirm`, { s3_key });
|
|
33117
33588
|
return ok(asRecord(res).data ?? res);
|
|
33118
33589
|
}));
|
|
33119
33590
|
registerTool("set_logo", {
|
|
33120
33591
|
title: "Set a brand logo overlay (0 credits)",
|
|
33121
|
-
description: "Uploads a local brand logo and overlays it on the video. Accepted: .png/.jpg/.jpeg (≤
|
|
33592
|
+
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.",
|
|
33122
33593
|
inputSchema: {
|
|
33123
33594
|
slug: exports_external.string().describe("Editor project slug"),
|
|
33124
33595
|
file_path: exports_external.string().describe("Local logo image (.png/.jpg/.jpeg)"),
|
|
@@ -33128,7 +33599,7 @@ registerTool("set_logo", {
|
|
|
33128
33599
|
},
|
|
33129
33600
|
annotations: { title: "Set logo", ...WRITE, idempotentHint: false }
|
|
33130
33601
|
}, tool(async (args, client) => {
|
|
33131
|
-
const { s3_key } = await uploadImageFile(client, `/editor/${args.slug}/logo/presign`, args.file_path);
|
|
33602
|
+
const { s3_key } = await uploadImageFile(client, `/editor/${args.slug}/logo/presign`, args.file_path, MAX_LOGO_BYTES);
|
|
33132
33603
|
const confirmed = await client.post(`/editor/${args.slug}/logo/confirm`, { s3_key });
|
|
33133
33604
|
let result = asRecord(confirmed).data ?? confirmed;
|
|
33134
33605
|
const settings = {};
|
|
@@ -33146,7 +33617,7 @@ registerTool("set_logo", {
|
|
|
33146
33617
|
}));
|
|
33147
33618
|
registerTool("set_short_product", {
|
|
33148
33619
|
title: "Attach a product image to a short (0 credits)",
|
|
33149
|
-
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
|
|
33620
|
+
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.)",
|
|
33150
33621
|
inputSchema: {
|
|
33151
33622
|
slug: exports_external.string().describe("Short slug (from create_short)"),
|
|
33152
33623
|
file_path: exports_external.string().describe("Local product image path (.jpg/.jpeg/.png)"),
|
|
@@ -33164,7 +33635,7 @@ registerTool("set_short_product", {
|
|
|
33164
33635
|
}));
|
|
33165
33636
|
registerTool("set_short_poster", {
|
|
33166
33637
|
title: "Set a short's end-card poster image (0 credits)",
|
|
33167
|
-
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
|
|
33638
|
+
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.)",
|
|
33168
33639
|
inputSchema: {
|
|
33169
33640
|
slug: exports_external.string().describe("Short slug (from create_short)"),
|
|
33170
33641
|
file_path: exports_external.string().describe("Local poster image (.jpg/.jpeg/.png)"),
|
|
@@ -33191,7 +33662,7 @@ registerTool("get_status", {
|
|
|
33191
33662
|
}));
|
|
33192
33663
|
registerTool("wait_for_completion", {
|
|
33193
33664
|
title: "Wait for a render to finish",
|
|
33194
|
-
description: "Polls status (emitting progress) until terminal
|
|
33665
|
+
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).",
|
|
33195
33666
|
inputSchema: {
|
|
33196
33667
|
slug: exports_external.string(),
|
|
33197
33668
|
kind: kindSchema,
|
|
@@ -33766,7 +34237,7 @@ registerTool("materialize_episode", {
|
|
|
33766
34237
|
const episode = summarizeEpisode(asRecord(res).data);
|
|
33767
34238
|
return ok({
|
|
33768
34239
|
...episode,
|
|
33769
|
-
next: episode.draft_slug ? "
|
|
34240
|
+
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."
|
|
33770
34241
|
});
|
|
33771
34242
|
}));
|
|
33772
34243
|
registerTool("get_series_dashboard", {
|
|
@@ -34116,7 +34587,7 @@ registerTool("get_asset_quota", {
|
|
|
34116
34587
|
}));
|
|
34117
34588
|
registerTool("upload_asset", {
|
|
34118
34589
|
title: "Upload a local file to your reusable asset catalog",
|
|
34119
|
-
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.",
|
|
34590
|
+
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.",
|
|
34120
34591
|
inputSchema: {
|
|
34121
34592
|
file_path: exports_external.string().describe("Path to a local image/video, inside HUBFLUENCER_INPUT_DIR (default: cwd)"),
|
|
34122
34593
|
description: exports_external.string().optional().describe("Optional description stored with the asset"),
|