@clipwright/core 0.33.0 → 0.35.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/CHANGELOG.md +251 -0
- package/README.md +3 -1
- package/dist/account-actors.d.ts +173 -14
- package/dist/account-actors.js +142 -10
- package/dist/account-actors.js.map +1 -1
- package/dist/actor-bounds.d.ts +4 -0
- package/dist/actor-bounds.js +4 -0
- package/dist/actor-bounds.js.map +1 -1
- package/dist/actors.d.ts +31 -0
- package/dist/actors.js +26 -0
- package/dist/actors.js.map +1 -1
- package/dist/api-failure.js +2 -1
- package/dist/api-failure.js.map +1 -1
- package/dist/asset-identity.d.ts +6 -0
- package/dist/asset-identity.js +7 -0
- package/dist/asset-identity.js.map +1 -0
- package/dist/assets.d.ts +403 -8
- package/dist/assets.js +132 -22
- package/dist/assets.js.map +1 -1
- package/dist/billing-terms.d.ts +69 -0
- package/dist/billing-terms.js +8 -0
- package/dist/billing-terms.js.map +1 -1
- package/dist/caption-language.d.ts +2 -0
- package/dist/caption-language.js +17 -0
- package/dist/caption-language.js.map +1 -0
- package/dist/contract-dispositions.d.ts +1617 -8
- package/dist/contract-dispositions.js +75 -11
- package/dist/contract-dispositions.js.map +1 -1
- package/dist/contracts/v1/workflow-brief.d.ts +3166 -78
- package/dist/contracts/v1/workflow-brief.js +4 -0
- package/dist/contracts/v1/workflow-brief.js.map +1 -1
- package/dist/contracts/v1/workflow-catalog.d.ts +3712 -8
- package/dist/contracts/v1/workflow-catalog.js +4 -2
- package/dist/contracts/v1/workflow-catalog.js.map +1 -1
- package/dist/contracts/v1/workflow-client.d.ts +2466 -4
- package/dist/contracts/v1/workflow-client.js +6 -3
- package/dist/contracts/v1/workflow-client.js.map +1 -1
- package/dist/contracts/v1/workflow-execution.d.ts +163 -9
- package/dist/contracts/v1/workflow-execution.js +15 -7
- package/dist/contracts/v1/workflow-execution.js.map +1 -1
- package/dist/contracts/v1/workflow-plan.d.ts +6014 -487
- package/dist/contracts/v1/workflow-primitives.d.ts +3 -1
- package/dist/contracts/v1/workflow-primitives.js +1 -1
- package/dist/contracts/v1/workflow-primitives.js.map +1 -1
- package/dist/contracts/v1/workflow-run-read.d.ts +114 -1
- package/dist/edit-billing.d.ts +12 -0
- package/dist/edit-billing.js +8 -0
- package/dist/edit-billing.js.map +1 -1
- package/dist/edit-long-form.d.ts +1 -1
- package/dist/edit-long-form.js +12 -2
- package/dist/edit-long-form.js.map +1 -1
- package/dist/endpoint-notes.d.ts +3 -0
- package/dist/endpoint-notes.js +66 -0
- package/dist/endpoint-notes.js.map +1 -0
- package/dist/endpoints.d.ts +4 -0
- package/dist/endpoints.js +15 -0
- package/dist/endpoints.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +7 -0
- package/dist/errors.js.map +1 -1
- package/dist/face-billing.d.ts +55 -0
- package/dist/face-billing.js +22 -0
- package/dist/face-billing.js.map +1 -1
- package/dist/faceless-price.d.ts +26 -2
- package/dist/faceless-price.js +33 -6
- package/dist/faceless-price.js.map +1 -1
- package/dist/faceless-public.d.ts +6 -1
- package/dist/faceless-public.js +2 -2
- package/dist/faceless-public.js.map +1 -1
- package/dist/generations.d.ts +32 -6
- package/dist/generations.js +82 -27
- package/dist/generations.js.map +1 -1
- package/dist/idempotency.d.ts +2 -0
- package/dist/idempotency.js +7 -4
- package/dist/idempotency.js.map +1 -1
- package/dist/index.d.ts +15 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -1
- package/dist/long-form-pricing.d.ts +7 -4
- package/dist/long-form-pricing.js +13 -10
- package/dist/long-form-pricing.js.map +1 -1
- package/dist/long-form-revision.js +1 -1
- package/dist/long-form-revision.js.map +1 -1
- package/dist/openapi.d.ts +154 -0
- package/dist/openapi.js +334 -0
- package/dist/openapi.js.map +1 -0
- package/dist/pacing.d.ts +15 -0
- package/dist/pacing.js +30 -0
- package/dist/pacing.js.map +1 -1
- package/dist/parts-pricing.d.ts +84 -0
- package/dist/parts-pricing.js +105 -0
- package/dist/parts-pricing.js.map +1 -0
- package/dist/presenter-beats.d.ts +45 -0
- package/dist/presenter-beats.js +144 -0
- package/dist/presenter-beats.js.map +1 -0
- package/dist/prices.d.ts +98 -0
- package/dist/prices.js +189 -0
- package/dist/prices.js.map +1 -0
- package/dist/public-endpoints.d.ts +2 -0
- package/dist/public-endpoints.js +2 -0
- package/dist/public-endpoints.js.map +1 -1
- package/dist/public-responses.d.ts +23 -0
- package/dist/public-responses.js +11 -0
- package/dist/public-responses.js.map +1 -0
- package/dist/reference-analysis.d.ts +683 -0
- package/dist/reference-analysis.js +91 -0
- package/dist/reference-analysis.js.map +1 -0
- package/dist/reference-kinds.d.ts +6 -0
- package/dist/reference-kinds.js +8 -0
- package/dist/reference-kinds.js.map +1 -0
- package/dist/reference-profile.d.ts +633 -0
- package/dist/reference-profile.js +151 -0
- package/dist/reference-profile.js.map +1 -0
- package/dist/reference-staging.d.ts +571 -0
- package/dist/reference-staging.js +151 -0
- package/dist/reference-staging.js.map +1 -0
- package/dist/reference-voice.d.ts +305 -0
- package/dist/reference-voice.js +108 -0
- package/dist/reference-voice.js.map +1 -0
- package/dist/resolved-timeline.d.ts +206 -1
- package/dist/resolved-timeline.js +96 -4
- package/dist/resolved-timeline.js.map +1 -1
- package/dist/run-static-warnings.js +19 -7
- package/dist/run-static-warnings.js.map +1 -1
- package/dist/runs.d.ts +75 -3
- package/dist/runs.js +6 -1
- package/dist/runs.js.map +1 -1
- package/dist/semantic-inserts.d.ts +7 -0
- package/dist/semantic-inserts.js +19 -9
- package/dist/semantic-inserts.js.map +1 -1
- package/dist/skills.d.ts +5838 -22
- package/dist/skills.js +380 -25
- package/dist/skills.js.map +1 -1
- package/dist/templates/callout.d.ts +34 -0
- package/dist/templates/callout.js +51 -0
- package/dist/templates/callout.js.map +1 -0
- package/dist/templates/checklist.d.ts +42 -0
- package/dist/templates/checklist.js +47 -0
- package/dist/templates/checklist.js.map +1 -0
- package/dist/templates/compare.d.ts +26 -0
- package/dist/templates/compare.js +35 -0
- package/dist/templates/compare.js.map +1 -0
- package/dist/templates/end-card.d.ts +16 -0
- package/dist/templates/end-card.js +25 -0
- package/dist/templates/end-card.js.map +1 -0
- package/dist/templates/index.d.ts +171 -0
- package/dist/templates/index.js +394 -0
- package/dist/templates/index.js.map +1 -0
- package/dist/templates/lower-third.d.ts +18 -0
- package/dist/templates/lower-third.js +26 -0
- package/dist/templates/lower-third.js.map +1 -0
- package/dist/templates/notification.d.ts +30 -0
- package/dist/templates/notification.js +32 -0
- package/dist/templates/notification.js.map +1 -0
- package/dist/templates/phone-call.d.ts +27 -0
- package/dist/templates/phone-call.js +43 -0
- package/dist/templates/phone-call.js.map +1 -0
- package/dist/templates/progress.d.ts +22 -0
- package/dist/templates/progress.js +42 -0
- package/dist/templates/progress.js.map +1 -0
- package/dist/templates/quote.d.ts +21 -0
- package/dist/templates/quote.js +29 -0
- package/dist/templates/quote.js.map +1 -0
- package/dist/templates/rating.d.ts +22 -0
- package/dist/templates/rating.js +29 -0
- package/dist/templates/rating.js.map +1 -0
- package/dist/templates/size.d.ts +13 -0
- package/dist/templates/size.js +10 -0
- package/dist/templates/size.js.map +1 -0
- package/dist/templates/slot.d.ts +10 -0
- package/dist/templates/slot.js +5 -0
- package/dist/templates/slot.js.map +1 -0
- package/dist/templates/sounds.d.ts +701 -0
- package/dist/templates/sounds.js +89 -0
- package/dist/templates/sounds.js.map +1 -0
- package/dist/templates/stat.d.ts +20 -0
- package/dist/templates/stat.js +33 -0
- package/dist/templates/stat.js.map +1 -0
- package/dist/templates/tag.d.ts +23 -0
- package/dist/templates/tag.js +31 -0
- package/dist/templates/tag.js.map +1 -0
- package/dist/templates/theme.d.ts +29 -0
- package/dist/templates/theme.js +47 -0
- package/dist/templates/theme.js.map +1 -0
- package/dist/templates/title-card.d.ts +21 -0
- package/dist/templates/title-card.js +30 -0
- package/dist/templates/title-card.js.map +1 -0
- package/dist/tts-backend.d.ts +9 -0
- package/dist/tts-backend.js +21 -0
- package/dist/tts-backend.js.map +1 -1
- package/dist/ugc-call.d.ts +89 -0
- package/dist/ugc-call.js +111 -0
- package/dist/ugc-call.js.map +1 -0
- package/dist/ugc-framing.d.ts +26 -0
- package/dist/ugc-framing.js +64 -0
- package/dist/ugc-framing.js.map +1 -0
- package/dist/ugc-layers.d.ts +88 -0
- package/dist/ugc-layers.js +142 -0
- package/dist/ugc-layers.js.map +1 -0
- package/dist/ugc-video-media.d.ts +12 -0
- package/dist/ugc-video-media.js +39 -0
- package/dist/ugc-video-media.js.map +1 -0
- package/dist/vendor-response.d.ts +4 -0
- package/dist/vendor-response.js +12 -0
- package/dist/vendor-response.js.map +1 -1
- package/dist/voice-admission.d.ts +1244 -1
- package/dist/voice-listing.js +7 -1
- package/dist/voice-listing.js.map +1 -1
- package/dist/voice-pace.d.ts +34 -0
- package/dist/voice-pace.generated.json +72 -0
- package/dist/voice-pace.js +34 -0
- package/dist/voice-pace.js.map +1 -0
- package/dist/voices.d.ts +21 -8
- package/dist/voices.js +44 -19
- package/dist/voices.js.map +1 -1
- package/dist/workflows/catalog.js +2 -1
- package/dist/workflows/catalog.js.map +1 -1
- package/dist/workflows/index.d.ts +2 -0
- package/dist/workflows/index.js +2 -0
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/methods/faceless-explainer.d.ts +697 -3
- package/dist/workflows/methods/kinetic-launch.d.ts +697 -3
- package/dist/workflows/methods/product-image-story.d.ts +697 -3
- package/dist/workflows/methods/reference-remake.d.ts +870 -0
- package/dist/workflows/methods/reference-remake.js +26 -0
- package/dist/workflows/methods/reference-remake.js.map +1 -0
- package/dist/workflows/methods/shared.d.ts +697 -3
- package/dist/workflows/methods/ugc-faq-answer.d.ts +697 -3
- package/dist/workflows/methods/ugc-product-intro.d.ts +697 -3
- package/dist/workflows/methods/website-proof-demo.d.ts +697 -3
- package/dist/workflows/reference-remake.d.ts +663 -0
- package/dist/workflows/reference-remake.js +598 -0
- package/dist/workflows/reference-remake.js.map +1 -0
- package/dist/workflows/validate.js +19 -11
- package/dist/workflows/validate.js.map +1 -1
- package/package.json +9 -3
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { actorIdScope } from '../account-actors.js';
|
|
3
|
+
import { DEFAULT_ASPECT_RATIO } from '../aspect.js';
|
|
4
|
+
import { assetId } from '../asset-identity.js';
|
|
5
|
+
import { captionLanguageOf } from '../caption-language.js';
|
|
6
|
+
import { LONG_FORM_QUALIFICATION_MAX_DURATION_SEC } from '../long-form-profile.js';
|
|
7
|
+
import { estimateDurationSec } from '../pacing.js';
|
|
8
|
+
import { referenceProfile } from '../reference-profile.js';
|
|
9
|
+
import { narrationPaceFromVoice, PAUSE_TRIM_CEILING_S, pauseTrimFromVoice } from '../reference-voice.js';
|
|
10
|
+
import { countActorShots, coverWordOf, lexicalTokens, planSemanticInserts, quoteOccurrences } from '../semantic-inserts.js';
|
|
11
|
+
import { actorId, callStage, isNarratedUgcInput, makeUgcInput, makeUgcInputShape, MAX_NARRATED_ACTOR_SHOTS, ugcCall } from '../skills.js';
|
|
12
|
+
import { CALL_PHONE_DEFAULTS, CALL_STAGE_SIDES } from '../ugc-call.js';
|
|
13
|
+
import { templateFaceWarnings, templateMediaOf, templateTextRefusal } from '../templates/index.js';
|
|
14
|
+
import { faceZone, frameCanvasOf, OVERLAY_POSITIONS, overlayRect, rectsOverlap } from '../ugc-layers.js';
|
|
15
|
+
import { isEnglishScript, measuredPaceOf, PACE_MATCH_SHARE, VOICE_PACE_RANGE, voicesNearestPace } from '../voice-pace.js';
|
|
16
|
+
import { BREAK_TAG_SOURCE, FIXED_PACE_MODELS, resolveTtsModel } from '../voices.js';
|
|
17
|
+
import { workflowPlanDocument } from '../contracts/v1/workflow-plan.js';
|
|
18
|
+
import { workflowText } from '../contracts/v1/workflow-primitives.js';
|
|
19
|
+
import { queryWorkflowMethod } from './catalog.js';
|
|
20
|
+
// The reference-remake compiler (#857 S2a): a profile and an accepted script become a complete plan whose native input is a make_ugc input.
|
|
21
|
+
export const planReferenceRemake = z.strictObject({
|
|
22
|
+
reference_profile: referenceProfile.describe('The reference_profile analyze_reference returned, or one you wrote to the same schema.'),
|
|
23
|
+
script: workflowText(10_000).describe('The accepted narration, copied into the plan unchanged.'),
|
|
24
|
+
actor_id: actorId, voice: makeUgcInputShape.voice, voice_id: makeUgcInputShape.voice_id,
|
|
25
|
+
assets: z.array(z.strictObject({ asset_id: assetId, media_type: z.enum(['image', 'video']) })).max(12).default([])
|
|
26
|
+
.describe('Your ready ast_* media, used in order by graphics that need it: a compare takes two images. Nothing is generated.'),
|
|
27
|
+
site: z.string().min(1).max(32).optional().describe('Your site, for an end card on the closing beat.'),
|
|
28
|
+
audience: workflowText(2000).optional(), message: workflowText(4000).optional(),
|
|
29
|
+
call: z.strictObject({ lines: ugcCall.shape.lines, voices: ugcCall.shape.voices, phone: ugcCall.shape.phone }).optional()
|
|
30
|
+
.describe('Your call for a reference beat where the presenter listens to a recorded call: the presenter listens, then freezes, while it plays. ' +
|
|
31
|
+
'The reference\'s own words are never offered; without lines the presenter keeps talking there.'),
|
|
32
|
+
});
|
|
33
|
+
const CLAUSE_COST = 6;
|
|
34
|
+
const TEXT_KINDS = new Set(['title_card', 'tag']);
|
|
35
|
+
const MEDIA_KINDS = { overlay_media: 'overlays', insert_media: 'inserts', layout_split: 'layout_windows' };
|
|
36
|
+
const MARKER = 'ref-long-form';
|
|
37
|
+
const GENERATE = 'pass a ready ast_* asset, or make one separately with generate_image or generate_video (its own quote), then compile again';
|
|
38
|
+
export function referenceScriptWords(script) {
|
|
39
|
+
const tags = [...script.matchAll(new RegExp(BREAK_TAG_SOURCE, 'g'))].map(match => [match.index, match.index + match[0].length]);
|
|
40
|
+
const words = [];
|
|
41
|
+
for (const chunk of script.matchAll(/\S+/gu)) {
|
|
42
|
+
const from = chunk.index, to = from + chunk[0].length, tokens = lexicalTokens(chunk[0]), end = chunk[0].replace(/["'”’»)\]]+$/u, '');
|
|
43
|
+
const tag = tags.some(([start, stop]) => from < stop && start < to);
|
|
44
|
+
if (tokens.length === 0) {
|
|
45
|
+
if (/^[—–-]+$/u.test(chunk[0]) && words.length > 0)
|
|
46
|
+
words.at(-1).stop ??= 'clause';
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const stop = tag ? undefined : /[.!?…]$/u.test(end) ? 'sentence' : /[,;:]$/u.test(end) ? 'clause' : undefined;
|
|
50
|
+
tokens.forEach((token, index) => {
|
|
51
|
+
words.push({ token, from, to, tag, first: index === 0, last: index === tokens.length - 1,
|
|
52
|
+
...(stop && index === tokens.length - 1 ? { stop } : {}) });
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return words;
|
|
56
|
+
}
|
|
57
|
+
/** Beats share the words by their share of the reference, cut at the nearest sentence end, or clause end at a cost. With fewer sentence ends than
|
|
58
|
+
* beats, neighbours of one role share a span first, latest first as a close stacks its CTA beats; then the shortest inner beats give up their words. */
|
|
59
|
+
export const proportionalSpans = ({ profile, words }) => {
|
|
60
|
+
const length = (group) => group.reduce((sum, index) => sum + profile.beats[index].end_frac - profile.beats[index].start_frac, 0);
|
|
61
|
+
const breaks = words.flatMap((word, index) => word.stop && index + 1 < words.length ? [{ at: index + 1, cost: word.stop === 'sentence' ? 0 : CLAUSE_COST }] : []);
|
|
62
|
+
const sentences = breaks.filter(cut => cut.cost === 0);
|
|
63
|
+
let kept = profile.beats.map((_, index) => [index]);
|
|
64
|
+
const short = kept.length - 1 > sentences.length;
|
|
65
|
+
for (let at = kept.length - 2; at >= 0 && kept.length - 1 > sentences.length; at--) {
|
|
66
|
+
if (profile.beats[kept[at][0]].role === profile.beats[kept[at + 1][0]].role)
|
|
67
|
+
kept.splice(at, 2, [...kept[at], ...kept[at + 1]]);
|
|
68
|
+
}
|
|
69
|
+
const cuts = short && kept.length - 1 <= sentences.length ? sentences : breaks;
|
|
70
|
+
while (kept.length - 1 > cuts.length) {
|
|
71
|
+
const inner = kept.length > 2 ? kept.slice(1, -1) : kept.slice(1);
|
|
72
|
+
const shortest = inner.reduce((best, group) => length(group) < length(best) ? group : best);
|
|
73
|
+
kept = kept.filter(group => group !== shortest);
|
|
74
|
+
}
|
|
75
|
+
const total = kept.reduce((sum, group) => sum + length(group), 0);
|
|
76
|
+
const spans = profile.beats.map(() => null);
|
|
77
|
+
let start = 0, share = 0;
|
|
78
|
+
kept.forEach((group, position) => {
|
|
79
|
+
if (position === kept.length - 1) {
|
|
80
|
+
for (const beat of group)
|
|
81
|
+
spans[beat] = { start, end: words.length };
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
share += length(group);
|
|
85
|
+
const target = (words.length * share) / total, needed = kept.length - 2 - position;
|
|
86
|
+
const options = cuts.filter((cut, index) => cut.at > start && cuts.length - index - 1 >= needed);
|
|
87
|
+
const best = options.reduce((left, right) => Math.abs(right.at - target) + right.cost < Math.abs(left.at - target) + left.cost ? right : left);
|
|
88
|
+
for (const beat of group)
|
|
89
|
+
spans[beat] = { start, end: best.at };
|
|
90
|
+
start = best.at;
|
|
91
|
+
});
|
|
92
|
+
return spans;
|
|
93
|
+
};
|
|
94
|
+
/** The call phone's place and rise from the reference's staging (#914 D1e): its measured rect and face-relative twin, a rise anchored on the call's
|
|
95
|
+
* freeze, behind the presenter. Each part the composition cannot draw, or the staging did not measure, keeps the call scene's default and is named;
|
|
96
|
+
* a profile without staging leaves the call as it was. */
|
|
97
|
+
function callStageOf(profile, listening, note) {
|
|
98
|
+
const staging = profile.staging, beat = profile.beats[listening];
|
|
99
|
+
if (!staging)
|
|
100
|
+
return undefined;
|
|
101
|
+
const inBeat = (t) => t !== undefined && t >= beat.start_s && t < beat.end_s;
|
|
102
|
+
const element = staging.elements.find(entry => entry.kind === 'phone_call' && (entry.beat_index === listening || inBeat(entry.entry?.settled_s)));
|
|
103
|
+
const unmeasured = (what, substitute) => note(listening, `the call phone's ${what} (reference staging did not measure it)`, `${substitute}: default used`);
|
|
104
|
+
if (!element?.rect_hold) {
|
|
105
|
+
unmeasured('place', 'the reference call scene\'s place');
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
const freeze = staging.freezes.findIndex(entry => inBeat(entry.start_s));
|
|
109
|
+
const entry = element.entry, anchor = entry?.anchors?.find(ref => ref.id === `freeze_start#${freeze + 1}`);
|
|
110
|
+
const enter = entry?.style === 'slide' && CALL_STAGE_SIDES.includes(entry.from ?? '') && entry.displacement && entry.duration_s !== undefined && anchor
|
|
111
|
+
? { from: entry.from, displacement: entry.displacement, duration_s: entry.duration_s,
|
|
112
|
+
anchor: { id: 'freeze_start#1', offset_s: anchor.offset_s } } : undefined;
|
|
113
|
+
if (!entry?.style || entry.style === 'unknown')
|
|
114
|
+
unmeasured('entry', 'the call scene\'s rise, 3.96 s before the freeze');
|
|
115
|
+
else if (!anchor)
|
|
116
|
+
note(listening, 'the call phone\'s entry (it is not anchored on the call\'s freeze)', 'the call scene\'s rise, 3.96 s before the freeze');
|
|
117
|
+
else if (!enter)
|
|
118
|
+
note(listening, `the call phone's ${entry.style} entry (side: ${entry.from ?? 'none'})`, 'the call scene\'s rise from below: the phone draws no other entry');
|
|
119
|
+
const layer = element.layer?.value;
|
|
120
|
+
if (layer === undefined || layer === 'unknown')
|
|
121
|
+
unmeasured('layer', 'behind the presenter');
|
|
122
|
+
else if (layer !== 'behind_presenter')
|
|
123
|
+
note(listening, `the call phone ${layer.replaceAll('_', ' ')}`, 'behind the presenter, as the call scene draws it');
|
|
124
|
+
const stage = callStage.safeParse({ rect: element.rect_hold, ...(element.rect_hold_face ? { rect_face: element.rect_hold_face } : {}), ...(enter ? { enter } : {}),
|
|
125
|
+
layer: 'behind_presenter' });
|
|
126
|
+
if (!stage.success)
|
|
127
|
+
note(listening, `the call phone's measured staging (${stage.error.issues[0]?.message ?? 'out of bounds'})`, 'the reference call scene\'s place and rise');
|
|
128
|
+
return stage.success ? stage.data : undefined;
|
|
129
|
+
}
|
|
130
|
+
export function compileReferenceRemake(input, capabilities, planSpans = proportionalSpans) {
|
|
131
|
+
const parsed = planReferenceRemake.safeParse(input);
|
|
132
|
+
if (!parsed.success)
|
|
133
|
+
return { kind: 'invalid', issues: parsed.error.issues.map(issue => ({ kind: 'invalid_request', path: issue.path.join('.'), message: issue.message })) };
|
|
134
|
+
const request = parsed.data, { script } = request, profile = request.reference_profile;
|
|
135
|
+
const words = referenceScriptWords(script), tokens = lexicalTokens(script), language = captionLanguageOf(script);
|
|
136
|
+
if (words.length === 0)
|
|
137
|
+
return { kind: 'invalid', issues: [{ kind: 'invalid_request', path: 'script', message: 'The script has no words to place beats on.' }] };
|
|
138
|
+
// The quote's own length estimate and limit, so a compiled plan is never refused there as too long.
|
|
139
|
+
const duration = estimateDurationSec(script, resolveTtsModel({ voice: request.voice, voice_id: request.voice_id, script }));
|
|
140
|
+
if (duration > LONG_FORM_QUALIFICATION_MAX_DURATION_SEC)
|
|
141
|
+
return { kind: 'invalid', issues: [{ kind: 'invalid_request', path: 'script',
|
|
142
|
+
message: `The script runs about ${duration} s, longer than the ${LONG_FORM_QUALIFICATION_MAX_DURATION_SEC} s a reference remake renders: shorten it.` }] };
|
|
143
|
+
const method = queryWorkflowMethod('reference-remake', capabilities).descriptor;
|
|
144
|
+
const executor = capabilities.executors.find(item => item.skill === 'make_ugc');
|
|
145
|
+
const longForm = executor?.features.includes('long_form') === true && !['warned', 'rejected', 'stored'].includes(executor.field_dispositions.long_form ?? '');
|
|
146
|
+
if (longForm) {
|
|
147
|
+
try {
|
|
148
|
+
planSemanticInserts(script, []);
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
// The chunk test mirrors planSemanticInserts, with break tags taken out so each fault gets its own advice.
|
|
152
|
+
const unsupported = (chunk) => { const n = lexicalTokens(chunk).length; return n > 1 || (n === 0 && !/^\p{P}+$/u.test(chunk)); };
|
|
153
|
+
const bad = [...new Set(script.replace(new RegExp(BREAK_TAG_SOURCE, 'g'), ' ').match(/\S+/gu)?.filter(unsupported) ?? [])].slice(0, 3);
|
|
154
|
+
const fixes = [
|
|
155
|
+
...new RegExp(BREAK_TAG_SOURCE).test(script) ? ['Remove the break tags; commas and full stops set the pauses'] : [],
|
|
156
|
+
...bad.map(chunk => lexicalTokens(chunk).length === 0
|
|
157
|
+
? `Write "${chunk}" as a word or remove it, as "$" becomes "dollars"`
|
|
158
|
+
: `Write "${chunk.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, '')}" as separate words, as "24/7" becomes "twenty-four seven" or "around the clock"`),
|
|
159
|
+
];
|
|
160
|
+
const fix = fixes.join('. ');
|
|
161
|
+
return { kind: 'invalid', issues: [{ kind: 'invalid_request', path: 'script', message: `${error instanceof Error ? error.message : 'Unsupported script'}. ` +
|
|
162
|
+
`${fix}: the long-form path a reference remake runs on counts words this way.` }] };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const templatesOff = executor?.field_dispositions.templates === 'rejected';
|
|
166
|
+
// The reference's caption height, where the deployment draws caption_position (#884); otherwise the style's own place.
|
|
167
|
+
const captionsPlaced = executor?.field_dispositions.caption_position === 'implemented';
|
|
168
|
+
// A wide reference asks for the wide view where it can render: honoured field, a library actor (personal ones have no wide view); graphics follow that face (#884 S5d).
|
|
169
|
+
const framing = profile.presenter_framing === 'wide' && executor?.field_dispositions.presenter_framing === 'implemented'
|
|
170
|
+
&& actorIdScope(request.actor_id) === 'clipwright' ? 'wide' : 'close';
|
|
171
|
+
// Library mic views are wide only, so a close plan asks for none.
|
|
172
|
+
const mic = framing === 'wide' && profile.presenter_mic === true && executor?.field_dispositions.presenter_mic === 'implemented';
|
|
173
|
+
const notes = [];
|
|
174
|
+
const note = (beat, what, substitute) => notes.push({ what: what.slice(0, 200), beat_index: beat, suggested_substitute: substitute.slice(0, 200) });
|
|
175
|
+
const quote = (from, limit, end) => {
|
|
176
|
+
let start = from;
|
|
177
|
+
while (start < end && (words[start].tag || !words[start].first))
|
|
178
|
+
start++;
|
|
179
|
+
let stop = start;
|
|
180
|
+
for (let index = start; index < Math.min(end, start + limit) && !words[index].tag; index++)
|
|
181
|
+
if (words[index].last)
|
|
182
|
+
stop = index + 1;
|
|
183
|
+
if (stop <= start)
|
|
184
|
+
return undefined;
|
|
185
|
+
const text = script.slice(words[start].from, words[stop - 1].to).replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, '');
|
|
186
|
+
const matches = quoteOccurrences(tokens, text), occurrence = matches.findIndex(match => match.startWord === start);
|
|
187
|
+
if (occurrence < 0)
|
|
188
|
+
return undefined;
|
|
189
|
+
return { anchor: { quote: text, ...(matches.length > 1 ? { occurrence } : {}) }, start, end: stop };
|
|
190
|
+
};
|
|
191
|
+
const label = (from, end, limit) => {
|
|
192
|
+
const chunks = [...new Set(words.slice(from, end).filter(word => !word.tag).map(word => script.slice(word.from, word.to)))];
|
|
193
|
+
let fitted;
|
|
194
|
+
for (let count = 1; count <= chunks.length; count++) {
|
|
195
|
+
const text = chunks.slice(0, count).join(' ').replace(/^[^\p{L}\p{N}$€£]+|[^\p{L}\p{N}%]+$/gu, '')
|
|
196
|
+
.replace(/^(and|or|but|so|и|а|но)\s+/iu, '').replace(/\s+(and|or|but|the|a|an|to|in|of|и|а|но|в)$/iu, '');
|
|
197
|
+
if (text && templateTextRefusal(text, limit, language) === undefined)
|
|
198
|
+
fitted = text;
|
|
199
|
+
else if (fitted)
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
return fitted;
|
|
203
|
+
};
|
|
204
|
+
const parts = (span) => {
|
|
205
|
+
const ranges = [];
|
|
206
|
+
let start = span.start;
|
|
207
|
+
for (let index = span.start; index < span.end; index++) {
|
|
208
|
+
if (words[index].stop || index === span.end - 1) {
|
|
209
|
+
ranges.push({ start, end: index + 1 });
|
|
210
|
+
start = index + 1;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return ranges.filter(range => words.slice(range.start, range.end).some(word => !word.tag));
|
|
214
|
+
};
|
|
215
|
+
const wordAt = (span, share) => quote(Math.min(span.end - 1, span.start + Math.max(1, Math.floor(share * (span.end - span.start)))), 2, span.end);
|
|
216
|
+
const spans = planSpans({ profile, words });
|
|
217
|
+
const layers = [];
|
|
218
|
+
const pool = [...request.assets], images = () => pool.filter(asset => asset.media_type === 'image').length;
|
|
219
|
+
const take = (image = false) => {
|
|
220
|
+
const at = pool.findIndex(asset => !image || asset.media_type === 'image');
|
|
221
|
+
return at < 0 ? undefined : pool.splice(at, 1)[0];
|
|
222
|
+
};
|
|
223
|
+
const anchored = [];
|
|
224
|
+
/** Beats whose words keep the presenter in the frame: no full-frame layer and no window, so a framing may sit on them. */
|
|
225
|
+
const onScreen = [];
|
|
226
|
+
profile.beats.forEach((beat, index) => {
|
|
227
|
+
const span = spans[index];
|
|
228
|
+
const lead = span ? quote(span.start, 6, span.end) : undefined;
|
|
229
|
+
if (!span || !lead) {
|
|
230
|
+
note(index, 'this beat (the script has fewer sentence and clause breaks than the reference has beats)', 'its words join a neighbouring beat');
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
anchored.push({ beat: index, anchor: lead.anchor });
|
|
234
|
+
const own = quote(span.start, 4, span.end), envelope = (kind) => ({ id: `ref-${index + 1}-${kind.replaceAll('_', '-')}`, kind,
|
|
235
|
+
anchor: own.anchor, cover_words: span.end - own.start });
|
|
236
|
+
const clauses = parts(span);
|
|
237
|
+
let placed, layered = false, fullFrame = false, windowed = false;
|
|
238
|
+
// Specific graphics first: a text card is what a beat falls back to.
|
|
239
|
+
for (const graphic of [...beat.graphics].sort((left, right) => Number(TEXT_KINDS.has(left.kind)) - Number(TEXT_KINDS.has(right.kind)))) {
|
|
240
|
+
const kind = graphic.kind;
|
|
241
|
+
if (kind === 'particles' || kind === 'unmapped')
|
|
242
|
+
continue;
|
|
243
|
+
if (kind in MEDIA_KINDS) {
|
|
244
|
+
if (layered) {
|
|
245
|
+
note(index, `${kind} (one media layer per beat)`, 'the first one shows here');
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (pool.length === 0) {
|
|
249
|
+
note(index, `${kind} (no image or video left in assets)`, GENERATE);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (!longForm) {
|
|
253
|
+
note(index, `${kind} (media layers need long-form qualification on this deployment)`, 'the presenter stays on screen on these words');
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const asset = take(), field = MEDIA_KINDS[kind], base = { ...envelope(kind), asset_id: asset.asset_id, media_type: asset.media_type };
|
|
257
|
+
const { kind: _kind, ...item } = base;
|
|
258
|
+
const position = graphic.position.replace(/^split_/, ''), wanted = OVERLAY_POSITIONS.includes(position) ? position : 'top_right';
|
|
259
|
+
// Off the face: the reference place, else a corner on its side (right by default), a band last.
|
|
260
|
+
const side = wanted.endsWith('left') ? 'left' : 'right', rank = (place) => place.endsWith(side) ? 0 : place.includes('_') ? 1 : 2;
|
|
261
|
+
const clear = (place) => !rectsOverlap(overlayRect(place, frameCanvasOf(DEFAULT_ASPECT_RATIO)), faceZone(DEFAULT_ASPECT_RATIO, framing));
|
|
262
|
+
const placed = clear(wanted) ? wanted : [...OVERLAY_POSITIONS].sort((left, right) => rank(left) - rank(right)).find(clear) ?? wanted;
|
|
263
|
+
if (field === 'overlays' && placed !== position)
|
|
264
|
+
note(index, `${kind} at ${graphic.position}`, `${placed}, to keep the face clear`);
|
|
265
|
+
layers.push({ field, beat: index, kind, item: field === 'overlays' ? { ...item, position: placed }
|
|
266
|
+
: field === 'layout_windows' ? { ...item, layout: 'split' } : item });
|
|
267
|
+
layered = true;
|
|
268
|
+
fullFrame ||= field === 'inserts';
|
|
269
|
+
windowed ||= field === 'layout_windows';
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (templatesOff) {
|
|
273
|
+
note(index, `${kind} (templates are disabled on this deployment)`, 'the presenter speaks these words without it');
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (placed) {
|
|
277
|
+
note(index, `${kind} (one graphic per beat)`, `the ${placed} on these words`);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const built = template(kind, graphic, span, clauses, beat.presenter_behaviour === 'absent');
|
|
281
|
+
if ('missing' in built) {
|
|
282
|
+
note(index, `${kind} (${built.missing})`, built.substitute);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (built.instead)
|
|
286
|
+
note(index, `${kind} (${built.instead})`, `a ${built.kind} with the same words`);
|
|
287
|
+
if ((built.kind === 'phone_call' || built.kind === 'notification') && /[^\p{Script=Latin}\p{N}\p{P}\p{S}\s]/u.test(script)) {
|
|
288
|
+
note(index, `${built.kind} card text stays English ("${built.kind === 'phone_call' ? 'Incoming call' : 'Messages'}")`, 'edit the plan to change it');
|
|
289
|
+
}
|
|
290
|
+
const item = { ...envelope(built.kind), ...(built.placement ? { placement: built.placement } : {}), data: built.data };
|
|
291
|
+
// A reference place over the face gives way to the kind's default, which every kind keeps off it.
|
|
292
|
+
if ('position' in built.data && templateFaceWarnings({ templates: [item], presenter_framing: framing }).length) {
|
|
293
|
+
const { position: _position, ...data } = built.data;
|
|
294
|
+
item.data = data;
|
|
295
|
+
note(index, `${built.kind} at ${graphic.position}`, `the ${built.kind}'s default place, to keep the face clear`);
|
|
296
|
+
}
|
|
297
|
+
layers.push({ field: 'templates', beat: index, kind: built.kind, item });
|
|
298
|
+
placed = built.kind;
|
|
299
|
+
fullFrame ||= built.placement === 'full';
|
|
300
|
+
}
|
|
301
|
+
if (beat.presenter_behaviour === 'absent' && !fullFrame)
|
|
302
|
+
note(index, 'the presenter leaving the frame', 'the presenter stays on screen; a full-frame insert or template would hide them');
|
|
303
|
+
if (!fullFrame && !windowed)
|
|
304
|
+
onScreen.push({ beat: index, anchor: own.anchor, cover_words: span.end - own.start });
|
|
305
|
+
if (profile.captions.present && profile.captions.emphasis_style !== 'none' && (beat.role === 'hook' || beat.role === 'cta')) {
|
|
306
|
+
const sentence = clauses.find(range => words[range.end - 1].stop === 'sentence') ?? clauses[0];
|
|
307
|
+
const key = quote(Math.max(sentence.start, sentence.end - 3), 3, sentence.end);
|
|
308
|
+
const shared = layers.some(layer => layer.field === 'caption_emphasis' && layer.item.quote === key?.anchor.quote);
|
|
309
|
+
if (key && !shared)
|
|
310
|
+
layers.push({ field: 'caption_emphasis', beat: index, kind: 'caption emphasis', item: { quote: key.anchor.quote, look: profile.captions.emphasis_style } });
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
function template(kind, graphic, span, clauses, absent) {
|
|
314
|
+
const first = clauses[0], fullScreen = graphic.position === 'full_screen';
|
|
315
|
+
const textCard = (instead) => {
|
|
316
|
+
if (absent || fullScreen) {
|
|
317
|
+
const title = label(first.start, first.end, 48);
|
|
318
|
+
if (title)
|
|
319
|
+
return { kind: 'title_card', data: { title }, placement: 'full', ...(instead ? { instead } : {}) };
|
|
320
|
+
}
|
|
321
|
+
const text = label(first.start, first.end, 32);
|
|
322
|
+
const position = /top/.test(graphic.position) ? 'top' : /bottom/.test(graphic.position) ? 'bottom' : graphic.position === 'center' ? 'center' : undefined;
|
|
323
|
+
return text ? { kind: 'tag', data: { text, ...(position ? { position } : {}) }, ...(instead ? { instead } : {}) }
|
|
324
|
+
: { missing: 'no words short enough for a label', substitute: 'the presenter speaks these words without it' };
|
|
325
|
+
};
|
|
326
|
+
const rows = (limit, most) => {
|
|
327
|
+
const pieces = clauses.length >= 2 ? clauses : Array.from({ length: 3 }, (_, part) => ({
|
|
328
|
+
start: span.start + Math.floor((part * (span.end - span.start)) / 3), end: span.start + Math.floor(((part + 1) * (span.end - span.start)) / 3)
|
|
329
|
+
}));
|
|
330
|
+
return pieces.slice(0, most).flatMap(piece => {
|
|
331
|
+
const text = label(piece.start, piece.end, limit), at = quote(piece.start, 2, piece.end);
|
|
332
|
+
return text && at ? [{ text, quote: at.anchor.quote }] : [];
|
|
333
|
+
});
|
|
334
|
+
};
|
|
335
|
+
const number = words.slice(span.start, span.end).find(word => !word.tag && /^\d+(?:[.,]\d+)?$/.test(word.token));
|
|
336
|
+
switch (kind) {
|
|
337
|
+
case 'checklist': {
|
|
338
|
+
const items = rows(24, 4);
|
|
339
|
+
if (items.length < 2)
|
|
340
|
+
return { missing: 'fewer than two short phrases for its rows', substitute: 'the presenter speaks these words without it' };
|
|
341
|
+
return { kind, data: { items: items.map(item => ({ label: item.text, on: false })), clicks: items.map((item, at) => ({ item: at, quote: item.quote })) } };
|
|
342
|
+
}
|
|
343
|
+
case 'progress': {
|
|
344
|
+
const steps = rows(20, 5);
|
|
345
|
+
if (steps.length < 2)
|
|
346
|
+
return { missing: 'fewer than two short phrases for its steps', substitute: 'the presenter speaks these words without it' };
|
|
347
|
+
return { kind, data: { steps: steps.map(step => step.text), advance_at: steps.map(step => step.quote) } };
|
|
348
|
+
}
|
|
349
|
+
case 'phone_call': {
|
|
350
|
+
const answer = wordAt(span, 0.3);
|
|
351
|
+
return answer ? { kind, data: { caller: 'Incoming call', answer_at: answer.anchor.quote } } : textCard('no word to answer the call on');
|
|
352
|
+
}
|
|
353
|
+
case 'compare': {
|
|
354
|
+
if (images() < 2)
|
|
355
|
+
return { missing: 'needs two images in assets for its before and after', substitute: GENERATE };
|
|
356
|
+
const reveal = wordAt(span, Math.min(0.8, Math.max(0.2, graphic.state_changes[0]?.at_frac ?? 0.5)));
|
|
357
|
+
if (!reveal)
|
|
358
|
+
return textCard('no word to reveal on');
|
|
359
|
+
return { kind, data: { before: { asset_id: take(true).asset_id }, after: { asset_id: take(true).asset_id }, reveal_at: reveal.anchor.quote } };
|
|
360
|
+
}
|
|
361
|
+
case 'notification': {
|
|
362
|
+
const title = label(first.start, first.end, 32), body = label(span.start, span.end, 80);
|
|
363
|
+
return title && body ? { kind, data: { app: 'Messages', title, body } } : textCard('no words short enough for it');
|
|
364
|
+
}
|
|
365
|
+
case 'stat': {
|
|
366
|
+
if (!number)
|
|
367
|
+
return textCard('no number in these words');
|
|
368
|
+
const value = Math.round(Number(number.token.replace(/,(\d{3})(?!\d)/g, '$1').replace(',', '.')) * 100) / 100;
|
|
369
|
+
const chunk = script.slice(number.from, number.to), after = words.indexOf(number) + 1;
|
|
370
|
+
const statLabel = after < span.end ? label(after, span.end, 32) : undefined;
|
|
371
|
+
return { kind, placement: fullScreen ? 'full' : 'over', data: { value: Math.min(value, 999_999_999), ...(/[$€£]/.exec(chunk) ? { prefix: /[$€£]/.exec(chunk)[0] } : {}),
|
|
372
|
+
...(chunk.includes('%') ? { suffix: '%' } : {}), ...(statLabel ? { label: statLabel } : {}) } };
|
|
373
|
+
}
|
|
374
|
+
case 'rating': {
|
|
375
|
+
const stars = number ? Number(number.token.replace(',', '.')) : NaN;
|
|
376
|
+
return stars >= 1 && stars <= 5 && Number.isInteger(stars * 2) ? { kind, data: { stars } } : textCard('no star count from 1 to 5 in these words');
|
|
377
|
+
}
|
|
378
|
+
case 'quote': {
|
|
379
|
+
const text = label(first.start, first.end, 120);
|
|
380
|
+
return text ? { kind, data: { text } } : textCard();
|
|
381
|
+
}
|
|
382
|
+
case 'end_card': {
|
|
383
|
+
// The card prints the site itself: the label stops before it, spoken ("x dot io") or written, and drops the preposition left over.
|
|
384
|
+
const site = lexicalTokens(request.site ?? '').filter(part => !['www', 'http', 'https'].includes(part));
|
|
385
|
+
const named = (at) => site.length > 0 && words.slice(at, at + 2 * site.length).map(word => word.token).filter(token => token !== 'dot')
|
|
386
|
+
.slice(0, site.length).join(' ') === site.join(' ');
|
|
387
|
+
const until = Array.from({ length: first.end - first.start }, (_, at) => first.start + at).find(named) ?? first.end;
|
|
388
|
+
const cta = until > first.start ? label(first.start, until, 48)?.replace(/\s+(at|on|via|from|to)$/iu, '') || undefined : undefined;
|
|
389
|
+
return request.site ? { kind, data: { site: request.site, ...(cta ? { cta } : {}) } } : textCard('no site: pass site');
|
|
390
|
+
}
|
|
391
|
+
case 'title_card': return fullScreen || absent ? textCard() : textCard('a band over the presenter, not a full frame');
|
|
392
|
+
case 'tag': return textCard();
|
|
393
|
+
default: return { missing: `it needs ${kind === 'callout' ? 'a point on screen to mark' : 'a name and a role'}`, substitute: 'the presenter speaks these words without it' };
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// A beat where the presenter listens to recorded audio is the reference's call (S5, D9): the client's lines open after the last quote before it,
|
|
397
|
+
// and the call brings its own in-call phone over the gap, so the reference's phone graphic goes.
|
|
398
|
+
const listening = profile.beats.findIndex(beat => beat.presenter_behaviour === 'listening' && beat.sounds.some(sound => sound.kind === 'recorded_audio'));
|
|
399
|
+
// The call opens on the sentence end nearest the beat's start, never inside a sentence (#924: reel B's opened after "That's why these days").
|
|
400
|
+
const beatStart = listening >= 0 ? spans[listening]?.start : undefined;
|
|
401
|
+
const resumeAt = beatStart === undefined ? undefined : words.flatMap((word, index) => word.stop === 'sentence' && index + 1 < words.length ? [index + 1] : [])
|
|
402
|
+
.reduce((best, at) => best === undefined || Math.abs(at - beatStart) < Math.abs(best - beatStart) ? at : best, undefined);
|
|
403
|
+
const reach = Math.min(4, resumeAt ?? 0);
|
|
404
|
+
const before = resumeAt === undefined || resumeAt >= words.length ? undefined
|
|
405
|
+
: Array.from({ length: reach }, (_, back) => quote(resumeAt - reach + back, reach - back, resumeAt)).find(found => found?.end === resumeAt);
|
|
406
|
+
let call;
|
|
407
|
+
if (listening >= 0 && !request.call)
|
|
408
|
+
note(listening, 'the reference\'s call needs your lines', 'the presenter keeps talking');
|
|
409
|
+
else if (listening >= 0 && !longForm)
|
|
410
|
+
note(listening, 'the reference\'s call (a call needs long-form qualification on this deployment)', 'the presenter keeps talking');
|
|
411
|
+
else if (listening >= 0 && !before)
|
|
412
|
+
note(listening, 'the reference\'s call (no sentence end with words after it to open it on)', 'the presenter keeps talking');
|
|
413
|
+
else if (listening >= 0 && before && request.call) {
|
|
414
|
+
const stage = callStageOf(profile, listening, note);
|
|
415
|
+
call = { after: before.anchor, lines: request.call.lines, ...(request.call.voices ? { voices: request.call.voices } : {}),
|
|
416
|
+
phone: { ...CALL_PHONE_DEFAULTS, ...request.call.phone }, ...(stage ? { stage } : {}) };
|
|
417
|
+
for (const layer of layers.filter(layer => layer.beat === listening && layer.kind === 'phone_call'))
|
|
418
|
+
layers.splice(layers.indexOf(layer), 1);
|
|
419
|
+
}
|
|
420
|
+
// Measured staging the composition draws only for the call's phone: any other measured graphic the plan places keeps its default place, and says
|
|
421
|
+
// so once per beat and kind (rule 3).
|
|
422
|
+
const placed = new Set(layers.filter(layer => layer.field !== 'framing' && layer.field !== 'caption_emphasis').map(layer => layer.beat));
|
|
423
|
+
const named = new Set();
|
|
424
|
+
for (const element of profile.staging?.elements ?? []) {
|
|
425
|
+
const graphic = element.graphic_index === null ? undefined : profile.beats[element.beat_index]?.graphics[element.graphic_index];
|
|
426
|
+
const key = `${element.beat_index}:${graphic?.kind}`;
|
|
427
|
+
if (!graphic || !placed.has(element.beat_index) || named.has(key) || (call && element.beat_index === listening && element.kind === 'phone_call'))
|
|
428
|
+
continue;
|
|
429
|
+
named.add(key);
|
|
430
|
+
note(element.beat_index, `${graphic.kind}: its measured place and entry (reference staging)`, 'its default place; measured staging is drawn for the call\'s phone only');
|
|
431
|
+
}
|
|
432
|
+
const aggregate = (values, keep, what, substitute) => {
|
|
433
|
+
for (const value of new Set(values.filter((entry) => entry !== undefined && keep(entry)))) {
|
|
434
|
+
const at = values.flatMap((entry, index) => entry === value ? [index] : []);
|
|
435
|
+
note(at[0], what(value, at.map(index => index + 1).join(', ')), substitute);
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
// A punch_in beat cuts close, the next on-screen beat back to medium; a flash into a speaking beat swaps the size, so each cut moves the face
|
|
439
|
+
// FRAMING_SCALE's 18% (S2, #924). A punch on a beat that gave up its words goes to the next on-screen beat.
|
|
440
|
+
const cutClose = new Set(), flashed = new Set();
|
|
441
|
+
let close = false, previous = -1;
|
|
442
|
+
for (const { beat: at, anchor, cover_words } of longForm ? onScreen : []) {
|
|
443
|
+
const carried = profile.beats.slice(previous + 1, at).flatMap((skipped, index) => !spans[previous + 1 + index] && skipped.camera === 'punch_in'
|
|
444
|
+
? [previous + 1 + index] : []);
|
|
445
|
+
const beat = profile.beats[at], punch = beat.camera === 'punch_in' || carried.length > 0;
|
|
446
|
+
const flash = beat.transition_in === 'flash' && beat.presenter_behaviour === 'speaking';
|
|
447
|
+
if (punch || flash || close) {
|
|
448
|
+
layers.push({ field: 'framing', beat: at, kind: 'framing', item: { id: `ref-${at + 1}-framing`, anchor, cover_words,
|
|
449
|
+
size: punch || (flash && !close) ? 'close' : 'medium', ...(flash ? { transition: 'flash' } : {}) } });
|
|
450
|
+
if (punch)
|
|
451
|
+
for (const index of [at, ...carried])
|
|
452
|
+
cutClose.add(index);
|
|
453
|
+
if (flash)
|
|
454
|
+
flashed.add(at);
|
|
455
|
+
}
|
|
456
|
+
close = punch || (flash && !close);
|
|
457
|
+
previous = at;
|
|
458
|
+
}
|
|
459
|
+
aggregate(profile.beats.map((beat, at) => flashed.has(at) ? undefined : beat.transition_in), value => !['none', 'cut'].includes(value), (value, beats) => `${value} transitions into beats ${beats}`, 'cuts');
|
|
460
|
+
aggregate(profile.beats.map((beat, at) => cutClose.has(at) ? undefined : beat.camera), value => value !== 'static', (value, beats) => `${value} camera on beats ${beats}`, 'a static frame');
|
|
461
|
+
const music = profile.beats.findIndex(beat => beat.sounds.some(sound => sound.kind === 'music'));
|
|
462
|
+
if (music >= 0)
|
|
463
|
+
note(music, 'the music bed', 'no music; Clipwright adds none yet');
|
|
464
|
+
// The narration chases the presenter's measured pace through the voice's speed (owner, 25.10); a model without one leaves the voice as the lever (#890).
|
|
465
|
+
const speechModel = resolveTtsModel({ voice: request.voice, voice_id: request.voice_id, script });
|
|
466
|
+
const pace = narrationPaceFromVoice(profile.voice), fixedPace = FIXED_PACE_MODELS.has(speechModel);
|
|
467
|
+
if (pace.kind === 'unmeasured')
|
|
468
|
+
note(0, `narration pace (${pace.reason})`, 'the voice speaks at its own pace');
|
|
469
|
+
else if (fixedPace) {
|
|
470
|
+
const named = measuredPaceOf(request.voice), gap = (wps) => Math.abs(wps - pace.target_wps);
|
|
471
|
+
// Only voices nearer than the named one: the nearest of a gender can still be farther off.
|
|
472
|
+
const nearer = ['female', 'male'].map(gender => [gender, voicesNearestPace(pace.target_wps, gender, 2, named?.voice)
|
|
473
|
+
.filter(entry => !named || gap(entry.words_per_second) < gap(named.words_per_second))])
|
|
474
|
+
.filter(([, voices]) => voices.length).map(([gender, voices]) => `${gender} ${voices.map(entry => `${entry.voice} ${entry.words_per_second}`).join(', ')}`);
|
|
475
|
+
const own = named ? `${named.voice} speaks ${named.words_per_second}` : `English voices speak ${VOICE_PACE_RANGE} words/s`;
|
|
476
|
+
if (!named || gap(named.words_per_second) > PACE_MATCH_SHARE * pace.target_wps) {
|
|
477
|
+
note(0, `narration pace ${pace.target_wps} words/s of the reference presenter`, isEnglishScript(script, language === 'en')
|
|
478
|
+
? `${speechModel} has no speed; ${own}. ${nearer.length ? `Nearer voices: ${nearer.join('; ')}` : 'No measured voice is nearer'}`
|
|
479
|
+
: `${speechModel} has no speed: the voice keeps its own pace; voice paces are measured on English only`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
else if (pace.clamped) {
|
|
483
|
+
note(0, `narration pace ${pace.target_wps} words/s of the reference presenter (speed ${pace.wanted} wanted)`, `voice_pace ${pace.voice_pace}, the end of the speed range: the narration may run ${pace.wanted > pace.voice_pace ? 'slower' : 'faster'} than the reference`);
|
|
484
|
+
}
|
|
485
|
+
// Speed first, then the residual: pauses longer than the presenter's word-gap medians are cut in silence only (owner, 25.10).
|
|
486
|
+
const pauseTrim = pauseTrimFromVoice(profile.voice), pauseTargets = pauseTrim?.targets;
|
|
487
|
+
if (!pauseTargets)
|
|
488
|
+
note(0, 'narration pauses (the reference presenter\'s pauses were not measured)', 'pauses stay as the voice speaks them');
|
|
489
|
+
for (const { key, median_s } of pauseTrim?.capped ?? []) {
|
|
490
|
+
note(0, `narration pauses ${key} ${Math.round(median_s * 100) / 100} s of the reference presenter`, `pause_trim ${key} ${PAUSE_TRIM_CEILING_S}, the longest target: these pauses may run shorter than the reference`);
|
|
491
|
+
}
|
|
492
|
+
let native = {}, marked = false;
|
|
493
|
+
// Every pass that does not break drops one layer or adds the one marker, so the loop ends.
|
|
494
|
+
for (;;) {
|
|
495
|
+
const field = (name) => { const items = layers.filter(layer => layer.field === name).map(layer => layer.item); return items.length ? { [name]: items } : {}; };
|
|
496
|
+
native = { script, actor_id: request.actor_id, ...(framing === 'wide' ? { presenter_framing: framing } : {}), ...(mic ? { presenter_mic: true } : {}),
|
|
497
|
+
...(request.voice ? { voice: request.voice } : {}), ...(request.voice_id ? { voice_id: request.voice_id } : {}),
|
|
498
|
+
...(pace.kind === 'paced' && !fixedPace ? { voice_pace: pace.voice_pace } : {}), ...(pauseTargets ? { pause_trim: pauseTargets } : {}),
|
|
499
|
+
...(profile.captions.present ? { captions: true, caption_style: profile.captions.words_per_caption_max <= 2 ? 'tiktok' : profile.captions.words_per_caption_max <= 4 ? 'hormozi' : 'youtube',
|
|
500
|
+
...(captionsPlaced ? { caption_position: profile.captions.position } : {}) } : {}),
|
|
501
|
+
...field('caption_emphasis'), ...field('templates'), ...field('overlays'), ...field('inserts'), ...field('layout_windows'), ...field('framing'),
|
|
502
|
+
...(call ? { call } : {}) };
|
|
503
|
+
// The quote refuses customer media without this policy; the note below discloses it (rule 3).
|
|
504
|
+
if (['overlays', 'inserts', 'layout_windows'].some(name => native[name]) || templateMediaOf(native.templates).length) {
|
|
505
|
+
native.broll_policy = 'anyone';
|
|
506
|
+
}
|
|
507
|
+
const checked = makeUgcInput.safeParse(native);
|
|
508
|
+
// A call the run refuses goes, and the presenter keeps talking there.
|
|
509
|
+
const refusedCall = checked.error?.issues.find(entry => entry.path[0] === 'call');
|
|
510
|
+
if (refusedCall && call) {
|
|
511
|
+
call = undefined;
|
|
512
|
+
note(listening, `the reference's call (${refusedCall.message})`, 'the presenter keeps talking');
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
if (!checked.success) {
|
|
516
|
+
const issue = checked.error.issues.find(entry => typeof entry.path[1] === 'number' && layers.some(layer => layer.field === entry.path[0]));
|
|
517
|
+
if (!issue)
|
|
518
|
+
return { kind: 'invalid', issues: checked.error.issues.map(entry => ({ kind: 'invalid_request', path: entry.path.join('.'), message: entry.message })) };
|
|
519
|
+
const dropped = layers.filter(layer => layer.field === issue.path[0])[issue.path[1]];
|
|
520
|
+
layers.splice(layers.indexOf(dropped), 1);
|
|
521
|
+
note(dropped.beat, `${dropped.kind} (${issue.message})`, 'left out; edit the plan to place it');
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
// The quote's actor-shot limit: the insert covering the fewest words goes first.
|
|
525
|
+
const inserts = layers.filter(layer => layer.field === 'inserts');
|
|
526
|
+
const plan = inserts.length ? planSemanticInserts(script, inserts.map(layer => {
|
|
527
|
+
const item = layer.item;
|
|
528
|
+
return { insertId: item.id, mediaId: item.id, anchor: item.anchor, ...(item.cover_words === undefined ? {} : { coverWords: item.cover_words }) };
|
|
529
|
+
})) : undefined;
|
|
530
|
+
const shots = plan ? countActorShots(plan) : 1;
|
|
531
|
+
if (plan && (shots === 0 || shots > MAX_NARRATED_ACTOR_SHOTS)) {
|
|
532
|
+
const fewest = plan.inserts.reduce((best, entry) => coverWordOf(entry) - entry.startWord < coverWordOf(best) - best.startWord ? entry : best);
|
|
533
|
+
const dropped = inserts.find(layer => layer.item.id === fewest.insertId);
|
|
534
|
+
layers.splice(layers.indexOf(dropped), 1);
|
|
535
|
+
note(dropped.beat, `${dropped.kind} (inserts leave ${shots} actor appearances; a run renders 1 to ${MAX_NARRATED_ACTOR_SHOTS})`, 'left out; the presenter speaks these words');
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
// A remake bills on the long-form tariff: with nothing else narrated, a medium framing (scale 1) over the whole script makes it so.
|
|
539
|
+
const lead = longForm && !marked && !isNarratedUgcInput(native) ? quote(0, 6, words.length) : undefined;
|
|
540
|
+
if (lead) {
|
|
541
|
+
marked = true;
|
|
542
|
+
layers.push({ field: 'framing', beat: 0, kind: 'framing', item: { id: MARKER, anchor: lead.anchor, cover_words: words.length - lead.start, size: 'medium' } });
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
break;
|
|
546
|
+
}
|
|
547
|
+
const disclosed = notes.length;
|
|
548
|
+
if (layers.some(layer => layer.item.id === MARKER)) {
|
|
549
|
+
note(0, 'the single-take path that bills every second as presenter time', 'the long-form path: one presenter take billed by the seconds it fills');
|
|
550
|
+
}
|
|
551
|
+
if (native.broll_policy === 'anyone') {
|
|
552
|
+
const media = layers.find(layer => ['overlays', 'inserts', 'layout_windows'].includes(layer.field)
|
|
553
|
+
|| (layer.field === 'templates' && templateMediaOf([layer.item]).length > 0));
|
|
554
|
+
note(media?.beat ?? 0, 'a check that your media shows no people', 'your media is shown as you supplied it (broll_policy=anyone)');
|
|
555
|
+
}
|
|
556
|
+
// Billing and media terms go first, so the 40-entry cap below never cuts them (rule 3).
|
|
557
|
+
notes.unshift(...notes.splice(disclosed));
|
|
558
|
+
let beats = [];
|
|
559
|
+
try {
|
|
560
|
+
planSemanticInserts(script, []);
|
|
561
|
+
beats = anchored.map(({ beat, anchor }) => ({ id: `beat-${beat + 1}`, role: profile.beats[beat].role, anchor, required: false }));
|
|
562
|
+
}
|
|
563
|
+
catch {
|
|
564
|
+
note(0, 'advisory beat anchors (the script holds a tag that plan beats cannot count words through)', 'the plan keeps no beats; the graphics still sit on their words');
|
|
565
|
+
}
|
|
566
|
+
// Past the bound the compiler's own gaps come first, then one analyser entry for each beat whose undrawn graphic needs one (the schema refuses it bare).
|
|
567
|
+
const needsEntry = (beat) => profile.beats[beat].graphics.some(graphic => graphic.kind === 'particles' || graphic.kind === 'unmapped');
|
|
568
|
+
// A plan that plays the call drops the analyser's word that recorded audio is not played on its beat: the client would read both.
|
|
569
|
+
const analysed = call ? profile.unmapped.filter(entry => entry.beat_index !== listening || !/recorded/i.test(entry.what)) : profile.unmapped;
|
|
570
|
+
const unmapped = (() => {
|
|
571
|
+
if (analysed.length + notes.length <= 40)
|
|
572
|
+
return [...analysed, ...notes];
|
|
573
|
+
const kept = notes.slice(0, 39), rest = analysed;
|
|
574
|
+
const covered = new Set(kept.map(entry => entry.beat_index));
|
|
575
|
+
for (const entry of rest)
|
|
576
|
+
if (kept.length < 39 && needsEntry(entry.beat_index) && !covered.has(entry.beat_index)) {
|
|
577
|
+
kept.push(entry);
|
|
578
|
+
covered.add(entry.beat_index);
|
|
579
|
+
}
|
|
580
|
+
for (const entry of rest)
|
|
581
|
+
if (kept.length < 39 && !kept.includes(entry))
|
|
582
|
+
kept.push(entry);
|
|
583
|
+
return [...kept, { what: `${analysed.length + notes.length - kept.length} more items`, beat_index: 0,
|
|
584
|
+
suggested_substitute: 'fewer graphics in the reference, or edit the plan' }];
|
|
585
|
+
})();
|
|
586
|
+
const firstSentence = parts({ start: 0, end: words.length })[0];
|
|
587
|
+
const document = workflowPlanDocument.safeParse({ v: 1,
|
|
588
|
+
method: { method_id: method.id, version: method.version, content_digest: method.content_digest, capability_digest: capabilities.content_digest },
|
|
589
|
+
brief: { audience: request.audience ?? 'Viewers of short ads in the style of the reference',
|
|
590
|
+
message: request.message ?? script.slice(words[firstSentence.start].from, words[firstSentence.end - 1].to),
|
|
591
|
+
script, native: { skill: 'make_ugc', input: native },
|
|
592
|
+
reference_profile: { ...profile, music: '', unmapped,
|
|
593
|
+
beats: profile.beats.map(beat => ({ ...beat, spoken_summary: '', graphics: beat.graphics.map(graphic => ({ ...graphic, label_hint: '' })) })) } },
|
|
594
|
+
beats, claims: [], assets: [] });
|
|
595
|
+
return document.success ? { kind: 'compiled', document: document.data }
|
|
596
|
+
: { kind: 'invalid', issues: document.error.issues.map(issue => ({ kind: 'invalid_plan', path: issue.path.join('.'), message: issue.message })) };
|
|
597
|
+
}
|
|
598
|
+
//# sourceMappingURL=reference-remake.js.map
|