@hubfluencer/mcp 0.15.0 → 0.17.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 +107 -50
- package/dist/index.js +13071 -7803
- package/package.json +12 -6
- package/server.json +56 -0
- package/skill/hubfluencer-create/SKILL.md +169 -0
- package/skill/hubfluencer-create/references/full-guide.md +228 -0
- package/src/client.ts +2 -2
- package/src/core.ts +311 -398
- package/src/doctor.ts +96 -0
- package/src/generation-summary.ts +262 -0
- package/src/index.ts +1075 -290
- package/src/output-schemas.ts +85 -12
- package/src/polling.ts +64 -0
- package/src/skill-installer.ts +100 -0
- package/src/tool-profile.ts +93 -0
package/src/core.ts
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
11
|
import { isAbsolute, resolve, sep } from "node:path";
|
|
12
|
+
import {
|
|
13
|
+
type GenerationSummary,
|
|
14
|
+
generationSummarySchema,
|
|
15
|
+
knownGenerationActionTypesFrom,
|
|
16
|
+
} from "./generation-summary.js";
|
|
12
17
|
|
|
13
18
|
export type Kind = "short" | "editor" | "tracking" | "slider";
|
|
14
19
|
|
|
@@ -265,6 +270,15 @@ export interface NormalizedStatus {
|
|
|
265
270
|
ready: boolean;
|
|
266
271
|
video_url: string | null;
|
|
267
272
|
error: string | null;
|
|
273
|
+
/** Stable API lifecycle contract, including the idle run_id-null state. */
|
|
274
|
+
generation_summary?: GenerationSummary;
|
|
275
|
+
/** Canonical run identity, null only for the idle projection. */
|
|
276
|
+
run_id?: string | null;
|
|
277
|
+
/** Immutable workflow identity, null for idle projections. */
|
|
278
|
+
workflow?: string | null;
|
|
279
|
+
revision?: number | null;
|
|
280
|
+
poll_after_ms?: number | null;
|
|
281
|
+
available_actions?: string[];
|
|
268
282
|
/** Short only: exact API failure phase instead of a collapsed terminal state. */
|
|
269
283
|
failed_stage?: string;
|
|
270
284
|
/** Short only: stable machine-readable terminal failure classification. */
|
|
@@ -275,8 +289,6 @@ export interface NormalizedStatus {
|
|
|
275
289
|
failure_source?: string;
|
|
276
290
|
/** Short only: paid generation attempt number that failed. */
|
|
277
291
|
failure_attempt?: number;
|
|
278
|
-
/** Short only: current paid attempt; echo when cancelling. */
|
|
279
|
-
generation_attempt?: number;
|
|
280
292
|
/** Short only: completed/current Veo clip count for useful long-poll progress. */
|
|
281
293
|
segments_completed?: number;
|
|
282
294
|
/** Short only: total current Veo clips in the paid attempt. */
|
|
@@ -289,6 +301,8 @@ export interface NormalizedStatus {
|
|
|
289
301
|
active_segment_ids?: Array<number | string>;
|
|
290
302
|
/** Editor only: completed current scene ids invalidated by continuity edits. */
|
|
291
303
|
stale_segment_ids?: Array<number | string>;
|
|
304
|
+
/** Editor Autopilot only: server-verified quoted delivery contract. */
|
|
305
|
+
autopilot_contract_satisfied?: boolean;
|
|
292
306
|
/** Short only (additive, absent on older APIs): the newest render row is a free re-render. */
|
|
293
307
|
latest_render_free?: boolean;
|
|
294
308
|
/**
|
|
@@ -299,6 +313,21 @@ export interface NormalizedStatus {
|
|
|
299
313
|
last_free_rerender_failed?: boolean;
|
|
300
314
|
}
|
|
301
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Lightweight projection of one `GET /video-factories` index row.
|
|
318
|
+
*
|
|
319
|
+
* That generic endpoint spans Editor, campaign, and creative factories and does
|
|
320
|
+
* not embed `generation_summary`; it is discovery metadata, not a lifecycle
|
|
321
|
+
* read. Keep this projection deliberately tolerant and direct callers to the
|
|
322
|
+
* product-specific status tools for authoritative state.
|
|
323
|
+
*/
|
|
324
|
+
export interface FactoryIndexProject {
|
|
325
|
+
slug: string;
|
|
326
|
+
kind_detail: string | null;
|
|
327
|
+
stage: string;
|
|
328
|
+
video_url: string | null;
|
|
329
|
+
}
|
|
330
|
+
|
|
302
331
|
/** The structured fields of an API error envelope errMessage formats from. */
|
|
303
332
|
export interface ApiErrorFields {
|
|
304
333
|
status: number;
|
|
@@ -318,16 +347,13 @@ export interface ApiErrorFields {
|
|
|
318
347
|
*
|
|
319
348
|
* For a 402 it surfaces the structured credit shortfall the server attaches so
|
|
320
349
|
* the agent can report exactly how short it is instead of a bare sentence. The
|
|
321
|
-
* canonical shape is required_credits/available_credits
|
|
322
|
-
* editor render); it falls back to the legacy required/available keys (older
|
|
323
|
-
* faceless 402s) so the counts always surface regardless of which the route
|
|
324
|
-
* returns.
|
|
350
|
+
* canonical shape is required_credits/available_credits.
|
|
325
351
|
*/
|
|
326
352
|
export function formatApiErrorMessage(f: ApiErrorFields): string {
|
|
327
353
|
const base = `API error (HTTP ${f.status}${f.code ? `, ${f.code}` : ""}): ${f.message}`;
|
|
328
354
|
const body = asRecord(f.body);
|
|
329
|
-
const req = body.required_credits
|
|
330
|
-
const have = body.available_credits
|
|
355
|
+
const req = body.required_credits;
|
|
356
|
+
const have = body.available_credits;
|
|
331
357
|
if (req != null || have != null) {
|
|
332
358
|
return `${base} (needs ${req ?? "?"} credits, have ${have ?? "?"}).`;
|
|
333
359
|
}
|
|
@@ -338,6 +364,118 @@ export function asRecord(v: unknown): Record<string, unknown> {
|
|
|
338
364
|
return v && typeof v === "object" ? (v as Record<string, unknown>) : {};
|
|
339
365
|
}
|
|
340
366
|
|
|
367
|
+
/** Preserves Short-specific recovery and progress fields across lifecycle projections. */
|
|
368
|
+
function shortProductFields(
|
|
369
|
+
d: Record<string, unknown>,
|
|
370
|
+
): Partial<
|
|
371
|
+
Pick<
|
|
372
|
+
NormalizedStatus,
|
|
373
|
+
| "failed_stage"
|
|
374
|
+
| "failure_code"
|
|
375
|
+
| "failure_details"
|
|
376
|
+
| "failure_source"
|
|
377
|
+
| "failure_attempt"
|
|
378
|
+
| "segments_completed"
|
|
379
|
+
| "segments_total"
|
|
380
|
+
| "active_segment_positions"
|
|
381
|
+
| "latest_render_free"
|
|
382
|
+
| "last_free_rerender_failed"
|
|
383
|
+
>
|
|
384
|
+
> {
|
|
385
|
+
const fields: Partial<NormalizedStatus> = {};
|
|
386
|
+
|
|
387
|
+
if (typeof d.failed_stage === "string") fields.failed_stage = d.failed_stage;
|
|
388
|
+
if (typeof d.failure_code === "string") fields.failure_code = d.failure_code;
|
|
389
|
+
if (d.failure_details && typeof d.failure_details === "object") {
|
|
390
|
+
fields.failure_details = d.failure_details as Record<string, unknown>;
|
|
391
|
+
}
|
|
392
|
+
if (typeof d.failure_source === "string")
|
|
393
|
+
fields.failure_source = d.failure_source;
|
|
394
|
+
if (typeof d.failure_attempt === "number") {
|
|
395
|
+
fields.failure_attempt = d.failure_attempt;
|
|
396
|
+
}
|
|
397
|
+
if (typeof d.segments_completed === "number") {
|
|
398
|
+
fields.segments_completed = d.segments_completed;
|
|
399
|
+
}
|
|
400
|
+
if (typeof d.segments_total === "number") {
|
|
401
|
+
fields.segments_total = d.segments_total;
|
|
402
|
+
}
|
|
403
|
+
if (
|
|
404
|
+
Array.isArray(d.active_segment_positions) &&
|
|
405
|
+
d.active_segment_positions.every((position) => typeof position === "number")
|
|
406
|
+
) {
|
|
407
|
+
fields.active_segment_positions = d.active_segment_positions as number[];
|
|
408
|
+
}
|
|
409
|
+
if (typeof d.latest_render_free === "boolean") {
|
|
410
|
+
fields.latest_render_free = d.latest_render_free;
|
|
411
|
+
}
|
|
412
|
+
if (typeof d.last_free_rerender_failed === "boolean") {
|
|
413
|
+
fields.last_free_rerender_failed = d.last_free_rerender_failed;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return fields;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Preserves Editor delivery-freshness diagnostics outside lifecycle authority. */
|
|
420
|
+
function editorProductFields(
|
|
421
|
+
d: Record<string, unknown>,
|
|
422
|
+
latest: Record<string, unknown>,
|
|
423
|
+
): Pick<NormalizedStatus, "active_segment_ids" | "stale_segment_ids"> &
|
|
424
|
+
Partial<Pick<NormalizedStatus, "stale" | "autopilot_contract_satisfied">> {
|
|
425
|
+
const segments = Array.isArray(d.segments) ? d.segments.map(asRecord) : [];
|
|
426
|
+
const activeSegments = segments.filter((segment) => {
|
|
427
|
+
const status = segment.status;
|
|
428
|
+
const previewStatus = segment.preview_status;
|
|
429
|
+
return (
|
|
430
|
+
status === "processing" ||
|
|
431
|
+
previewStatus === "processing" ||
|
|
432
|
+
previewStatus === "generating"
|
|
433
|
+
);
|
|
434
|
+
});
|
|
435
|
+
const staleSegments = segments.filter(
|
|
436
|
+
(segment) => segment.video_stale === true && segment.status === "completed",
|
|
437
|
+
);
|
|
438
|
+
const stale =
|
|
439
|
+
latest.is_stale === true ||
|
|
440
|
+
staleSegments.length > 0 ||
|
|
441
|
+
d.narration_stale === true ||
|
|
442
|
+
d.voice_stale === true ||
|
|
443
|
+
d.music_stale === true;
|
|
444
|
+
const ids = (rows: Record<string, unknown>[]): Array<number | string> =>
|
|
445
|
+
rows
|
|
446
|
+
.map((row) => row.id)
|
|
447
|
+
.filter(
|
|
448
|
+
(id): id is number | string =>
|
|
449
|
+
typeof id === "number" || typeof id === "string",
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
return {
|
|
453
|
+
// A legacy render without a hash has `is_stale:null`. Preserve that as
|
|
454
|
+
// unknown instead of laundering it into stale:false/current.
|
|
455
|
+
...(stale || latest.is_stale === false ? { stale } : {}),
|
|
456
|
+
active_segment_ids: ids(activeSegments),
|
|
457
|
+
stale_segment_ids: ids(staleSegments),
|
|
458
|
+
...(typeof d.autopilot_contract_satisfied === "boolean"
|
|
459
|
+
? {
|
|
460
|
+
autopilot_contract_satisfied:
|
|
461
|
+
d.autopilot_contract_satisfied as boolean,
|
|
462
|
+
}
|
|
463
|
+
: {}),
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export function normalizeFactoryIndexProject(
|
|
468
|
+
data: unknown,
|
|
469
|
+
): FactoryIndexProject {
|
|
470
|
+
const row = asRecord(data);
|
|
471
|
+
return {
|
|
472
|
+
slug: typeof row.slug === "string" ? row.slug : "",
|
|
473
|
+
kind_detail: typeof row.kind === "string" ? row.kind : null,
|
|
474
|
+
stage: typeof row.status === "string" ? row.status : "unknown",
|
|
475
|
+
video_url: typeof row.video_url === "string" ? row.video_url : null,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
341
479
|
/** Collapses the short/editor state payloads into one shape the agent can poll. */
|
|
342
480
|
export function normalizeStatus(
|
|
343
481
|
kind: Kind,
|
|
@@ -346,56 +484,62 @@ export function normalizeStatus(
|
|
|
346
484
|
): NormalizedStatus {
|
|
347
485
|
const d = asRecord(data);
|
|
348
486
|
const latest = asRecord(d.latest_render);
|
|
349
|
-
const videoUrl =
|
|
487
|
+
const videoUrl =
|
|
488
|
+
typeof latest.video_url === "string" ? latest.video_url : null;
|
|
489
|
+
|
|
490
|
+
if (kind === "short" || kind === "editor") {
|
|
491
|
+
const parsed = generationSummarySchema.safeParse(d.generation_summary);
|
|
492
|
+
if (!parsed.success) {
|
|
493
|
+
throw new Error(
|
|
494
|
+
`The Hubfluencer ${kind} response did not contain a valid generation_summary. Refresh after the API and MCP contracts are upgraded together.`,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const summary = parsed.data;
|
|
499
|
+
const terminal = [
|
|
500
|
+
"needs_input",
|
|
501
|
+
"completed",
|
|
502
|
+
"failed",
|
|
503
|
+
"cancelled",
|
|
504
|
+
].includes(summary.status);
|
|
505
|
+
const availableActions = knownGenerationActionTypesFrom(summary);
|
|
506
|
+
const editorFields =
|
|
507
|
+
kind === "editor" ? editorProductFields(d, latest) : null;
|
|
508
|
+
const productFields = editorFields ?? shortProductFields(d);
|
|
509
|
+
// `generation_summary` owns lifecycle state, but the current Editor
|
|
510
|
+
// timeline's delivery freshness is a separate product contract. A completed
|
|
511
|
+
// render artifact may remain downloadable after an edit, so the still-present
|
|
512
|
+
// `is_stale` / `*_stale` wire fields must prevent that historical MP4 from
|
|
513
|
+
// being presented as current.
|
|
514
|
+
const editingRequired =
|
|
515
|
+
kind === "editor" &&
|
|
516
|
+
summary.status === "completed" &&
|
|
517
|
+
editorFields?.stale === true;
|
|
518
|
+
const editorDeliveryFresh =
|
|
519
|
+
kind !== "editor" || editorFields?.stale === false;
|
|
520
|
+
const ready =
|
|
521
|
+
summary.status === "completed" &&
|
|
522
|
+
availableActions.includes("download_result") &&
|
|
523
|
+
!editingRequired &&
|
|
524
|
+
editorDeliveryFresh &&
|
|
525
|
+
Boolean(videoUrl);
|
|
350
526
|
|
|
351
|
-
|
|
352
|
-
const stage = (d.stage as string) ?? "unknown";
|
|
353
|
-
const status: NormalizedStatus = {
|
|
527
|
+
return {
|
|
354
528
|
kind,
|
|
355
529
|
slug,
|
|
356
|
-
stage,
|
|
357
|
-
terminal
|
|
358
|
-
ready
|
|
530
|
+
stage: editingRequired ? "editing_required" : summary.phase,
|
|
531
|
+
terminal,
|
|
532
|
+
ready,
|
|
359
533
|
video_url: videoUrl,
|
|
360
|
-
error:
|
|
534
|
+
error: summary.failure?.message ?? null,
|
|
535
|
+
generation_summary: summary,
|
|
536
|
+
run_id: summary.run_id,
|
|
537
|
+
workflow: summary.workflow,
|
|
538
|
+
revision: summary.revision,
|
|
539
|
+
poll_after_ms: summary.timing.poll_after_ms,
|
|
540
|
+
available_actions: availableActions,
|
|
541
|
+
...productFields,
|
|
361
542
|
};
|
|
362
|
-
if (typeof d.failed_stage === "string")
|
|
363
|
-
status.failed_stage = d.failed_stage;
|
|
364
|
-
if (typeof d.failure_code === "string")
|
|
365
|
-
status.failure_code = d.failure_code;
|
|
366
|
-
if (d.failure_details && typeof d.failure_details === "object")
|
|
367
|
-
status.failure_details = d.failure_details as Record<string, unknown>;
|
|
368
|
-
if (typeof d.failure_source === "string")
|
|
369
|
-
status.failure_source = d.failure_source;
|
|
370
|
-
if (typeof d.failure_attempt === "number")
|
|
371
|
-
status.failure_attempt = d.failure_attempt;
|
|
372
|
-
if (typeof d.generation_attempt === "number")
|
|
373
|
-
status.generation_attempt = d.generation_attempt;
|
|
374
|
-
if (typeof d.segments_completed === "number")
|
|
375
|
-
status.segments_completed = d.segments_completed;
|
|
376
|
-
if (typeof d.segments_total === "number")
|
|
377
|
-
status.segments_total = d.segments_total;
|
|
378
|
-
if (
|
|
379
|
-
Array.isArray(d.active_segment_positions) &&
|
|
380
|
-
d.active_segment_positions.every(
|
|
381
|
-
(position) => typeof position === "number",
|
|
382
|
-
)
|
|
383
|
-
) {
|
|
384
|
-
status.active_segment_positions = d.active_segment_positions as number[];
|
|
385
|
-
}
|
|
386
|
-
// Additive free-re-render bookkeeping (2026-07 API): surfaced only when
|
|
387
|
-
// the server actually sends the booleans, so older payloads normalize
|
|
388
|
-
// byte-identically. A failed free re-render does NOT flip stage to
|
|
389
|
-
// "failed" server-side — the delivered video stays visible — so these
|
|
390
|
-
// flags are the only signal that the newest render row was a free
|
|
391
|
-
// re-render (or a failed one worth retrying, still free).
|
|
392
|
-
if (typeof d.latest_render_free === "boolean") {
|
|
393
|
-
status.latest_render_free = d.latest_render_free;
|
|
394
|
-
}
|
|
395
|
-
if (typeof d.last_free_rerender_failed === "boolean") {
|
|
396
|
-
status.last_free_rerender_failed = d.last_free_rerender_failed;
|
|
397
|
-
}
|
|
398
|
-
return status;
|
|
399
543
|
}
|
|
400
544
|
|
|
401
545
|
if (kind === "tracking") {
|
|
@@ -443,355 +587,114 @@ export function normalizeStatus(
|
|
|
443
587
|
};
|
|
444
588
|
}
|
|
445
589
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
const segments = Array.isArray(d.segments) ? d.segments.map(asRecord) : [];
|
|
454
|
-
// "Active" means a scene is genuinely mid-generation. Crucially it must NOT
|
|
455
|
-
// include the *idle* defaults: segment.status "pending" and preview_status
|
|
456
|
-
// "pending" are the never-generated / cleared-preview defaults the API stamps
|
|
457
|
-
// on every fresh scene (video_segment.ex: status default "pending",
|
|
458
|
-
// preview_status default "pending"). Counting either as active made a fully
|
|
459
|
-
// idle draft report stage "segment_processing" with every scene in
|
|
460
|
-
// active_segment_ids, so wait_for_completion burned its whole budget on a
|
|
461
|
-
// project that isn't generating anything. The real in-flight states are
|
|
462
|
-
// segment.status "processing" and preview_status "generating" (its wire enum
|
|
463
|
-
// is pending|generating|completed|failed|stale — "processing" is kept only as
|
|
464
|
-
// a defensive alias, it never appears).
|
|
465
|
-
const activeSegments = segments.filter((segment) => {
|
|
466
|
-
const status = segment.status as string | undefined;
|
|
467
|
-
const preview = segment.preview_status as string | undefined;
|
|
468
|
-
return (
|
|
469
|
-
status === "processing" ||
|
|
470
|
-
preview === "processing" ||
|
|
471
|
-
preview === "generating"
|
|
472
|
-
);
|
|
473
|
-
});
|
|
474
|
-
const staleSegments = segments.filter(
|
|
475
|
-
(segment) => segment.video_stale === true && segment.status === "completed",
|
|
476
|
-
);
|
|
477
|
-
const failedSegments = segments.filter(
|
|
478
|
-
(segment) => segment.status === "failed",
|
|
479
|
-
);
|
|
480
|
-
const batchStatus = (d.batch_generation_status as string) ?? null;
|
|
481
|
-
// The batch enum's ACTIVE states (video_factory.ex): preparing (spinning up),
|
|
482
|
-
// awaiting_previews (previews generating), and running. "queued" is not a real
|
|
483
|
-
// value and was dropped. paused_for_retry is deliberately NOT active: no worker
|
|
484
|
-
// owns it until the user explicitly retries or cancels the paused batch.
|
|
485
|
-
const batchActive =
|
|
486
|
-
batchStatus === "running" ||
|
|
487
|
-
batchStatus === "preparing" ||
|
|
488
|
-
batchStatus === "awaiting_previews";
|
|
489
|
-
// The scene batch commits `completed` before the durable auto-render handoff
|
|
490
|
-
// creates its VideoResult row. While the persisted intent remains armed, the
|
|
491
|
-
// server still owns the project and a reconciler may create the render at any
|
|
492
|
-
// moment. Treat that narrow gap as active work so wait_for_completion cannot
|
|
493
|
-
// return an idle/terminal result just before the render appears.
|
|
494
|
-
const autoRenderHandoffActive =
|
|
495
|
-
batchStatus === "completed" && d.auto_render_after_batch === true;
|
|
496
|
-
const batchRetryRequired = batchStatus === "paused_for_retry";
|
|
497
|
-
// A parked batch that ran out of credits: it cannot progress without the user
|
|
498
|
-
// topping up or reducing scope, so it is a needs-user-action TERMINAL state
|
|
499
|
-
// (not active work to wait on).
|
|
500
|
-
const batchInsufficientCredits =
|
|
501
|
-
batchStatus === "paused_insufficient_credits";
|
|
502
|
-
const batchFailed = batchStatus === "failed";
|
|
503
|
-
const autopilotActive = [
|
|
504
|
-
"pending",
|
|
505
|
-
"running",
|
|
506
|
-
"generating",
|
|
507
|
-
"rendering",
|
|
508
|
-
].includes(autopilot);
|
|
509
|
-
const audioStale =
|
|
510
|
-
d.narration_stale === true ||
|
|
511
|
-
d.voice_stale === true ||
|
|
512
|
-
d.music_stale === true;
|
|
513
|
-
const renderStale = latest.is_stale === true;
|
|
514
|
-
// Only an explicit false proves the completed render matches the live editor.
|
|
515
|
-
// Legacy/null/omitted freshness is unknown and must never be promoted to ready.
|
|
516
|
-
const renderFresh = latest.is_stale === false;
|
|
517
|
-
const stale = renderStale || staleSegments.length > 0 || audioStale;
|
|
518
|
-
// A render in flight is active work too — fold it into workActive so the
|
|
519
|
-
// terminal computation below can never fire while a render is queued/running.
|
|
520
|
-
// "pending" here is NOT an idle default: a video_results row only exists once
|
|
521
|
-
// a render was actually requested, so a pending latest render is queued work.
|
|
522
|
-
const renderActive =
|
|
523
|
-
renderStatus === "pending" ||
|
|
524
|
-
renderStatus === "rendering" ||
|
|
525
|
-
renderStatus === "processing";
|
|
526
|
-
// Scenario text generation / scenario apply run as async workers too
|
|
527
|
-
// (scenario_status: none|generating|ready|failed; scenario_apply_status:
|
|
528
|
-
// idle|pending|applying|failed) — count them as active so a wait right after
|
|
529
|
-
// generate_scenario/apply_scenario doesn't bail out as an "idle" draft.
|
|
530
|
-
const scenarioActive =
|
|
531
|
-
d.scenario_status === "generating" ||
|
|
532
|
-
d.scenario_apply_status === "pending" ||
|
|
533
|
-
d.scenario_apply_status === "applying";
|
|
534
|
-
// Narration generation is a distinct async worker that runs before an
|
|
535
|
-
// AudioVersion exists. Without this gate, a project with an older completed
|
|
536
|
-
// render looks ready/terminal during narration regeneration and
|
|
537
|
-
// wait_for_completion can return that old MP4 while the new script is still
|
|
538
|
-
// being produced.
|
|
539
|
-
const narrationActive = d.narration_status === "generating";
|
|
540
|
-
const scenarioFailed = d.scenario_status === "failed";
|
|
541
|
-
const scenarioApplyFailed = d.scenario_apply_status === "failed";
|
|
542
|
-
const narrationFailed = d.narration_status === "failed";
|
|
543
|
-
// First-time generation is visible through the current selection. A
|
|
544
|
-
// regeneration intentionally keeps the prior completed version current until
|
|
545
|
-
// the replacement lands, so inspect the version histories as well; otherwise
|
|
546
|
-
// an old matching render can look ready while a paid replacement is running.
|
|
547
|
-
const currentAudio = asRecord(d.current_audio);
|
|
548
|
-
const currentMusic = asRecord(d.current_music);
|
|
549
|
-
const audioVersions = Array.isArray(d.audio_versions)
|
|
550
|
-
? d.audio_versions.map(asRecord)
|
|
551
|
-
: [];
|
|
552
|
-
const musicVersions = Array.isArray(d.music_versions)
|
|
553
|
-
? d.music_versions.map(asRecord)
|
|
554
|
-
: [];
|
|
555
|
-
const numericVersion = (version: unknown): number =>
|
|
556
|
-
typeof version === "number" && Number.isFinite(version) ? version : 0;
|
|
557
|
-
const selectedAudioVersion = numericVersion(currentAudio.version);
|
|
558
|
-
const selectedMusicVersion = numericVersion(currentMusic.version);
|
|
559
|
-
const newerAudioVersions = audioVersions.filter(
|
|
560
|
-
(version) => numericVersion(version.version) > selectedAudioVersion,
|
|
561
|
-
);
|
|
562
|
-
const newerMusicVersions = musicVersions.filter(
|
|
563
|
-
(version) => numericVersion(version.version) > selectedMusicVersion,
|
|
564
|
-
);
|
|
565
|
-
const versionActive = (version: Record<string, unknown>) =>
|
|
566
|
-
version.status === "pending" || version.status === "processing";
|
|
567
|
-
const newestVersion = (versions: Array<Record<string, unknown>>) =>
|
|
568
|
-
versions.reduce<Record<string, unknown> | null>((latest, version) => {
|
|
569
|
-
if (!latest) return version;
|
|
570
|
-
return numericVersion(version.version) > numericVersion(latest.version)
|
|
571
|
-
? version
|
|
572
|
-
: latest;
|
|
573
|
-
}, null);
|
|
574
|
-
const newestReplacementAudio = newestVersion(newerAudioVersions);
|
|
575
|
-
const newestReplacementMusic = newestVersion(newerMusicVersions);
|
|
576
|
-
const audioFailed =
|
|
577
|
-
currentAudio.status === "failed" ||
|
|
578
|
-
currentMusic.status === "failed" ||
|
|
579
|
-
newestReplacementAudio?.status === "failed" ||
|
|
580
|
-
newestReplacementMusic?.status === "failed";
|
|
581
|
-
const audioError = [
|
|
582
|
-
newestReplacementAudio?.error_message,
|
|
583
|
-
newestReplacementMusic?.error_message,
|
|
584
|
-
currentAudio.error_message,
|
|
585
|
-
currentMusic.error_message,
|
|
586
|
-
].find(
|
|
587
|
-
(value): value is string =>
|
|
588
|
-
typeof value === "string" && value.trim().length > 0,
|
|
589
|
-
);
|
|
590
|
-
const audioActive =
|
|
591
|
-
currentAudio.status === "pending" ||
|
|
592
|
-
currentAudio.status === "processing" ||
|
|
593
|
-
currentMusic.status === "pending" ||
|
|
594
|
-
currentMusic.status === "processing" ||
|
|
595
|
-
newerAudioVersions.some(versionActive) ||
|
|
596
|
-
newerMusicVersions.some(versionActive);
|
|
597
|
-
const childWorkActive =
|
|
598
|
-
batchActive ||
|
|
599
|
-
autoRenderHandoffActive ||
|
|
600
|
-
renderActive ||
|
|
601
|
-
scenarioActive ||
|
|
602
|
-
narrationActive ||
|
|
603
|
-
audioActive ||
|
|
604
|
-
activeSegments.length > 0;
|
|
605
|
-
// A resumed Autopilot briefly exposes the previous attempt's failed child
|
|
606
|
-
// states before its new worker reaches and clears them. Suppress that history
|
|
607
|
-
// only while real child work is active or for a short, timestamped hand-off
|
|
608
|
-
// window. A parent stuck "running" with no worker must eventually surface the
|
|
609
|
-
// child failure instead of making wait_for_completion poll forever.
|
|
610
|
-
const autopilotStartedAt =
|
|
611
|
-
typeof d.autopilot_started_at === "string"
|
|
612
|
-
? Date.parse(d.autopilot_started_at)
|
|
613
|
-
: Number.NaN;
|
|
614
|
-
const now = Date.now();
|
|
615
|
-
const autopilotResumeGraceActive =
|
|
616
|
-
autopilotActive &&
|
|
617
|
-
Number.isFinite(autopilotStartedAt) &&
|
|
618
|
-
autopilotStartedAt <= now &&
|
|
619
|
-
now - autopilotStartedAt <= 120_000;
|
|
620
|
-
const autopilotFailed = autopilot === "failed" || autopilot === "cancelled";
|
|
621
|
-
// Retrying a paused batch deliberately leaves the failed parent Autopilot
|
|
622
|
-
// marker in place while the already-paid child batch finishes. In that one
|
|
623
|
-
// state combination the parent/old scene failures are history, not a reason
|
|
624
|
-
// to return terminal immediately. Once the batch settles, the parent failure
|
|
625
|
-
// becomes actionable again and the agent can quote/relaunch Autopilot.
|
|
626
|
-
const batchRecoveryActive = autopilotFailed && batchActive;
|
|
627
|
-
const childFailureActionable =
|
|
628
|
-
!batchRecoveryActive &&
|
|
629
|
-
(!autopilotActive || (!childWorkActive && !autopilotResumeGraceActive));
|
|
630
|
-
const workActive = autopilotActive || childWorkActive;
|
|
631
|
-
// Orchestration failures are historical once a repaired timeline has a fresh,
|
|
632
|
-
// completed MP4. Keeping them terminal would permanently block download_result
|
|
633
|
-
// after a successful granular recovery.
|
|
634
|
-
const deliveredArtifactSupersedesOrchestrationFailure =
|
|
635
|
-
renderStatus === "completed" &&
|
|
636
|
-
Boolean(videoUrl) &&
|
|
637
|
-
renderFresh &&
|
|
638
|
-
!stale &&
|
|
639
|
-
!workActive &&
|
|
640
|
-
failedSegments.length === 0 &&
|
|
641
|
-
!audioFailed;
|
|
642
|
-
const ready = deliveredArtifactSupersedesOrchestrationFailure;
|
|
643
|
-
const parentFailureActionable = autopilotFailed && !batchRecoveryActive;
|
|
644
|
-
const renderFailed = renderStatus === "failed" && childFailureActionable;
|
|
645
|
-
const failed =
|
|
646
|
-
(parentFailureActionable &&
|
|
647
|
-
!deliveredArtifactSupersedesOrchestrationFailure) ||
|
|
648
|
-
renderFailed ||
|
|
649
|
-
(childFailureActionable &&
|
|
650
|
-
scenarioApplyFailed &&
|
|
651
|
-
!deliveredArtifactSupersedesOrchestrationFailure) ||
|
|
652
|
-
(childFailureActionable &&
|
|
653
|
-
scenarioFailed &&
|
|
654
|
-
!deliveredArtifactSupersedesOrchestrationFailure) ||
|
|
655
|
-
(childFailureActionable &&
|
|
656
|
-
narrationFailed &&
|
|
657
|
-
!deliveredArtifactSupersedesOrchestrationFailure) ||
|
|
658
|
-
(childFailureActionable &&
|
|
659
|
-
batchFailed &&
|
|
660
|
-
!deliveredArtifactSupersedesOrchestrationFailure) ||
|
|
661
|
-
(childFailureActionable && failedSegments.length > 0) ||
|
|
662
|
-
(childFailureActionable && audioFailed);
|
|
663
|
-
// Terminal = control should return to the agent because nothing will progress
|
|
664
|
-
// without another tool call. With every async job type folded into workActive
|
|
665
|
-
// above (autopilot, batch, segments, scenario, narration, audio, render), NO
|
|
666
|
-
// work active means exactly that — whether the project is ready, failed,
|
|
667
|
-
// stale (editing_required), parked on credits, or a pristine idle draft, only
|
|
668
|
-
// a new tool call can move it, so wait_for_completion must return instead of
|
|
669
|
-
// polling its full budget. (ready is subsumed by !workActive but kept for
|
|
670
|
-
// clarity; explicit failure states are terminal even while cleanup work spins.)
|
|
671
|
-
const terminal =
|
|
672
|
-
ready ||
|
|
673
|
-
failed ||
|
|
674
|
-
batchRetryRequired ||
|
|
675
|
-
batchInsufficientCredits ||
|
|
676
|
-
!workActive;
|
|
677
|
-
let stage = autopilot;
|
|
678
|
-
// Failure always wins over an unrelated active flag. Workers can leave an
|
|
679
|
-
// outer autopilot/batch state active after a child stage has failed; reporting
|
|
680
|
-
// the active stage would make agents poll forever instead of taking action.
|
|
681
|
-
if (ready) stage = "completed";
|
|
682
|
-
// A paused child batch is the immediate recovery gate even when its parent
|
|
683
|
-
// Autopilot run has already failed/cancelled. Reporting only the generic
|
|
684
|
-
// parent terminal state hides the one action that must happen before a new
|
|
685
|
-
// Autopilot run is allowed to start.
|
|
686
|
-
else if (batchRetryRequired) stage = "batch_retry_required";
|
|
687
|
-
else if (parentFailureActionable && autopilot === "failed") stage = "failed";
|
|
688
|
-
else if (parentFailureActionable && autopilot === "cancelled")
|
|
689
|
-
stage = "cancelled";
|
|
690
|
-
else if (renderFailed) stage = "render_failed";
|
|
691
|
-
else if (childFailureActionable && scenarioApplyFailed)
|
|
692
|
-
stage = "scenario_apply_failed";
|
|
693
|
-
else if (childFailureActionable && scenarioFailed) stage = "scenario_failed";
|
|
694
|
-
else if (childFailureActionable && narrationFailed)
|
|
695
|
-
stage = "narration_failed";
|
|
696
|
-
else if (childFailureActionable && batchFailed) stage = "batch_failed";
|
|
697
|
-
else if (childFailureActionable && failedSegments.length > 0)
|
|
698
|
-
stage = "segment_failed";
|
|
699
|
-
else if (childFailureActionable && audioFailed) stage = "audio_failed";
|
|
700
|
-
else if (batchInsufficientCredits) stage = "insufficient_credits";
|
|
701
|
-
else if (activeSegments.length > 0) stage = "segment_processing";
|
|
702
|
-
else if (batchActive) stage = "segment_generation";
|
|
703
|
-
else if (autoRenderHandoffActive) stage = "rendering";
|
|
704
|
-
else if (renderActive) stage = "rendering";
|
|
705
|
-
else if (scenarioActive) stage = "scenario_processing";
|
|
706
|
-
else if (narrationActive) stage = "narration_processing";
|
|
707
|
-
else if (audioActive) stage = "audio_processing";
|
|
708
|
-
else if (!workActive && stale) stage = "editing_required";
|
|
709
|
-
const segmentError = failedSegments
|
|
710
|
-
.map((segment) => segment.error_message)
|
|
711
|
-
.find(
|
|
712
|
-
(value): value is string => typeof value === "string" && value.length > 0,
|
|
590
|
+
throw new Error(`Unsupported Hubfluencer project kind: ${String(kind)}`);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
export class GenerationStartStateContractError extends Error {
|
|
594
|
+
constructor(reason: string) {
|
|
595
|
+
super(
|
|
596
|
+
`Invalid canonical generation summary for start discrimination: ${reason}`,
|
|
713
597
|
);
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
(d.scenario_apply_error_message as string) ??
|
|
717
|
-
(d.scenario_error_message as string) ??
|
|
718
|
-
(d.narration_error_message as string) ??
|
|
719
|
-
(d.batch_generation_error_message as string) ??
|
|
720
|
-
segmentError ??
|
|
721
|
-
audioError)
|
|
722
|
-
: null;
|
|
723
|
-
return {
|
|
724
|
-
kind,
|
|
725
|
-
slug,
|
|
726
|
-
stage,
|
|
727
|
-
terminal,
|
|
728
|
-
ready,
|
|
729
|
-
video_url: videoUrl,
|
|
730
|
-
error: ready
|
|
731
|
-
? null
|
|
732
|
-
: batchRetryRequired
|
|
733
|
-
? "Batch generation is paused after a failed scene. Call retry_editor_batch or cancel_editor_batch before continuing."
|
|
734
|
-
: ((parentFailureActionable
|
|
735
|
-
? (d.autopilot_error_message as string)
|
|
736
|
-
: null) ??
|
|
737
|
-
actionableChildError ??
|
|
738
|
-
(batchInsufficientCredits
|
|
739
|
-
? "Batch generation paused: not enough credits to finish. Top up or reduce scope, then invoke the generation/render path again; retry_editor_batch only applies to paused_for_retry."
|
|
740
|
-
: null)),
|
|
741
|
-
stale,
|
|
742
|
-
active_segment_ids: activeSegments
|
|
743
|
-
.map((segment) => segment.id)
|
|
744
|
-
.filter(
|
|
745
|
-
(id): id is number | string =>
|
|
746
|
-
typeof id === "number" || typeof id === "string",
|
|
747
|
-
),
|
|
748
|
-
stale_segment_ids: staleSegments
|
|
749
|
-
.map((segment) => segment.id)
|
|
750
|
-
.filter(
|
|
751
|
-
(id): id is number | string =>
|
|
752
|
-
typeof id === "number" || typeof id === "string",
|
|
753
|
-
),
|
|
754
|
-
};
|
|
598
|
+
this.name = "GenerationStartStateContractError";
|
|
599
|
+
}
|
|
755
600
|
}
|
|
756
601
|
|
|
757
602
|
/**
|
|
758
|
-
*
|
|
759
|
-
* AFTER a terminal failure genuinely restarts instead of replaying the failed
|
|
760
|
-
* run's cached 202 for 24h (the server caches idempotent POSTs). Mirrors the
|
|
761
|
-
* tracking render key's anti-replay design (which folds the failed result id):
|
|
603
|
+
* Canonical discriminator folded into make_video's start idempotency key.
|
|
762
604
|
*
|
|
763
|
-
*
|
|
764
|
-
*
|
|
765
|
-
*
|
|
766
|
-
*
|
|
767
|
-
*
|
|
768
|
-
*
|
|
769
|
-
* state, mints a DIFFERENT key, and re-enqueues the pipeline. Each subsequent
|
|
770
|
-
* rendered failure changes latest_render.id, so successive retries keep
|
|
771
|
-
* minting fresh keys rather than replaying a dead 202.
|
|
605
|
+
* The stable `start` token deliberately survives idle, accepted/nonterminal,
|
|
606
|
+
* completed, and non-restartable terminal snapshots. That preserves exact
|
|
607
|
+
* transport replay of the original logical start. Only a terminal snapshot
|
|
608
|
+
* which explicitly advertises the server-owned `start_generation` capability
|
|
609
|
+
* mints a new key, using canonical run/revision/failure identity. Product detail
|
|
610
|
+
* fields are never consulted here.
|
|
772
611
|
*
|
|
773
|
-
*
|
|
612
|
+
* A response that is missing or violates the stable summary contract fails
|
|
613
|
+
* closed. Starting chargeable work without a trustworthy authority snapshot
|
|
614
|
+
* would turn a contract/version outage into an unsafe new intent.
|
|
774
615
|
*/
|
|
775
616
|
export function startStateDiscriminator(kind: Kind, data: unknown): string {
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
617
|
+
if (kind !== "short" && kind !== "editor") {
|
|
618
|
+
throw new GenerationStartStateContractError(`unsupported product ${kind}`);
|
|
619
|
+
}
|
|
779
620
|
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
621
|
+
const parsed = generationSummarySchema.safeParse(
|
|
622
|
+
asRecord(data).generation_summary,
|
|
623
|
+
);
|
|
624
|
+
if (!parsed.success) {
|
|
625
|
+
throw new GenerationStartStateContractError(
|
|
626
|
+
"missing or malformed generation_summary",
|
|
627
|
+
);
|
|
784
628
|
}
|
|
785
629
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
const
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
630
|
+
const summary = parsed.data;
|
|
631
|
+
validateSummaryProduct(kind, summary);
|
|
632
|
+
|
|
633
|
+
const restartAvailable = summary.available_actions.some(
|
|
634
|
+
(action) => action.type === "start_generation",
|
|
635
|
+
);
|
|
636
|
+
const restartableTerminal =
|
|
637
|
+
restartAvailable &&
|
|
638
|
+
(summary.status === "failed" || summary.status === "cancelled");
|
|
639
|
+
|
|
640
|
+
if (!restartableTerminal) return "start";
|
|
641
|
+
|
|
642
|
+
const attemptIdentity = terminalAttemptIdentity(summary);
|
|
643
|
+
return `restart:${idemKey(
|
|
644
|
+
"generation-restart",
|
|
645
|
+
kind,
|
|
646
|
+
attemptIdentity,
|
|
647
|
+
summary.workflow ?? "",
|
|
648
|
+
String(summary.workflow_version ?? ""),
|
|
649
|
+
String(summary.revision ?? ""),
|
|
650
|
+
summary.status,
|
|
651
|
+
summary.failure?.code ?? "",
|
|
652
|
+
)}`;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function validateSummaryProduct(
|
|
656
|
+
kind: "short" | "editor",
|
|
657
|
+
summary: GenerationSummary,
|
|
658
|
+
): void {
|
|
659
|
+
if (summary.status === "idle") {
|
|
660
|
+
if (summary.run_id !== null || summary.workflow !== null) {
|
|
661
|
+
throw new GenerationStartStateContractError(
|
|
662
|
+
"idle summary carries run identity",
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const workflow = summary.workflow;
|
|
669
|
+
if (workflow === null) {
|
|
670
|
+
throw new GenerationStartStateContractError(
|
|
671
|
+
"non-idle summary has no workflow",
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const belongsToProduct =
|
|
676
|
+
kind === "short"
|
|
677
|
+
? workflow.startsWith("short.")
|
|
678
|
+
: workflow.startsWith("editor.");
|
|
679
|
+
if (!belongsToProduct) {
|
|
680
|
+
throw new GenerationStartStateContractError("workflow/product mismatch");
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if (summary.run_id === null) {
|
|
684
|
+
throw new GenerationStartStateContractError(
|
|
685
|
+
"run identity/workflow mismatch",
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function terminalAttemptIdentity(summary: GenerationSummary): string {
|
|
691
|
+
if (summary.run_id === null) {
|
|
692
|
+
throw new GenerationStartStateContractError(
|
|
693
|
+
"restartable terminal summary has no durable attempt identity",
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
return summary.run_id;
|
|
795
698
|
}
|
|
796
699
|
|
|
797
700
|
/** Stable idempotency key for a logical operation, so a retried call is safe. */
|
|
@@ -817,6 +720,7 @@ export function idemKey(...parts: string[]): string {
|
|
|
817
720
|
/** make_video's editor draft-create key (`POST /editor`). */
|
|
818
721
|
export interface MakeVideoEditorCreateArgs {
|
|
819
722
|
prompt: string;
|
|
723
|
+
segments_count?: number;
|
|
820
724
|
product_subject?: string;
|
|
821
725
|
project_intent?: string;
|
|
822
726
|
language?: string;
|
|
@@ -831,6 +735,7 @@ export function makeVideoEditorCreateKey(a: MakeVideoEditorCreateArgs): string {
|
|
|
831
735
|
return idemKey(
|
|
832
736
|
"make-editor",
|
|
833
737
|
a.prompt,
|
|
738
|
+
a.segments_count === undefined ? "" : String(a.segments_count),
|
|
834
739
|
a.product_subject ?? "",
|
|
835
740
|
a.project_intent ?? "",
|
|
836
741
|
a.language ?? "en",
|
|
@@ -857,6 +762,7 @@ export function makeVideoAutopilotKey(
|
|
|
857
762
|
"autopilot",
|
|
858
763
|
slug,
|
|
859
764
|
a.prompt,
|
|
765
|
+
a.segments_count === undefined ? "" : String(a.segments_count),
|
|
860
766
|
a.product_subject ?? "",
|
|
861
767
|
a.project_intent ?? "",
|
|
862
768
|
a.language ?? "en",
|
|
@@ -872,6 +778,7 @@ export function makeVideoAutopilotKey(
|
|
|
872
778
|
/** create_editor_ad's draft-create and autopilot-start key argument set. */
|
|
873
779
|
export interface EditorAdKeyArgs {
|
|
874
780
|
product_prompt?: string;
|
|
781
|
+
segments_count?: number;
|
|
875
782
|
product_subject?: string;
|
|
876
783
|
project_intent?: string;
|
|
877
784
|
language?: string;
|
|
@@ -906,6 +813,7 @@ export function editorAdCreateKey(a: EditorAdKeyArgs): string {
|
|
|
906
813
|
return idemKey(
|
|
907
814
|
"create-editor",
|
|
908
815
|
a.product_prompt ?? "",
|
|
816
|
+
a.segments_count === undefined ? "" : String(a.segments_count),
|
|
909
817
|
a.product_subject ?? "",
|
|
910
818
|
a.project_intent ?? "",
|
|
911
819
|
a.language ?? "en",
|
|
@@ -931,18 +839,22 @@ export function editorAdCreateKey(a: EditorAdKeyArgs): string {
|
|
|
931
839
|
|
|
932
840
|
/**
|
|
933
841
|
* create_editor_ad's autopilot-start key (`POST /editor/:slug/autopilot`). Folds
|
|
934
|
-
* the create params
|
|
935
|
-
* discriminator
|
|
936
|
-
*
|
|
937
|
-
*
|
|
938
|
-
*
|
|
939
|
-
* only.
|
|
842
|
+
* the create params, approved max_credits cap, AND the canonical start-state
|
|
843
|
+
* discriminator observed immediately before launch. The create endpoint is
|
|
844
|
+
* idempotent and can return an existing failed/cancelled draft, so the start key
|
|
845
|
+
* must rotate with that durable attempt identity instead of replaying an older
|
|
846
|
+
* 24h-cached 202. Self-consistent per-tool only.
|
|
940
847
|
*/
|
|
941
|
-
export function editorAdAutopilotKey(
|
|
848
|
+
export function editorAdAutopilotKey(
|
|
849
|
+
slug: string,
|
|
850
|
+
a: EditorAdKeyArgs,
|
|
851
|
+
startState: string,
|
|
852
|
+
): string {
|
|
942
853
|
return idemKey(
|
|
943
854
|
"autopilot",
|
|
944
855
|
slug,
|
|
945
856
|
a.product_prompt ?? "",
|
|
857
|
+
a.segments_count === undefined ? "" : String(a.segments_count),
|
|
946
858
|
a.product_subject ?? "",
|
|
947
859
|
a.project_intent ?? "",
|
|
948
860
|
a.language ?? "en",
|
|
@@ -961,6 +873,7 @@ export function editorAdAutopilotKey(slug: string, a: EditorAdKeyArgs): string {
|
|
|
961
873
|
? ""
|
|
962
874
|
: String(a.logo_duration_seconds),
|
|
963
875
|
a.max_credits === undefined ? "" : String(a.max_credits),
|
|
876
|
+
startState,
|
|
964
877
|
);
|
|
965
878
|
}
|
|
966
879
|
|