@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hubfluencer/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
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,12 @@ 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>;
|
|
268
274
|
/** Short only (additive, absent on older APIs): the newest render row is a free re-render. */
|
|
269
275
|
latest_render_free?: boolean;
|
|
270
276
|
/**
|
|
@@ -395,23 +401,251 @@ export function normalizeStatus(
|
|
|
395
401
|
};
|
|
396
402
|
}
|
|
397
403
|
|
|
398
|
-
// 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.
|
|
399
409
|
const autopilot = (d.autopilot_status as string) ?? "unknown";
|
|
400
410
|
const renderStatus = (latest.status as string) ?? null;
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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|pending|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 === "pending" ||
|
|
485
|
+
d.scenario_apply_status === "applying";
|
|
486
|
+
// Narration generation is a distinct async worker that runs before an
|
|
487
|
+
// AudioVersion exists. Without this gate, a project with an older completed
|
|
488
|
+
// render looks ready/terminal during narration regeneration and
|
|
489
|
+
// wait_for_completion can return that old MP4 while the new script is still
|
|
490
|
+
// being produced.
|
|
491
|
+
const narrationActive = d.narration_status === "generating";
|
|
492
|
+
const scenarioFailed = d.scenario_status === "failed";
|
|
493
|
+
const scenarioApplyFailed = d.scenario_apply_status === "failed";
|
|
494
|
+
const narrationFailed = d.narration_status === "failed";
|
|
495
|
+
// First-time generation is visible through the current selection. A
|
|
496
|
+
// regeneration intentionally keeps the prior completed version current until
|
|
497
|
+
// the replacement lands, so inspect the version histories as well; otherwise
|
|
498
|
+
// an old matching render can look ready while a paid replacement is running.
|
|
499
|
+
const currentAudio = asRecord(d.current_audio);
|
|
500
|
+
const currentMusic = asRecord(d.current_music);
|
|
501
|
+
const audioVersions = Array.isArray(d.audio_versions)
|
|
502
|
+
? d.audio_versions.map(asRecord)
|
|
503
|
+
: [];
|
|
504
|
+
const musicVersions = Array.isArray(d.music_versions)
|
|
505
|
+
? d.music_versions.map(asRecord)
|
|
506
|
+
: [];
|
|
507
|
+
const numericVersion = (version: unknown): number =>
|
|
508
|
+
typeof version === "number" && Number.isFinite(version) ? version : 0;
|
|
509
|
+
const selectedAudioVersion = numericVersion(currentAudio.version);
|
|
510
|
+
const selectedMusicVersion = numericVersion(currentMusic.version);
|
|
511
|
+
const newerAudioVersions = audioVersions.filter(
|
|
512
|
+
(version) => numericVersion(version.version) > selectedAudioVersion,
|
|
513
|
+
);
|
|
514
|
+
const newerMusicVersions = musicVersions.filter(
|
|
515
|
+
(version) => numericVersion(version.version) > selectedMusicVersion,
|
|
516
|
+
);
|
|
517
|
+
const versionActive = (version: Record<string, unknown>) =>
|
|
518
|
+
version.status === "pending" || version.status === "processing";
|
|
519
|
+
const newestVersion = (versions: Array<Record<string, unknown>>) =>
|
|
520
|
+
versions.reduce<Record<string, unknown> | null>((latest, version) => {
|
|
521
|
+
if (!latest) return version;
|
|
522
|
+
return numericVersion(version.version) > numericVersion(latest.version)
|
|
523
|
+
? version
|
|
524
|
+
: latest;
|
|
525
|
+
}, null);
|
|
526
|
+
const newestReplacementAudio = newestVersion(newerAudioVersions);
|
|
527
|
+
const newestReplacementMusic = newestVersion(newerMusicVersions);
|
|
528
|
+
const audioFailed =
|
|
529
|
+
currentAudio.status === "failed" ||
|
|
530
|
+
currentMusic.status === "failed" ||
|
|
531
|
+
newestReplacementAudio?.status === "failed" ||
|
|
532
|
+
newestReplacementMusic?.status === "failed";
|
|
533
|
+
const audioError = [
|
|
534
|
+
newestReplacementAudio?.error_message,
|
|
535
|
+
newestReplacementMusic?.error_message,
|
|
536
|
+
currentAudio.error_message,
|
|
537
|
+
currentMusic.error_message,
|
|
538
|
+
].find(
|
|
539
|
+
(value): value is string =>
|
|
540
|
+
typeof value === "string" && value.trim().length > 0,
|
|
541
|
+
);
|
|
542
|
+
const audioActive =
|
|
543
|
+
currentAudio.status === "pending" ||
|
|
544
|
+
currentAudio.status === "processing" ||
|
|
545
|
+
currentMusic.status === "pending" ||
|
|
546
|
+
currentMusic.status === "processing" ||
|
|
547
|
+
newerAudioVersions.some(versionActive) ||
|
|
548
|
+
newerMusicVersions.some(versionActive);
|
|
549
|
+
const workActive =
|
|
550
|
+
autopilotActive ||
|
|
551
|
+
batchActive ||
|
|
552
|
+
renderActive ||
|
|
553
|
+
scenarioActive ||
|
|
554
|
+
narrationActive ||
|
|
555
|
+
audioActive ||
|
|
556
|
+
activeSegments.length > 0;
|
|
557
|
+
// Orchestration failures are historical once a repaired timeline has a fresh,
|
|
558
|
+
// completed MP4. Keeping them terminal would permanently block download_result
|
|
559
|
+
// after a successful granular recovery.
|
|
560
|
+
const deliveredArtifactSupersedesOrchestrationFailure =
|
|
561
|
+
renderStatus === "completed" &&
|
|
562
|
+
Boolean(videoUrl) &&
|
|
563
|
+
renderFresh &&
|
|
564
|
+
!stale &&
|
|
565
|
+
!workActive &&
|
|
566
|
+
failedSegments.length === 0 &&
|
|
567
|
+
!audioFailed;
|
|
568
|
+
const ready = deliveredArtifactSupersedesOrchestrationFailure;
|
|
569
|
+
const autopilotFailed = autopilot === "failed" || autopilot === "cancelled";
|
|
570
|
+
const renderFailed = renderStatus === "failed";
|
|
571
|
+
const failed =
|
|
572
|
+
(autopilotFailed && !deliveredArtifactSupersedesOrchestrationFailure) ||
|
|
573
|
+
renderFailed ||
|
|
574
|
+
(scenarioApplyFailed && !deliveredArtifactSupersedesOrchestrationFailure) ||
|
|
575
|
+
(scenarioFailed && !deliveredArtifactSupersedesOrchestrationFailure) ||
|
|
576
|
+
(narrationFailed && !deliveredArtifactSupersedesOrchestrationFailure) ||
|
|
577
|
+
(batchFailed && !deliveredArtifactSupersedesOrchestrationFailure) ||
|
|
578
|
+
failedSegments.length > 0 ||
|
|
579
|
+
audioFailed;
|
|
580
|
+
// Terminal = control should return to the agent because nothing will progress
|
|
581
|
+
// without another tool call. With every async job type folded into workActive
|
|
582
|
+
// above (autopilot, batch, segments, scenario, narration, audio, render), NO
|
|
583
|
+
// work active means exactly that — whether the project is ready, failed,
|
|
584
|
+
// stale (editing_required), parked on credits, or a pristine idle draft, only
|
|
585
|
+
// a new tool call can move it, so wait_for_completion must return instead of
|
|
586
|
+
// polling its full budget. (ready is subsumed by !workActive but kept for
|
|
587
|
+
// clarity; explicit failure states are terminal even while cleanup work spins.)
|
|
588
|
+
const terminal = ready || failed || batchInsufficientCredits || !workActive;
|
|
589
|
+
let stage = autopilot;
|
|
590
|
+
// Failure always wins over an unrelated active flag. Workers can leave an
|
|
591
|
+
// outer autopilot/batch state active after a child stage has failed; reporting
|
|
592
|
+
// the active stage would make agents poll forever instead of taking action.
|
|
593
|
+
if (ready) stage = "completed";
|
|
594
|
+
else if (autopilot === "failed") stage = "failed";
|
|
595
|
+
else if (autopilot === "cancelled") stage = "cancelled";
|
|
596
|
+
else if (renderFailed) stage = "render_failed";
|
|
597
|
+
else if (scenarioApplyFailed) stage = "scenario_apply_failed";
|
|
598
|
+
else if (scenarioFailed) stage = "scenario_failed";
|
|
599
|
+
else if (narrationFailed) stage = "narration_failed";
|
|
600
|
+
else if (batchFailed) stage = "batch_failed";
|
|
601
|
+
else if (failedSegments.length > 0) stage = "segment_failed";
|
|
602
|
+
else if (audioFailed) stage = "audio_failed";
|
|
603
|
+
else if (batchInsufficientCredits) stage = "insufficient_credits";
|
|
604
|
+
else if (activeSegments.length > 0) stage = "segment_processing";
|
|
605
|
+
else if (batchActive) stage = "segment_generation";
|
|
606
|
+
else if (renderActive) stage = "rendering";
|
|
607
|
+
else if (scenarioActive) stage = "scenario_processing";
|
|
608
|
+
else if (narrationActive) stage = "narration_processing";
|
|
609
|
+
else if (audioActive) stage = "audio_processing";
|
|
610
|
+
else if (!workActive && stale) stage = "editing_required";
|
|
611
|
+
const segmentError = failedSegments
|
|
612
|
+
.map((segment) => segment.error_message)
|
|
613
|
+
.find(
|
|
614
|
+
(value): value is string => typeof value === "string" && value.length > 0,
|
|
615
|
+
);
|
|
407
616
|
return {
|
|
408
617
|
kind,
|
|
409
618
|
slug,
|
|
410
|
-
stage
|
|
619
|
+
stage,
|
|
411
620
|
terminal,
|
|
412
621
|
ready,
|
|
413
622
|
video_url: videoUrl,
|
|
414
|
-
error:
|
|
623
|
+
error: ready
|
|
624
|
+
? null
|
|
625
|
+
: ((d.autopilot_error_message as string) ??
|
|
626
|
+
(latest.error_message as string) ??
|
|
627
|
+
(d.scenario_apply_error_message as string) ??
|
|
628
|
+
(d.scenario_error_message as string) ??
|
|
629
|
+
(d.narration_error_message as string) ??
|
|
630
|
+
(d.batch_generation_error_message as string) ??
|
|
631
|
+
segmentError ??
|
|
632
|
+
audioError ??
|
|
633
|
+
(batchInsufficientCredits
|
|
634
|
+
? "Batch generation paused: not enough credits to finish. Top up or reduce scope, then resume."
|
|
635
|
+
: null)),
|
|
636
|
+
stale,
|
|
637
|
+
active_segment_ids: activeSegments
|
|
638
|
+
.map((segment) => segment.id)
|
|
639
|
+
.filter(
|
|
640
|
+
(id): id is number | string =>
|
|
641
|
+
typeof id === "number" || typeof id === "string",
|
|
642
|
+
),
|
|
643
|
+
stale_segment_ids: staleSegments
|
|
644
|
+
.map((segment) => segment.id)
|
|
645
|
+
.filter(
|
|
646
|
+
(id): id is number | string =>
|
|
647
|
+
typeof id === "number" || typeof id === "string",
|
|
648
|
+
),
|
|
415
649
|
};
|
|
416
650
|
}
|
|
417
651
|
|
|
@@ -541,6 +775,26 @@ export interface EditorAdKeyArgs {
|
|
|
541
775
|
theme?: string;
|
|
542
776
|
voice_id?: string;
|
|
543
777
|
export_aspect_ratio?: string;
|
|
778
|
+
product_image_path?: string;
|
|
779
|
+
product_description?: string;
|
|
780
|
+
closing_image_path?: string;
|
|
781
|
+
logo_path?: string;
|
|
782
|
+
logo_treatment?: string;
|
|
783
|
+
logo_position?: string;
|
|
784
|
+
logo_duration_seconds?: number;
|
|
785
|
+
cast_mode?: string;
|
|
786
|
+
/** Identities of the retained, pre-create asset byte snapshots. */
|
|
787
|
+
product_image_identity?: string;
|
|
788
|
+
closing_image_identity?: string;
|
|
789
|
+
logo_identity?: string;
|
|
790
|
+
/**
|
|
791
|
+
* The approved spend cap forwarded to the launch POST. Folded into the
|
|
792
|
+
* autopilot-start key ONLY (not the draft-create key) so that re-calling
|
|
793
|
+
* create_editor_ad with a RAISED cap after a failed/over-cap launch mints a
|
|
794
|
+
* fresh key and re-enqueues, instead of replaying the server's 24h-cached dead
|
|
795
|
+
* 202 from the earlier lower-cap attempt.
|
|
796
|
+
*/
|
|
797
|
+
max_credits?: number;
|
|
544
798
|
}
|
|
545
799
|
|
|
546
800
|
/** create_editor_ad's editor draft-create key (`POST /editor`). */
|
|
@@ -556,14 +810,30 @@ export function editorAdCreateKey(a: EditorAdKeyArgs): string {
|
|
|
556
810
|
a.theme ?? "",
|
|
557
811
|
a.voice_id ?? "",
|
|
558
812
|
a.export_aspect_ratio ?? "",
|
|
813
|
+
a.product_image_path ?? "",
|
|
814
|
+
a.product_image_identity ?? "",
|
|
815
|
+
a.product_description ?? "",
|
|
816
|
+
a.closing_image_path ?? "",
|
|
817
|
+
a.closing_image_identity ?? "",
|
|
818
|
+
a.logo_path ?? "",
|
|
819
|
+
a.logo_identity ?? "",
|
|
820
|
+
a.logo_treatment ?? "",
|
|
821
|
+
a.logo_position ?? "",
|
|
822
|
+
a.logo_duration_seconds === undefined
|
|
823
|
+
? ""
|
|
824
|
+
: String(a.logo_duration_seconds),
|
|
825
|
+
a.cast_mode ?? "",
|
|
559
826
|
);
|
|
560
827
|
}
|
|
561
828
|
|
|
562
829
|
/**
|
|
563
830
|
* create_editor_ad's autopilot-start key (`POST /editor/:slug/autopilot`). Folds
|
|
564
|
-
* the create params
|
|
565
|
-
*
|
|
566
|
-
*
|
|
831
|
+
* the create params AND the approved max_credits cap. There is no startState
|
|
832
|
+
* discriminator (this tool starts right after create, so there is no prior
|
|
833
|
+
* terminal state to guard against), so max_credits is what lets a re-call with a
|
|
834
|
+
* RAISED cap after a failed/over-cap launch mint a fresh key and re-enqueue
|
|
835
|
+
* rather than replay the server's 24h-cached dead 202. Self-consistent per-tool
|
|
836
|
+
* only.
|
|
567
837
|
*/
|
|
568
838
|
export function editorAdAutopilotKey(slug: string, a: EditorAdKeyArgs): string {
|
|
569
839
|
return idemKey(
|
|
@@ -578,6 +848,17 @@ export function editorAdAutopilotKey(slug: string, a: EditorAdKeyArgs): string {
|
|
|
578
848
|
a.theme ?? "",
|
|
579
849
|
a.voice_id ?? "",
|
|
580
850
|
a.export_aspect_ratio ?? "",
|
|
851
|
+
a.product_image_path ?? "",
|
|
852
|
+
a.product_description ?? "",
|
|
853
|
+
a.closing_image_path ?? "",
|
|
854
|
+
a.logo_path ?? "",
|
|
855
|
+
a.logo_treatment ?? "",
|
|
856
|
+
a.logo_position ?? "",
|
|
857
|
+
a.logo_duration_seconds === undefined
|
|
858
|
+
? ""
|
|
859
|
+
: String(a.logo_duration_seconds),
|
|
860
|
+
a.cast_mode ?? "",
|
|
861
|
+
a.max_credits === undefined ? "" : String(a.max_credits),
|
|
581
862
|
);
|
|
582
863
|
}
|
|
583
864
|
|
|
@@ -637,9 +918,22 @@ export function addSegmentKey(
|
|
|
637
918
|
*/
|
|
638
919
|
export function resolveSavePath(savePath: string): string {
|
|
639
920
|
const base = resolve(process.env.HUBFLUENCER_OUTPUT_DIR || process.cwd());
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
921
|
+
let target: string;
|
|
922
|
+
if (isAbsolute(savePath)) {
|
|
923
|
+
target = resolve(savePath);
|
|
924
|
+
} else {
|
|
925
|
+
// Accept both output-root-relative paths ("campaign/ad.mp4") and
|
|
926
|
+
// workspace-relative paths that already include the configured output
|
|
927
|
+
// directory ("videos/campaign/ad.mp4"). Always choose a candidate only
|
|
928
|
+
// when it remains confined to `base`; this fixes /videos/videos/... without
|
|
929
|
+
// weakening the traversal guard.
|
|
930
|
+
const workspaceCandidate = resolve(process.cwd(), savePath);
|
|
931
|
+
const workspaceCandidateInsideBase =
|
|
932
|
+
workspaceCandidate === base || workspaceCandidate.startsWith(base + sep);
|
|
933
|
+
target = workspaceCandidateInsideBase
|
|
934
|
+
? workspaceCandidate
|
|
935
|
+
: resolve(base, savePath);
|
|
936
|
+
}
|
|
643
937
|
if (!target.toLowerCase().endsWith(".mp4")) {
|
|
644
938
|
throw new Error("save_path must end in .mp4");
|
|
645
939
|
}
|