@hubfluencer/mcp 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -15
- package/dist/index.js +723 -203
- package/package.json +1 -1
- package/src/campaign.ts +1 -1
- package/src/client.ts +4 -1
- package/src/core.ts +329 -16
- package/src/index.ts +819 -123
- package/src/output-schemas.ts +21 -2
- package/src/uploads.ts +401 -163
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hubfluencer/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Model Context Protocol server for Hubfluencer — let AI agents generate post-ready shorts and editor ads.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Monocursive <contact@monocursive.com>",
|
package/src/campaign.ts
CHANGED
|
@@ -686,7 +686,7 @@ export async function runCreateCampaign(
|
|
|
686
686
|
items,
|
|
687
687
|
summary,
|
|
688
688
|
steps,
|
|
689
|
-
next: `get_campaign_pack({ id: ${campaignPackId} }) to review the drafts; then
|
|
689
|
+
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.`,
|
|
690
690
|
note:
|
|
691
691
|
`Campaign pack ${campaignPackId} created with ${summary.created} item(s), ${draftable} materialized into editable DRAFTS. ` +
|
|
692
692
|
(positioning ? `Positioning: ${positioning}. ` : "") +
|
package/src/client.ts
CHANGED
|
@@ -251,7 +251,10 @@ export class HubfluencerClient {
|
|
|
251
251
|
);
|
|
252
252
|
}
|
|
253
253
|
throw e instanceof Error
|
|
254
|
-
?
|
|
254
|
+
? Object.assign(
|
|
255
|
+
new Error(`Network error on ${method} ${path}: ${e.message}`),
|
|
256
|
+
{ retryable: isRetryableNetworkError(e) },
|
|
257
|
+
)
|
|
255
258
|
: e;
|
|
256
259
|
}
|
|
257
260
|
|
package/src/core.ts
CHANGED
|
@@ -265,6 +265,20 @@ export interface NormalizedStatus {
|
|
|
265
265
|
ready: boolean;
|
|
266
266
|
video_url: string | null;
|
|
267
267
|
error: string | null;
|
|
268
|
+
/** Editor only: the newest delivered render no longer matches the live project. */
|
|
269
|
+
stale?: boolean;
|
|
270
|
+
/** Editor only: current scene ids that are still generating. */
|
|
271
|
+
active_segment_ids?: Array<number | string>;
|
|
272
|
+
/** Editor only: completed current scene ids invalidated by continuity edits. */
|
|
273
|
+
stale_segment_ids?: Array<number | string>;
|
|
274
|
+
/** Short only (additive, absent on older APIs): the newest render row is a free re-render. */
|
|
275
|
+
latest_render_free?: boolean;
|
|
276
|
+
/**
|
|
277
|
+
* Short only (additive): the newest render row is a FAILED free re-render.
|
|
278
|
+
* The delivered video stays intact (stage/latest_render fall back to the
|
|
279
|
+
* latest completed render), so the agent can just retry rerender_short — free.
|
|
280
|
+
*/
|
|
281
|
+
last_free_rerender_failed?: boolean;
|
|
268
282
|
}
|
|
269
283
|
|
|
270
284
|
/** The structured fields of an API error envelope errMessage formats from. */
|
|
@@ -318,7 +332,7 @@ export function normalizeStatus(
|
|
|
318
332
|
|
|
319
333
|
if (kind === "short") {
|
|
320
334
|
const stage = (d.stage as string) ?? "unknown";
|
|
321
|
-
|
|
335
|
+
const status: NormalizedStatus = {
|
|
322
336
|
kind,
|
|
323
337
|
slug,
|
|
324
338
|
stage,
|
|
@@ -327,6 +341,19 @@ export function normalizeStatus(
|
|
|
327
341
|
video_url: videoUrl,
|
|
328
342
|
error: (d.error_message as string) ?? null,
|
|
329
343
|
};
|
|
344
|
+
// Additive free-re-render bookkeeping (2026-07 API): surfaced only when
|
|
345
|
+
// the server actually sends the booleans, so older payloads normalize
|
|
346
|
+
// byte-identically. A failed free re-render does NOT flip stage to
|
|
347
|
+
// "failed" server-side — the delivered video stays visible — so these
|
|
348
|
+
// flags are the only signal that the newest render row was a free
|
|
349
|
+
// re-render (or a failed one worth retrying, still free).
|
|
350
|
+
if (typeof d.latest_render_free === "boolean") {
|
|
351
|
+
status.latest_render_free = d.latest_render_free;
|
|
352
|
+
}
|
|
353
|
+
if (typeof d.last_free_rerender_failed === "boolean") {
|
|
354
|
+
status.last_free_rerender_failed = d.last_free_rerender_failed;
|
|
355
|
+
}
|
|
356
|
+
return status;
|
|
330
357
|
}
|
|
331
358
|
|
|
332
359
|
if (kind === "tracking") {
|
|
@@ -374,23 +401,249 @@ export function normalizeStatus(
|
|
|
374
401
|
};
|
|
375
402
|
}
|
|
376
403
|
|
|
377
|
-
// editor
|
|
404
|
+
// editor. A completed render is only "ready" when it still represents the
|
|
405
|
+
// CURRENT timeline and no newer scene/audio work is active or stale. The API
|
|
406
|
+
// intentionally keeps the last successful MP4 available after edits; treating
|
|
407
|
+
// that historical delivery as current made get_status/wait_for_completion
|
|
408
|
+
// return immediately while paid regenerations were still running.
|
|
378
409
|
const autopilot = (d.autopilot_status as string) ?? "unknown";
|
|
379
410
|
const renderStatus = (latest.status as string) ?? null;
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
411
|
+
const segments = Array.isArray(d.segments) ? d.segments.map(asRecord) : [];
|
|
412
|
+
// "Active" means a scene is genuinely mid-generation. Crucially it must NOT
|
|
413
|
+
// include the *idle* defaults: segment.status "pending" and preview_status
|
|
414
|
+
// "pending" are the never-generated / cleared-preview defaults the API stamps
|
|
415
|
+
// on every fresh scene (video_segment.ex: status default "pending",
|
|
416
|
+
// preview_status default "pending"). Counting either as active made a fully
|
|
417
|
+
// idle draft report stage "segment_processing" with every scene in
|
|
418
|
+
// active_segment_ids, so wait_for_completion burned its whole budget on a
|
|
419
|
+
// project that isn't generating anything. The real in-flight states are
|
|
420
|
+
// segment.status "processing" and preview_status "generating" (its wire enum
|
|
421
|
+
// is pending|generating|completed|failed|stale — "processing" is kept only as
|
|
422
|
+
// a defensive alias, it never appears).
|
|
423
|
+
const activeSegments = segments.filter((segment) => {
|
|
424
|
+
const status = segment.status as string | undefined;
|
|
425
|
+
const preview = segment.preview_status as string | undefined;
|
|
426
|
+
return (
|
|
427
|
+
status === "processing" ||
|
|
428
|
+
preview === "processing" ||
|
|
429
|
+
preview === "generating"
|
|
430
|
+
);
|
|
431
|
+
});
|
|
432
|
+
const staleSegments = segments.filter(
|
|
433
|
+
(segment) => segment.video_stale === true && segment.status === "completed",
|
|
434
|
+
);
|
|
435
|
+
const failedSegments = segments.filter(
|
|
436
|
+
(segment) => segment.status === "failed",
|
|
437
|
+
);
|
|
438
|
+
const batchStatus = (d.batch_generation_status as string) ?? null;
|
|
439
|
+
// The batch enum's ACTIVE states (video_factory.ex): preparing (spinning up),
|
|
440
|
+
// awaiting_previews (previews generating), running, and paused_for_retry
|
|
441
|
+
// (auto-resuming). "queued" is not a real value and was dropped; "preparing"
|
|
442
|
+
// and "awaiting_previews" were missing, so an in-flight batch briefly looked
|
|
443
|
+
// idle and could be reported terminal mid-run.
|
|
444
|
+
const batchActive =
|
|
445
|
+
batchStatus === "running" ||
|
|
446
|
+
batchStatus === "preparing" ||
|
|
447
|
+
batchStatus === "awaiting_previews" ||
|
|
448
|
+
batchStatus === "paused_for_retry";
|
|
449
|
+
// A parked batch that ran out of credits: it cannot progress without the user
|
|
450
|
+
// topping up or reducing scope, so it is a needs-user-action TERMINAL state
|
|
451
|
+
// (not active work to wait on).
|
|
452
|
+
const batchInsufficientCredits =
|
|
453
|
+
batchStatus === "paused_insufficient_credits";
|
|
454
|
+
const batchFailed = batchStatus === "failed";
|
|
455
|
+
const autopilotActive = [
|
|
456
|
+
"pending",
|
|
457
|
+
"running",
|
|
458
|
+
"generating",
|
|
459
|
+
"rendering",
|
|
460
|
+
].includes(autopilot);
|
|
461
|
+
const audioStale =
|
|
462
|
+
d.narration_stale === true ||
|
|
463
|
+
d.voice_stale === true ||
|
|
464
|
+
d.music_stale === true;
|
|
465
|
+
const renderStale = latest.is_stale === true;
|
|
466
|
+
// Only an explicit false proves the completed render matches the live editor.
|
|
467
|
+
// Legacy/null/omitted freshness is unknown and must never be promoted to ready.
|
|
468
|
+
const renderFresh = latest.is_stale === false;
|
|
469
|
+
const stale = renderStale || staleSegments.length > 0 || audioStale;
|
|
470
|
+
// A render in flight is active work too — fold it into workActive so the
|
|
471
|
+
// terminal computation below can never fire while a render is queued/running.
|
|
472
|
+
// "pending" here is NOT an idle default: a video_results row only exists once
|
|
473
|
+
// a render was actually requested, so a pending latest render is queued work.
|
|
474
|
+
const renderActive =
|
|
475
|
+
renderStatus === "pending" ||
|
|
476
|
+
renderStatus === "rendering" ||
|
|
477
|
+
renderStatus === "processing";
|
|
478
|
+
// Scenario text generation / scenario apply run as async workers too
|
|
479
|
+
// (scenario_status: none|generating|ready|failed; scenario_apply_status:
|
|
480
|
+
// idle|applying|failed) — count them as active so a wait right after
|
|
481
|
+
// generate_scenario/apply_scenario doesn't bail out as an "idle" draft.
|
|
482
|
+
const scenarioActive =
|
|
483
|
+
d.scenario_status === "generating" ||
|
|
484
|
+
d.scenario_apply_status === "applying";
|
|
485
|
+
// Narration generation is a distinct async worker that runs before an
|
|
486
|
+
// AudioVersion exists. Without this gate, a project with an older completed
|
|
487
|
+
// render looks ready/terminal during narration regeneration and
|
|
488
|
+
// wait_for_completion can return that old MP4 while the new script is still
|
|
489
|
+
// being produced.
|
|
490
|
+
const narrationActive = d.narration_status === "generating";
|
|
491
|
+
const scenarioFailed = d.scenario_status === "failed";
|
|
492
|
+
const scenarioApplyFailed = d.scenario_apply_status === "failed";
|
|
493
|
+
const narrationFailed = d.narration_status === "failed";
|
|
494
|
+
// First-time generation is visible through the current selection. A
|
|
495
|
+
// regeneration intentionally keeps the prior completed version current until
|
|
496
|
+
// the replacement lands, so inspect the version histories as well; otherwise
|
|
497
|
+
// an old matching render can look ready while a paid replacement is running.
|
|
498
|
+
const currentAudio = asRecord(d.current_audio);
|
|
499
|
+
const currentMusic = asRecord(d.current_music);
|
|
500
|
+
const audioVersions = Array.isArray(d.audio_versions)
|
|
501
|
+
? d.audio_versions.map(asRecord)
|
|
502
|
+
: [];
|
|
503
|
+
const musicVersions = Array.isArray(d.music_versions)
|
|
504
|
+
? d.music_versions.map(asRecord)
|
|
505
|
+
: [];
|
|
506
|
+
const numericVersion = (version: unknown): number =>
|
|
507
|
+
typeof version === "number" && Number.isFinite(version) ? version : 0;
|
|
508
|
+
const selectedAudioVersion = numericVersion(currentAudio.version);
|
|
509
|
+
const selectedMusicVersion = numericVersion(currentMusic.version);
|
|
510
|
+
const newerAudioVersions = audioVersions.filter(
|
|
511
|
+
(version) => numericVersion(version.version) > selectedAudioVersion,
|
|
512
|
+
);
|
|
513
|
+
const newerMusicVersions = musicVersions.filter(
|
|
514
|
+
(version) => numericVersion(version.version) > selectedMusicVersion,
|
|
515
|
+
);
|
|
516
|
+
const versionActive = (version: Record<string, unknown>) =>
|
|
517
|
+
version.status === "pending" || version.status === "processing";
|
|
518
|
+
const newestVersion = (versions: Array<Record<string, unknown>>) =>
|
|
519
|
+
versions.reduce<Record<string, unknown> | null>((latest, version) => {
|
|
520
|
+
if (!latest) return version;
|
|
521
|
+
return numericVersion(version.version) > numericVersion(latest.version)
|
|
522
|
+
? version
|
|
523
|
+
: latest;
|
|
524
|
+
}, null);
|
|
525
|
+
const newestReplacementAudio = newestVersion(newerAudioVersions);
|
|
526
|
+
const newestReplacementMusic = newestVersion(newerMusicVersions);
|
|
527
|
+
const audioFailed =
|
|
528
|
+
currentAudio.status === "failed" ||
|
|
529
|
+
currentMusic.status === "failed" ||
|
|
530
|
+
newestReplacementAudio?.status === "failed" ||
|
|
531
|
+
newestReplacementMusic?.status === "failed";
|
|
532
|
+
const audioError = [
|
|
533
|
+
newestReplacementAudio?.error_message,
|
|
534
|
+
newestReplacementMusic?.error_message,
|
|
535
|
+
currentAudio.error_message,
|
|
536
|
+
currentMusic.error_message,
|
|
537
|
+
].find(
|
|
538
|
+
(value): value is string =>
|
|
539
|
+
typeof value === "string" && value.trim().length > 0,
|
|
540
|
+
);
|
|
541
|
+
const audioActive =
|
|
542
|
+
currentAudio.status === "pending" ||
|
|
543
|
+
currentAudio.status === "processing" ||
|
|
544
|
+
currentMusic.status === "pending" ||
|
|
545
|
+
currentMusic.status === "processing" ||
|
|
546
|
+
newerAudioVersions.some(versionActive) ||
|
|
547
|
+
newerMusicVersions.some(versionActive);
|
|
548
|
+
const workActive =
|
|
549
|
+
autopilotActive ||
|
|
550
|
+
batchActive ||
|
|
551
|
+
renderActive ||
|
|
552
|
+
scenarioActive ||
|
|
553
|
+
narrationActive ||
|
|
554
|
+
audioActive ||
|
|
555
|
+
activeSegments.length > 0;
|
|
556
|
+
const ready =
|
|
557
|
+
renderStatus === "completed" &&
|
|
558
|
+
Boolean(videoUrl) &&
|
|
559
|
+
renderFresh &&
|
|
560
|
+
!stale &&
|
|
561
|
+
!workActive &&
|
|
562
|
+
failedSegments.length === 0 &&
|
|
563
|
+
!audioFailed &&
|
|
564
|
+
!scenarioFailed &&
|
|
565
|
+
!scenarioApplyFailed &&
|
|
566
|
+
!narrationFailed &&
|
|
567
|
+
!batchFailed;
|
|
568
|
+
const autopilotFailed = autopilot === "failed" || autopilot === "cancelled";
|
|
569
|
+
const renderFailed = renderStatus === "failed";
|
|
570
|
+
const failed =
|
|
571
|
+
autopilotFailed ||
|
|
572
|
+
renderFailed ||
|
|
573
|
+
scenarioApplyFailed ||
|
|
574
|
+
scenarioFailed ||
|
|
575
|
+
narrationFailed ||
|
|
576
|
+
batchFailed ||
|
|
577
|
+
failedSegments.length > 0 ||
|
|
578
|
+
audioFailed;
|
|
579
|
+
// Terminal = control should return to the agent because nothing will progress
|
|
580
|
+
// without another tool call. With every async job type folded into workActive
|
|
581
|
+
// above (autopilot, batch, segments, scenario, narration, audio, render), NO
|
|
582
|
+
// work active means exactly that — whether the project is ready, failed,
|
|
583
|
+
// stale (editing_required), parked on credits, or a pristine idle draft, only
|
|
584
|
+
// a new tool call can move it, so wait_for_completion must return instead of
|
|
585
|
+
// polling its full budget. (ready is subsumed by !workActive but kept for
|
|
586
|
+
// clarity; explicit failure states are terminal even while cleanup work spins.)
|
|
587
|
+
const terminal = ready || failed || batchInsufficientCredits || !workActive;
|
|
588
|
+
let stage = autopilot;
|
|
589
|
+
// Failure always wins over an unrelated active flag. Workers can leave an
|
|
590
|
+
// outer autopilot/batch state active after a child stage has failed; reporting
|
|
591
|
+
// the active stage would make agents poll forever instead of taking action.
|
|
592
|
+
if (autopilot === "failed") stage = "failed";
|
|
593
|
+
else if (autopilot === "cancelled") stage = "cancelled";
|
|
594
|
+
else if (renderFailed) stage = "render_failed";
|
|
595
|
+
else if (scenarioApplyFailed) stage = "scenario_apply_failed";
|
|
596
|
+
else if (scenarioFailed) stage = "scenario_failed";
|
|
597
|
+
else if (narrationFailed) stage = "narration_failed";
|
|
598
|
+
else if (batchFailed) stage = "batch_failed";
|
|
599
|
+
else if (failedSegments.length > 0) stage = "segment_failed";
|
|
600
|
+
else if (audioFailed) stage = "audio_failed";
|
|
601
|
+
else if (batchInsufficientCredits) stage = "insufficient_credits";
|
|
602
|
+
else if (activeSegments.length > 0) stage = "segment_processing";
|
|
603
|
+
else if (batchActive) stage = "segment_generation";
|
|
604
|
+
else if (renderActive) stage = "rendering";
|
|
605
|
+
else if (scenarioActive) stage = "scenario_processing";
|
|
606
|
+
else if (narrationActive) stage = "narration_processing";
|
|
607
|
+
else if (audioActive) stage = "audio_processing";
|
|
608
|
+
else if (!workActive && stale) stage = "editing_required";
|
|
609
|
+
else if (ready) stage = "completed";
|
|
610
|
+
const segmentError = failedSegments
|
|
611
|
+
.map((segment) => segment.error_message)
|
|
612
|
+
.find(
|
|
613
|
+
(value): value is string => typeof value === "string" && value.length > 0,
|
|
614
|
+
);
|
|
386
615
|
return {
|
|
387
616
|
kind,
|
|
388
617
|
slug,
|
|
389
|
-
stage
|
|
618
|
+
stage,
|
|
390
619
|
terminal,
|
|
391
620
|
ready,
|
|
392
621
|
video_url: videoUrl,
|
|
393
|
-
error:
|
|
622
|
+
error:
|
|
623
|
+
(d.autopilot_error_message as string) ??
|
|
624
|
+
(latest.error_message as string) ??
|
|
625
|
+
(d.scenario_apply_error_message as string) ??
|
|
626
|
+
(d.scenario_error_message as string) ??
|
|
627
|
+
(d.narration_error_message as string) ??
|
|
628
|
+
(d.batch_generation_error_message as string) ??
|
|
629
|
+
segmentError ??
|
|
630
|
+
audioError ??
|
|
631
|
+
(batchInsufficientCredits
|
|
632
|
+
? "Batch generation paused: not enough credits to finish. Top up or reduce scope, then resume."
|
|
633
|
+
: null),
|
|
634
|
+
stale,
|
|
635
|
+
active_segment_ids: activeSegments
|
|
636
|
+
.map((segment) => segment.id)
|
|
637
|
+
.filter(
|
|
638
|
+
(id): id is number | string =>
|
|
639
|
+
typeof id === "number" || typeof id === "string",
|
|
640
|
+
),
|
|
641
|
+
stale_segment_ids: staleSegments
|
|
642
|
+
.map((segment) => segment.id)
|
|
643
|
+
.filter(
|
|
644
|
+
(id): id is number | string =>
|
|
645
|
+
typeof id === "number" || typeof id === "string",
|
|
646
|
+
),
|
|
394
647
|
};
|
|
395
648
|
}
|
|
396
649
|
|
|
@@ -520,6 +773,26 @@ export interface EditorAdKeyArgs {
|
|
|
520
773
|
theme?: string;
|
|
521
774
|
voice_id?: string;
|
|
522
775
|
export_aspect_ratio?: string;
|
|
776
|
+
product_image_path?: string;
|
|
777
|
+
product_description?: string;
|
|
778
|
+
closing_image_path?: string;
|
|
779
|
+
logo_path?: string;
|
|
780
|
+
logo_treatment?: string;
|
|
781
|
+
logo_position?: string;
|
|
782
|
+
logo_duration_seconds?: number;
|
|
783
|
+
cast_mode?: string;
|
|
784
|
+
/** Identities of the retained, pre-create asset byte snapshots. */
|
|
785
|
+
product_image_identity?: string;
|
|
786
|
+
closing_image_identity?: string;
|
|
787
|
+
logo_identity?: string;
|
|
788
|
+
/**
|
|
789
|
+
* The approved spend cap forwarded to the launch POST. Folded into the
|
|
790
|
+
* autopilot-start key ONLY (not the draft-create key) so that re-calling
|
|
791
|
+
* create_editor_ad with a RAISED cap after a failed/over-cap launch mints a
|
|
792
|
+
* fresh key and re-enqueues, instead of replaying the server's 24h-cached dead
|
|
793
|
+
* 202 from the earlier lower-cap attempt.
|
|
794
|
+
*/
|
|
795
|
+
max_credits?: number;
|
|
523
796
|
}
|
|
524
797
|
|
|
525
798
|
/** create_editor_ad's editor draft-create key (`POST /editor`). */
|
|
@@ -535,14 +808,30 @@ export function editorAdCreateKey(a: EditorAdKeyArgs): string {
|
|
|
535
808
|
a.theme ?? "",
|
|
536
809
|
a.voice_id ?? "",
|
|
537
810
|
a.export_aspect_ratio ?? "",
|
|
811
|
+
a.product_image_path ?? "",
|
|
812
|
+
a.product_image_identity ?? "",
|
|
813
|
+
a.product_description ?? "",
|
|
814
|
+
a.closing_image_path ?? "",
|
|
815
|
+
a.closing_image_identity ?? "",
|
|
816
|
+
a.logo_path ?? "",
|
|
817
|
+
a.logo_identity ?? "",
|
|
818
|
+
a.logo_treatment ?? "",
|
|
819
|
+
a.logo_position ?? "",
|
|
820
|
+
a.logo_duration_seconds === undefined
|
|
821
|
+
? ""
|
|
822
|
+
: String(a.logo_duration_seconds),
|
|
823
|
+
a.cast_mode ?? "",
|
|
538
824
|
);
|
|
539
825
|
}
|
|
540
826
|
|
|
541
827
|
/**
|
|
542
828
|
* create_editor_ad's autopilot-start key (`POST /editor/:slug/autopilot`). Folds
|
|
543
|
-
* the create params
|
|
544
|
-
*
|
|
545
|
-
*
|
|
829
|
+
* the create params AND the approved max_credits cap. There is no startState
|
|
830
|
+
* discriminator (this tool starts right after create, so there is no prior
|
|
831
|
+
* terminal state to guard against), so max_credits is what lets a re-call with a
|
|
832
|
+
* RAISED cap after a failed/over-cap launch mint a fresh key and re-enqueue
|
|
833
|
+
* rather than replay the server's 24h-cached dead 202. Self-consistent per-tool
|
|
834
|
+
* only.
|
|
546
835
|
*/
|
|
547
836
|
export function editorAdAutopilotKey(slug: string, a: EditorAdKeyArgs): string {
|
|
548
837
|
return idemKey(
|
|
@@ -557,6 +846,17 @@ export function editorAdAutopilotKey(slug: string, a: EditorAdKeyArgs): string {
|
|
|
557
846
|
a.theme ?? "",
|
|
558
847
|
a.voice_id ?? "",
|
|
559
848
|
a.export_aspect_ratio ?? "",
|
|
849
|
+
a.product_image_path ?? "",
|
|
850
|
+
a.product_description ?? "",
|
|
851
|
+
a.closing_image_path ?? "",
|
|
852
|
+
a.logo_path ?? "",
|
|
853
|
+
a.logo_treatment ?? "",
|
|
854
|
+
a.logo_position ?? "",
|
|
855
|
+
a.logo_duration_seconds === undefined
|
|
856
|
+
? ""
|
|
857
|
+
: String(a.logo_duration_seconds),
|
|
858
|
+
a.cast_mode ?? "",
|
|
859
|
+
a.max_credits === undefined ? "" : String(a.max_credits),
|
|
560
860
|
);
|
|
561
861
|
}
|
|
562
862
|
|
|
@@ -616,9 +916,22 @@ export function addSegmentKey(
|
|
|
616
916
|
*/
|
|
617
917
|
export function resolveSavePath(savePath: string): string {
|
|
618
918
|
const base = resolve(process.env.HUBFLUENCER_OUTPUT_DIR || process.cwd());
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
919
|
+
let target: string;
|
|
920
|
+
if (isAbsolute(savePath)) {
|
|
921
|
+
target = resolve(savePath);
|
|
922
|
+
} else {
|
|
923
|
+
// Accept both output-root-relative paths ("campaign/ad.mp4") and
|
|
924
|
+
// workspace-relative paths that already include the configured output
|
|
925
|
+
// directory ("videos/campaign/ad.mp4"). Always choose a candidate only
|
|
926
|
+
// when it remains confined to `base`; this fixes /videos/videos/... without
|
|
927
|
+
// weakening the traversal guard.
|
|
928
|
+
const workspaceCandidate = resolve(process.cwd(), savePath);
|
|
929
|
+
const workspaceCandidateInsideBase =
|
|
930
|
+
workspaceCandidate === base || workspaceCandidate.startsWith(base + sep);
|
|
931
|
+
target = workspaceCandidateInsideBase
|
|
932
|
+
? workspaceCandidate
|
|
933
|
+
: resolve(base, savePath);
|
|
934
|
+
}
|
|
622
935
|
if (!target.toLowerCase().endsWith(".mp4")) {
|
|
623
936
|
throw new Error("save_path must end in .mp4");
|
|
624
937
|
}
|