@clipwright/core 0.39.0 → 0.40.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/dist/account-actors.d.ts +93 -2
- package/dist/account-actors.js +135 -20
- package/dist/account-actors.js.map +1 -1
- package/dist/actors.d.ts +24 -2
- package/dist/actors.js +30 -9
- package/dist/actors.js.map +1 -1
- package/dist/ad-pause-trim.d.ts +21 -0
- package/dist/ad-pause-trim.js +16 -0
- package/dist/ad-pause-trim.js.map +1 -0
- package/dist/aspect.d.ts +5 -0
- package/dist/aspect.js +7 -0
- package/dist/aspect.js.map +1 -1
- package/dist/assets.d.ts +2 -1
- package/dist/assets.js +10 -11
- package/dist/assets.js.map +1 -1
- package/dist/billing-terms.d.ts +8 -0
- package/dist/broll-policy.js +2 -3
- package/dist/broll-policy.js.map +1 -1
- package/dist/caption-geometry.js +10 -8
- package/dist/caption-geometry.js.map +1 -1
- package/dist/contract-dispositions.d.ts +1575 -100
- package/dist/contract-dispositions.js +100 -14
- package/dist/contract-dispositions.js.map +1 -1
- package/dist/contracts/v1/workflow-brief.d.ts +10762 -2790
- package/dist/contracts/v1/workflow-brief.js +16 -1
- package/dist/contracts/v1/workflow-brief.js.map +1 -1
- package/dist/contracts/v1/workflow-catalog.d.ts +11612 -3536
- package/dist/contracts/v1/workflow-client.d.ts +5429 -1187
- package/dist/contracts/v1/workflow-client.js +9 -5
- package/dist/contracts/v1/workflow-client.js.map +1 -1
- package/dist/contracts/v1/workflow-execution.d.ts +242 -0
- package/dist/contracts/v1/workflow-execution.js +2 -2
- package/dist/contracts/v1/workflow-execution.js.map +1 -1
- package/dist/contracts/v1/workflow-next.d.ts +225 -0
- package/dist/contracts/v1/workflow-next.js +21 -0
- package/dist/contracts/v1/workflow-next.js.map +1 -0
- package/dist/contracts/v1/workflow-plan.d.ts +18556 -6429
- package/dist/contracts/v1/workflow-plan.js +2 -1
- package/dist/contracts/v1/workflow-plan.js.map +1 -1
- package/dist/contracts/v1/workflow-primitives.d.ts +20 -1
- package/dist/contracts/v1/workflow-primitives.js +8 -2
- package/dist/contracts/v1/workflow-primitives.js.map +1 -1
- package/dist/contracts/v1/workflow-run-read.d.ts +50 -0
- package/dist/creator-profile.d.ts +1 -0
- package/dist/creator-profile.js +13 -7
- package/dist/creator-profile.js.map +1 -1
- package/dist/design.d.ts +921 -0
- package/dist/design.js +183 -0
- package/dist/design.js.map +1 -0
- package/dist/edit-candidate.d.ts +2 -0
- package/dist/edit-candidate.js +3 -0
- package/dist/edit-candidate.js.map +1 -1
- package/dist/edit-faceless.js +2 -1
- package/dist/edit-faceless.js.map +1 -1
- package/dist/endpoint-notes.js +19 -0
- package/dist/endpoint-notes.js.map +1 -1
- package/dist/endpoints.js +21 -4
- 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 +39 -0
- package/dist/face-billing.js +11 -1
- package/dist/face-billing.js.map +1 -1
- package/dist/faceless-action-shots.d.ts +31 -0
- package/dist/faceless-action-shots.js +51 -0
- package/dist/faceless-action-shots.js.map +1 -0
- package/dist/faceless-media-record.d.ts +56 -0
- package/dist/faceless-media-record.js +6 -2
- package/dist/faceless-media-record.js.map +1 -1
- package/dist/faceless-price.d.ts +22 -0
- package/dist/faceless-price.js +27 -3
- package/dist/faceless-price.js.map +1 -1
- package/dist/faceless-public-plan.d.ts +10 -1
- package/dist/faceless-public-plan.js +4 -0
- package/dist/faceless-public-plan.js.map +1 -1
- package/dist/faceless-public.d.ts +5583 -184
- package/dist/faceless-public.js +110 -12
- package/dist/faceless-public.js.map +1 -1
- package/dist/feedback.js +1 -1
- package/dist/feedback.js.map +1 -1
- package/dist/generations-output.js +1 -1
- package/dist/generations-output.js.map +1 -1
- package/dist/generations.js +1 -1
- package/dist/index.d.ts +10 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/live-shot-kinds.d.ts +22 -0
- package/dist/live-shot-kinds.js +17 -0
- package/dist/live-shot-kinds.js.map +1 -0
- package/dist/mcp-ugc-descriptions.d.ts +32 -0
- package/dist/mcp-ugc-descriptions.js +112 -0
- package/dist/mcp-ugc-descriptions.js.map +1 -0
- package/dist/motion-styles.d.ts +54 -0
- package/dist/motion-styles.js +67 -0
- package/dist/motion-styles.js.map +1 -0
- package/dist/moves.d.ts +222 -0
- package/dist/moves.js +81 -0
- package/dist/moves.js.map +1 -0
- package/dist/music.d.ts +18 -0
- package/dist/music.js +29 -0
- package/dist/music.js.map +1 -0
- package/dist/openapi.d.ts +3 -1
- package/dist/openapi.js +32 -5
- package/dist/openapi.js.map +1 -1
- package/dist/parts-pricing.d.ts +14 -3
- package/dist/parts-pricing.js +23 -5
- package/dist/parts-pricing.js.map +1 -1
- package/dist/prices.d.ts +6 -1
- package/dist/prices.js +56 -17
- package/dist/prices.js.map +1 -1
- package/dist/reference-analysis.d.ts +134 -0
- package/dist/reference-analysis.js +45 -33
- package/dist/reference-analysis.js.map +1 -1
- package/dist/reference-display.js +3 -0
- package/dist/reference-display.js.map +1 -1
- package/dist/reference-kinds.d.ts +3 -2
- package/dist/reference-kinds.js +2 -1
- package/dist/reference-kinds.js.map +1 -1
- package/dist/reference-profile.d.ts +435 -0
- package/dist/reference-profile.js +40 -1
- package/dist/reference-profile.js.map +1 -1
- package/dist/reference-staging.d.ts +54 -2
- package/dist/reference-staging.js +28 -5
- package/dist/reference-staging.js.map +1 -1
- package/dist/reference-text-spans.d.ts +26 -0
- package/dist/reference-text-spans.js +76 -0
- package/dist/reference-text-spans.js.map +1 -0
- package/dist/resolved-timeline.d.ts +173 -0
- package/dist/resolved-timeline.js +15 -4
- package/dist/resolved-timeline.js.map +1 -1
- package/dist/run-media.d.ts +5 -0
- package/dist/run-media.js +17 -0
- package/dist/run-media.js.map +1 -0
- package/dist/run-static-warnings.js +12 -3
- package/dist/run-static-warnings.js.map +1 -1
- package/dist/runs.d.ts +49 -0
- package/dist/runs.js +5 -0
- package/dist/runs.js.map +1 -1
- package/dist/scene-graph.d.ts +547 -0
- package/dist/scene-graph.js +296 -0
- package/dist/scene-graph.js.map +1 -0
- package/dist/scene-place.d.ts +2 -0
- package/dist/scene-place.js +6 -0
- package/dist/scene-place.js.map +1 -1
- package/dist/semantic-inserts.d.ts +2 -1
- package/dist/semantic-inserts.js +4 -4
- package/dist/semantic-inserts.js.map +1 -1
- package/dist/skills.d.ts +11737 -5424
- package/dist/skills.js +239 -52
- package/dist/skills.js.map +1 -1
- package/dist/templates/chart.d.ts +35 -0
- package/dist/templates/chart.js +38 -0
- package/dist/templates/chart.js.map +1 -0
- package/dist/templates/chat-window.d.ts +58 -0
- package/dist/templates/chat-window.js +57 -0
- package/dist/templates/chat-window.js.map +1 -0
- package/dist/templates/compare.d.ts +10 -2
- package/dist/templates/compare.js +13 -9
- package/dist/templates/compare.js.map +1 -1
- package/dist/templates/hand-title.d.ts +32 -0
- package/dist/templates/hand-title.js +47 -0
- package/dist/templates/hand-title.js.map +1 -0
- package/dist/templates/index.d.ts +32 -6
- package/dist/templates/index.js +91 -47
- package/dist/templates/index.js.map +1 -1
- package/dist/templates/lower-third.d.ts +11 -0
- package/dist/templates/lower-third.js +8 -3
- package/dist/templates/lower-third.js.map +1 -1
- package/dist/templates/notification.d.ts +24 -2
- package/dist/templates/notification.js +21 -12
- package/dist/templates/notification.js.map +1 -1
- package/dist/templates/phone-screen.d.ts +37 -0
- package/dist/templates/phone-screen.js +41 -0
- package/dist/templates/phone-screen.js.map +1 -0
- package/dist/templates/pointer-chips.d.ts +40 -0
- package/dist/templates/pointer-chips.js +53 -0
- package/dist/templates/pointer-chips.js.map +1 -0
- package/dist/templates/quote.d.ts +2 -2
- package/dist/templates/quote.js +5 -5
- package/dist/templates/quote.js.map +1 -1
- package/dist/templates/race.d.ts +32 -0
- package/dist/templates/race.js +43 -0
- package/dist/templates/race.js.map +1 -0
- package/dist/templates/size.d.ts +20 -0
- package/dist/templates/size.js +14 -0
- package/dist/templates/size.js.map +1 -1
- package/dist/templates/slot.js +1 -1
- package/dist/templates/slot.js.map +1 -1
- package/dist/templates/sounds.d.ts +221 -4
- package/dist/templates/sounds.js +40 -10
- package/dist/templates/sounds.js.map +1 -1
- package/dist/templates/terminal.d.ts +46 -0
- package/dist/templates/terminal.js +46 -0
- package/dist/templates/terminal.js.map +1 -0
- package/dist/templates/video-grid.d.ts +52 -0
- package/dist/templates/video-grid.js +50 -0
- package/dist/templates/video-grid.js.map +1 -0
- package/dist/templates/word-fill.d.ts +22 -0
- package/dist/templates/word-fill.js +29 -0
- package/dist/templates/word-fill.js.map +1 -0
- package/dist/templates/word-title.d.ts +24 -0
- package/dist/templates/word-title.js +33 -0
- package/dist/templates/word-title.js.map +1 -0
- package/dist/trend-search.d.ts +336 -0
- package/dist/trend-search.js +307 -0
- package/dist/trend-search.js.map +1 -0
- package/dist/ugc-framing.d.ts +2 -1
- package/dist/ugc-framing.js +3 -2
- package/dist/ugc-framing.js.map +1 -1
- package/dist/ugc-layers.d.ts +36 -4
- package/dist/ugc-layers.js +26 -5
- package/dist/ugc-layers.js.map +1 -1
- package/dist/ugc-scenes.d.ts +40 -0
- package/dist/ugc-scenes.js +35 -0
- package/dist/ugc-scenes.js.map +1 -0
- package/dist/uploads.d.ts +1 -1
- package/dist/uploads.js +9 -9
- package/dist/uploads.js.map +1 -1
- package/dist/voice-admission.d.ts +4767 -836
- package/dist/voice-catalog-data.d.ts +1 -0
- package/dist/voice-catalog.d.ts +2 -0
- package/dist/voice-catalog.generated.json +286 -0
- package/dist/voice-catalog.js +1 -0
- package/dist/voice-catalog.js.map +1 -1
- package/dist/voice-pace.generated.json +65 -0
- package/dist/voices.d.ts +1 -0
- package/dist/voices.js +2 -1
- package/dist/voices.js.map +1 -1
- package/dist/workflows/index.d.ts +2 -0
- package/dist/workflows/index.js +1 -0
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/methods/faceless-explainer.d.ts +2275 -188
- package/dist/workflows/methods/kinetic-launch.d.ts +2275 -188
- package/dist/workflows/methods/product-image-story.d.ts +2275 -188
- package/dist/workflows/methods/reference-remake.d.ts +2275 -188
- package/dist/workflows/methods/shared.d.ts +2275 -188
- package/dist/workflows/methods/ugc-faq-answer.d.ts +2275 -188
- package/dist/workflows/methods/ugc-product-intro.d.ts +2275 -188
- package/dist/workflows/methods/website-proof-demo.d.ts +2275 -188
- package/dist/workflows/motion-director.d.ts +59 -0
- package/dist/workflows/motion-director.js +118 -0
- package/dist/workflows/motion-director.js.map +1 -0
- package/dist/workflows/reference-remake.d.ts +1172 -2
- package/dist/workflows/reference-remake.js +938 -114
- package/dist/workflows/reference-remake.js.map +1 -1
- package/dist/workflows/remake-chooser.d.ts +16 -0
- package/dist/workflows/remake-chooser.js +10 -0
- package/dist/workflows/remake-chooser.js.map +1 -0
- package/dist/workflows/remake-motion.d.ts +100 -0
- package/dist/workflows/remake-motion.js +237 -0
- package/dist/workflows/remake-motion.js.map +1 -0
- package/dist/workflows/validate.js +1 -1
- package/dist/workflows/validate.js.map +1 -1
- package/package.json +5 -1
|
@@ -1,48 +1,132 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { actorIdScope } from '../account-actors.js';
|
|
3
2
|
import { DEFAULT_ASPECT_RATIO } from '../aspect.js';
|
|
4
3
|
import { assetId } from '../asset-identity.js';
|
|
5
4
|
import { captionLanguageOf } from '../caption-language.js';
|
|
6
5
|
import { LONG_FORM_QUALIFICATION_MAX_DURATION_SEC } from '../long-form-profile.js';
|
|
7
|
-
import {
|
|
6
|
+
import { CANVAS_FPS } from '../media-timebase.js';
|
|
7
|
+
import { estimateDurationSec, WORDS_PER_SECOND_MAX } from '../pacing.js';
|
|
8
|
+
import { referenceAnalysisId } from '../reference-analysis.js';
|
|
9
|
+
import { actorIdScope } from '../account-actors.js';
|
|
8
10
|
import { referenceProfileInput } from '../reference-profile.js';
|
|
9
11
|
import { narrationPaceFromVoice, PAUSE_TRIM_CEILING_S, pauseTrimFromVoice } from '../reference-voice.js';
|
|
12
|
+
import { AD_PAUSE_TRIM } from '../ad-pause-trim.js';
|
|
10
13
|
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';
|
|
14
|
+
import { actorId, callStage, insertBox, isNarratedUgcInput, makeUgcInput, makeUgcInputShape, MAX_INSERTS, MAX_LIVE_SHOTS, MAX_NARRATED_ACTOR_SHOTS, motionStyle, ugcCall, ugcTemplate } from '../skills.js';
|
|
12
15
|
import { CALL_PHONE_DEFAULTS, CALL_STAGE_SIDES } from '../ugc-call.js';
|
|
13
|
-
import { TEMPLATE_KINDS, templateFaceWarnings, templateMediaOf, templateTextRefusal } from '../templates/index.js';
|
|
14
|
-
import {
|
|
16
|
+
import { MAX_TEMPLATES, TEMPLATE_ENTER_FRAMES, TEMPLATE_KIND_IDS, TEMPLATE_KINDS, TEMPLATE_PLACEMENTS, templateFaceWarnings, templateMediaOf, templateTextRefusal } from '../templates/index.js';
|
|
17
|
+
import { MAX_SCENES, SCENE_BRIEF_MAX_CHARS, SCENE_TEXT_MAX_CHARS } from '../ugc-scenes.js';
|
|
18
|
+
import { MAX_FRAMING } from '../ugc-framing.js';
|
|
19
|
+
import { CAPTION_POSITION_VERTICAL, captionBandBox, captionEdgeAt } from '../caption-geometry.js';
|
|
15
20
|
import { fitPlacedRect, place, plannedFace, presenterPlace, resolvePlace, sharedArea } from '../scene-place.js';
|
|
16
|
-
import { FACE_COVER_SHARE, faceZone, frameCanvasOf, LAYER_ENTERS, MAX_ENTER_OFFSET_S, MAX_OVERLAYS, OVERLAY_POSITIONS, overlayRect, ownPixels, rectsOverlap } from '../ugc-layers.js';
|
|
21
|
+
import { FACE_COVER_SHARE, faceZone, frameCanvasOf, LAYER_ENTERS, layerWordRange, MAX_ENTER_OFFSET_S, MAX_OVERLAYS, OVERLAY_POSITIONS, overlayRect, ownPixels, MAX_LAYOUT_WINDOWS, rectsOverlap, SPLIT_MEDIA_SHARE } from '../ugc-layers.js';
|
|
17
22
|
import { isEnglishScript, measuredPaceOf, PACE_MATCH_SHARE, VOICE_PACE_RANGE, voicesNearestPace } from '../voice-pace.js';
|
|
18
23
|
import { BREAK_TAG_SOURCE, FIXED_PACE_MODELS, resolveTtsModel } from '../voices.js';
|
|
19
|
-
import { workflowPlanDocument } from '../contracts/v1/workflow-plan.js';
|
|
20
|
-
import { workflowText } from '../contracts/v1/workflow-primitives.js';
|
|
24
|
+
import { saveWorkflowPlan, workflowPlanDocument } from '../contracts/v1/workflow-plan.js';
|
|
25
|
+
import { workflowId, workflowText } from '../contracts/v1/workflow-primitives.js';
|
|
21
26
|
import { queryWorkflowMethod } from './catalog.js';
|
|
27
|
+
import { MUSIC_BED_CREDITS, musicRequest } from '../music.js';
|
|
28
|
+
import { MAX_MOVES, ugcMove } from '../moves.js';
|
|
29
|
+
import { lookName } from './motion-director.js';
|
|
30
|
+
import { preferChosen } from './remake-chooser.js';
|
|
31
|
+
import { beatCue, FLASH_SNAP_S, planMotion } from './remake-motion.js';
|
|
22
32
|
// The reference-remake compiler (#857 S2a): a profile and an accepted script become a complete plan whose native input is a make_ugc input.
|
|
23
|
-
|
|
33
|
+
const remakeFields = {
|
|
34
|
+
plan_id: workflowId.optional(), base_version: saveWorkflowPlan.shape.base_version.optional(),
|
|
35
|
+
review_notes: z.array(workflowText(2000)).max(10).optional(),
|
|
24
36
|
reference_profile: referenceProfileInput.describe('The reference_profile analyze_reference returned, or one you wrote to the same schema.'),
|
|
25
37
|
script: workflowText(10_000).describe('The accepted narration, copied into the plan unchanged.'),
|
|
26
38
|
actor_id: actorId, voice: makeUgcInputShape.voice, voice_id: makeUgcInputShape.voice_id,
|
|
27
|
-
assets: z.array(z.strictObject({ asset_id: assetId, media_type: z.enum(['image', 'video'])
|
|
28
|
-
.describe('
|
|
39
|
+
assets: z.array(z.strictObject({ asset_id: assetId, media_type: z.enum(['image', 'video']),
|
|
40
|
+
anchor: z.string().trim().min(1).max(300).optional().describe('Words of the script, as written, it shows on: always placed there.') }))
|
|
41
|
+
.max(12).default([])
|
|
42
|
+
.describe('Your ready ast_* media. Without anchor each goes to the line its name matches, else in order (named in warnings). Nothing is generated.'),
|
|
43
|
+
line_edits: z.array(z.strictObject({ quote: z.string().trim().min(1).max(300).describe('Words of the script, as written.'),
|
|
44
|
+
template: z.strictObject({ kind: z.enum(TEMPLATE_KIND_IDS), data: z.record(z.string(), z.unknown()), placement: z.enum(TEMPLATE_PLACEMENTS).optional() })
|
|
45
|
+
.optional().describe('The graphic on these words, as a make_ugc template: kind, data, placement.') }))
|
|
46
|
+
.max(MAX_TEMPLATES).optional()
|
|
47
|
+
.describe('The user\'s shot notes: on these words the reference\'s graphics go, and the template shows, if given. Pass all of them on every compile.'),
|
|
29
48
|
site: z.string().min(1).max(32).optional().describe('Your site, for an end card on the closing beat.'),
|
|
30
|
-
audience: workflowText(2000).optional(),
|
|
49
|
+
audience: workflowText(2000).optional().describe('Who watches and whom they trust where, as shown to the user: for example a homeowner ' +
|
|
50
|
+
'who trusts an electrician at the fuse box. Kept in the plan brief.'),
|
|
51
|
+
message: workflowText(4000).optional(),
|
|
31
52
|
call: z.strictObject({ lines: ugcCall.shape.lines, voices: ugcCall.shape.voices, phone: ugcCall.shape.phone }).optional()
|
|
32
53
|
.describe('Your call for a reference beat where the presenter listens to a recorded call: the presenter listens, then freezes, while it plays. ' +
|
|
33
54
|
'The reference\'s own words are never offered; without lines the presenter keeps talking there.'),
|
|
55
|
+
add_moves: z.boolean().default(true).describe('false: only the reference\'s moves, none of ours.'),
|
|
56
|
+
moves: z.array(ugcMove).min(1).max(MAX_MOVES).optional().describe('Your list_moves picks, as make_ugc moves; each replaces what the plan drew ' +
|
|
57
|
+
'on its words, but a line edit\'s graphic wins. One not drawn is named in warnings[].'),
|
|
58
|
+
edit_sounds: z.boolean().optional().describe('true: a sound on every cut, as make_ugc edit_sounds. Off unless you ask.'),
|
|
59
|
+
music: musicRequest.optional().describe(`Music under the voice, as make_ugc music: ${MUSIC_BED_CREDITS} credits on the plan's quote. Off unless you ask.`),
|
|
60
|
+
live_shots: z.boolean().optional().describe('Live shots of the actor, as make_ugc shots. Omitted: beats where the reference presenter gestures ' +
|
|
61
|
+
'on screen; true: every beat where they speak or react on screen; false: none. Costs more than the usual take: each shot kind has its own ' +
|
|
62
|
+
'presenter-second price in list_prices, shown on the plan\'s quote.'),
|
|
63
|
+
motion_style: motionStyle.optional().describe('As make_ugc motion_style; spans sit on the script\'s words. Free.'),
|
|
64
|
+
};
|
|
65
|
+
const revisionPair = (value, context) => {
|
|
66
|
+
if ((value.plan_id === undefined) !== (value.base_version === undefined)) {
|
|
67
|
+
context.addIssue({ code: z.ZodIssueCode.custom, path: ['plan_id'], message: 'plan_id and base_version must be supplied together' });
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
/** What the compiler reads: the profile itself. */
|
|
71
|
+
export const planReferenceRemake = z.strictObject(remakeFields).superRefine(revisionPair);
|
|
72
|
+
/** What a client sends (#1172): the profile, or the analysis_id of an analysis the server reads it from. */
|
|
73
|
+
export const planReferenceRemakeRequest = z.strictObject({ ...remakeFields,
|
|
74
|
+
reference_profile: remakeFields.reference_profile.optional(),
|
|
75
|
+
analysis_id: referenceAnalysisId.optional().describe('The analysis_id of a succeeded full analyze_reference: the server reads its reference_profile. ' +
|
|
76
|
+
'Pass it instead of reference_profile.'),
|
|
77
|
+
}).superRefine((value, context) => {
|
|
78
|
+
revisionPair(value, context);
|
|
79
|
+
if ((value.analysis_id === undefined) === (value.reference_profile === undefined)) {
|
|
80
|
+
context.addIssue({ code: z.ZodIssueCode.custom, path: ['analysis_id'], message: 'pass exactly one of analysis_id or reference_profile' });
|
|
81
|
+
}
|
|
34
82
|
});
|
|
35
83
|
const CLAUSE_COST = 6;
|
|
36
84
|
const TEXT_KINDS = new Set(['title_card', 'tag']);
|
|
37
85
|
const MEDIA_KINDS = { overlay_media: 'overlays', insert_media: 'inserts', layout_split: 'layout_windows' };
|
|
38
86
|
const MARKER = 'ref-long-form';
|
|
87
|
+
/** Beats that show the product: a card of their words does not stand in for it. */
|
|
88
|
+
const SHOWN_ROLES = new Set(['demo', 'before_after']);
|
|
89
|
+
/** The share of a reference's length without its presenter past which a faceless video is the closer remake. */
|
|
90
|
+
const FACELESS_SHARE = 0.8;
|
|
39
91
|
const GENERATE = 'pass a ready ast_* asset, or make one separately with generate_image or generate_video (its own quote), then compile again';
|
|
92
|
+
const TERMINAL_SCREEN = /\bterminal\b|\bcommand[- ]line\b|\bconsole\b|\bshell\b|\bcli\b/i;
|
|
93
|
+
const CHAT_SCREEN = /\bchat|\bmessag|\bconversation|\bprompt\b|\bassistant\b|\bdms?\b|\brepl(?:y|ies)\b/i;
|
|
94
|
+
const PHONE_SCREEN = /\b(?:smart)?phones?\b|\bmobile\b|\biphone\b|\bandroid\b|\breels?\b|\bfeed\b|\btiktok\b|\binstagram\b|\bshorts\b|\block ?screen\b/i;
|
|
95
|
+
/** The drawn kind an analyser device stands for (#1112, #1113): a terminal, a chat or a phone on a product screen, a collage of clips, a full-frame
|
|
96
|
+
* kinetic title, arrows marking the subject. */
|
|
97
|
+
function drawnKindOf(graphic) {
|
|
98
|
+
if (graphic.source_kind === 'product_ui' || graphic.source_kind === 'screenshot') {
|
|
99
|
+
return TERMINAL_SCREEN.test(graphic.label_hint) ? 'terminal' : CHAT_SCREEN.test(graphic.label_hint) ? 'chat_window'
|
|
100
|
+
: PHONE_SCREEN.test(graphic.label_hint) ? 'phone_screen' : undefined;
|
|
101
|
+
}
|
|
102
|
+
if (graphic.device === 'collage')
|
|
103
|
+
return 'video_grid';
|
|
104
|
+
if (graphic.device === 'arrow' && (graphic.kind === 'unmapped' || graphic.kind === 'callout'))
|
|
105
|
+
return 'pointer_chips';
|
|
106
|
+
return graphic.device === 'text_effect' && graphic.position === 'full_screen' ? 'word_title' : undefined;
|
|
107
|
+
}
|
|
40
108
|
/** The saved plan keeps the reference's techniques, not its words: the remake copies no line of it, and the analysis keeps them already. */
|
|
41
109
|
const withoutWords = ({ transcript: _transcript, hook: _hook, ...profile }) => profile;
|
|
110
|
+
/** What the actor does in a live shot for each gesture the reference presenter makes; `other` names nothing to draw. */
|
|
111
|
+
const GESTURE_ACTIONS = { both_hands_to_viewer: 'reaches both hands toward the viewer',
|
|
112
|
+
point_at_viewer: 'points at the viewer', point_at_element: 'points to the side of the frame', count_on_fingers: 'counts the points on their fingers',
|
|
113
|
+
hold_object: 'holds an object up to the camera', open_palms: 'opens both palms toward the viewer', wave: 'waves at the viewer', shrug: 'shrugs',
|
|
114
|
+
thumbs_up: 'gives a thumbs up', other: undefined };
|
|
115
|
+
const REACTION = 'reacts to the words with a warm smile and a nod';
|
|
116
|
+
const SHOT_SIZE_RANK = { close: 0, wide: 1, full: 2 };
|
|
117
|
+
/** An acting shot's first frame, wider than the take (#1175 S7): the beat's measured size when wider (medium and wide are the wide view), else */
|
|
118
|
+
/** wide on a close take, since an action needs the hands in frame; undefined keeps the take's view. */
|
|
119
|
+
function shotFramingOf(size, take) {
|
|
120
|
+
const measured = size === 'full' ? 'full' : size === 'medium' || size === 'wide' ? 'wide' : size === 'close' ? 'close' : undefined;
|
|
121
|
+
const wanted = measured ?? 'wide';
|
|
122
|
+
return wanted !== 'close' && SHOT_SIZE_RANK[wanted] > SHOT_SIZE_RANK[take] ? wanted : undefined;
|
|
123
|
+
}
|
|
42
124
|
/** A layer sits under the captions when its top is at most this share of the height below them and its centre this share of the width off theirs. */
|
|
43
125
|
const UNDER_CAPTIONS = 0.03;
|
|
44
126
|
/** With no caption box measured, a centred layer whose top lies this far below where top-anchored captions start: a line to a short block. */
|
|
45
127
|
const UNMEASURED_CAPTIONS_BELOW = [0.03, 0.15];
|
|
128
|
+
/** The cuts a hook's live shot may take of ours, flash first: both are pictures only, so none adds a sound the customer did not ask for. */
|
|
129
|
+
const HOOK_SHOT_CUTS = ['flash', 'light_leak'];
|
|
46
130
|
const ENTER_OF = { pop: 'pop', fade: 'fade', slide: 'slide', scale_up: 'scale_up', tilt_in: 'tilt_in' };
|
|
47
131
|
const EXIT_OF = { cut: 'cut', fade: 'fade', slide: 'slide', pop: 'pop_out' };
|
|
48
132
|
const FRAME = { left: 0, top: 0, width: 1, height: 1 };
|
|
@@ -51,6 +135,68 @@ const median = (values) => {
|
|
|
51
135
|
const sorted = [...values].sort((a, b) => a - b), mid = sorted.length >> 1;
|
|
52
136
|
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
53
137
|
};
|
|
138
|
+
/** What a scene draws for each device a scene can draw (#1107); footage, photos, 3D and effects need media or a look the format lacks. */
|
|
139
|
+
const SCENE_DEVICES = { icon: 'a simple flat icon', emoji: 'a flat drawn icon in place of an emoji',
|
|
140
|
+
sticker: 'a sticker-style badge', arrow: 'a hand-drawn arrow pointing at the subject', highlight: 'a pen stroke under the key words',
|
|
141
|
+
text_effect: 'an animated word effect', chart: 'a simple animated chart', map: 'a simple stylised map with a pin', other: 'a simple motion graphic' };
|
|
142
|
+
/** Devices that carry the beat's words. */
|
|
143
|
+
const WORD_DEVICES = new Set(['sticker', 'highlight', 'text_effect']);
|
|
144
|
+
/** A quoted span in straight or typographic quotes, each closed by its own pair; an apostrophe inside a word opens none. */
|
|
145
|
+
const QUOTED = /"[^"]*"|(?<![\p{L}\p{N}])'[^']*'(?![\p{L}\p{N}])|‘[^’]*’|“[^”]*”|„[^“”]*[“”]|«[^»]*»|‹[^›]*›/gu;
|
|
146
|
+
const COLOUR_WORDS = /#[0-9a-f]{3,8}\b|\b(red|orange|yellow|green|teal|cyan|blue|navy|indigo|purple|violet|lilac|lavender|magenta|pink|brown|beige|black|white|gr[ae]y|gold|silver|neon|pastel|dark|light|bright)\b/giu;
|
|
147
|
+
/** Name words that say nothing of the line a picture belongs on. */
|
|
148
|
+
const NAME_NOISE = new Set(['image', 'photo', 'picture', 'pic', 'shot', 'screenshot', 'video', 'clip', 'final', 'copy', 'new', 'the', 'and', 'with', 'for']);
|
|
149
|
+
/** One word, inflected or not: equal, a shared stem of four letters, or a three-letter word and a four-letter one that open alike (pay, paid). */
|
|
150
|
+
function sameStem(left, right) {
|
|
151
|
+
if (left === right)
|
|
152
|
+
return true;
|
|
153
|
+
const [short, long] = left.length <= right.length ? [left, right] : [right, left];
|
|
154
|
+
let shared = 0;
|
|
155
|
+
while (shared < short.length && short[shared] === long[shared])
|
|
156
|
+
shared++;
|
|
157
|
+
return short.length >= 4 ? shared >= 4 : short.length === 3 && long.length === 4 && shared >= 2;
|
|
158
|
+
}
|
|
159
|
+
const nameWords = (name) => [...new Set(name.toLowerCase().split(/[^\p{L}\p{N}]+/u)
|
|
160
|
+
.filter(word => word.length >= 3 && !/^\p{N}+$/u.test(word) && !NAME_NOISE.has(word)))];
|
|
161
|
+
/** A drawn mark on something in the reference's own shot (#1172): a circle around it, an arrow at it. */
|
|
162
|
+
const MARKED = /\b(?:around|circling|encircling|circles|pointing (?:at|to)|points (?:at|to)|aimed at|marking)\s+((?:[\p{L}-]+\s*){1,4})/iu;
|
|
163
|
+
const MARK_PLACE = new Set(['the', 'a', 'an', 'left', 'right', 'top', 'bottom', 'upper', 'lower', 'middle', 'centre', 'center', 'first', 'second',
|
|
164
|
+
'third', 'other', 'each', 'both', 'two', 'three', 'small', 'big', 'large', 'side']);
|
|
165
|
+
const MARK_STOP = new Set(['with', 'and', 'in', 'on', 'of', 'at', 'while', 'as', 'to', 'from', 'over', 'under', 'near']);
|
|
166
|
+
/** What our own shot always holds: the presenter. */
|
|
167
|
+
const PRESENTER_WORDS = ['presenter', 'person', 'man', 'woman', 'face', 'head', 'hand', 'hands', 'speaker', 'host', 'creator', 'actor'];
|
|
168
|
+
/** The object a reference graphic marks in its own shot, as the analyser wrote it, or undefined when it marks none. */
|
|
169
|
+
function markedObject(label) {
|
|
170
|
+
const found = MARKED.exec(label.replace(QUOTED, ' ').replace(COLOUR_WORDS, ' '));
|
|
171
|
+
if (!found)
|
|
172
|
+
return undefined;
|
|
173
|
+
const words = [];
|
|
174
|
+
for (const word of found[1].toLowerCase().split(/[^\p{L}]+/u).filter(Boolean)) {
|
|
175
|
+
if (MARK_STOP.has(word))
|
|
176
|
+
break;
|
|
177
|
+
words.push(word);
|
|
178
|
+
}
|
|
179
|
+
const object = words.filter(word => !MARK_PLACE.has(word));
|
|
180
|
+
return object.length ? object : undefined;
|
|
181
|
+
}
|
|
182
|
+
/** The reference's own call for engagement on its closing beat: likes, follows, a comment. */
|
|
183
|
+
const ENGAGEMENT = /\b(?:like|likes|follow|subscribe|comment|share|save|favou?rite|bell|heart)/i;
|
|
184
|
+
/** The analysis's name for a graphic in its gap list: its device, else its own kind. */
|
|
185
|
+
export const sceneGapWhat = (graphic) => graphic.device && graphic.device !== 'other' ? graphic.device.replace(/_/g, ' ') : graphic.source_kind;
|
|
186
|
+
/** A scene's brief from the analyser's reading of a graphic, with the reference's quoted words and colours left out: the scene is ours.
|
|
187
|
+
* The beat's role closes it, so the scene's author can choose motion that serves the beat. */
|
|
188
|
+
export function sceneBriefOf(graphic, asked, site, words, role) {
|
|
189
|
+
const label = graphic.label_hint.replace(QUOTED, ' ').replace(COLOUR_WORDS, ' ').replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
190
|
+
const device = graphic.device ?? 'other', what = SCENE_DEVICES[device];
|
|
191
|
+
if (!what || (device === 'other' && !label && !asked))
|
|
192
|
+
return undefined;
|
|
193
|
+
const changes = [...new Set(graphic.state_changes.map(entry => entry.change.replace(/_/g, ' ')).filter(change => change !== 'other'))];
|
|
194
|
+
const brief = `${what}${label ? ` (${label})` : ''}, at the ${graphic.position.replace(/_/g, ' ')} of the frame${changes.length ? `; it ${changes.join(', then ')}` : ''}` +
|
|
195
|
+
(role && role !== 'other' ? `; for the ${role.replace(/_/g, ' ')} beat` : '');
|
|
196
|
+
const text = site && /\b(site|url|link|domain|website|web address)\b|\.(com|io|ai|app|co)\b/iu.test(graphic.label_hint) ? site
|
|
197
|
+
: WORD_DEVICES.has(device) ? words.slice(0, SCENE_TEXT_MAX_CHARS) : undefined;
|
|
198
|
+
return { brief: brief.replace(/[\p{Cc}]/gu, ' ').slice(0, SCENE_BRIEF_MAX_CHARS), ...(text ? { text } : {}) };
|
|
199
|
+
}
|
|
54
200
|
/** The reference presenter's face while its graphics show (#973 S5): each element's box over its face-unit twin gives the face; freezes stand in. */
|
|
55
201
|
function referenceFaceOf(profile) {
|
|
56
202
|
const staging = profile.staging, { width, height } = profile.source;
|
|
@@ -159,7 +305,9 @@ function callStageOf(profile, listening, note) {
|
|
|
159
305
|
note(listening, `the call phone's measured staging (${stage.error.issues[0]?.message ?? 'out of bounds'})`, 'the reference call scene\'s place and rise');
|
|
160
306
|
return stage.success ? stage.data : undefined;
|
|
161
307
|
}
|
|
162
|
-
|
|
308
|
+
/** `choose`: the server's chooser among looks the plan adds of its own (#1121); absent, the compiler keeps its own order.
|
|
309
|
+
* `facts`: the names and roles of the request's assets, which place an asset on the line it shows (#1170). */
|
|
310
|
+
export function compileReferenceRemake(input, capabilities, planSpans = proportionalSpans, choose, facts) {
|
|
163
311
|
const parsed = planReferenceRemake.safeParse(input);
|
|
164
312
|
if (!parsed.success)
|
|
165
313
|
return { kind: 'invalid', issues: parsed.error.issues.map(issue => ({ kind: 'invalid_request', path: issue.path.join('.'), message: issue.message })) };
|
|
@@ -195,15 +343,24 @@ export function compileReferenceRemake(input, capabilities, planSpans = proporti
|
|
|
195
343
|
}
|
|
196
344
|
}
|
|
197
345
|
const templatesOff = executor?.field_dispositions.templates === 'rejected';
|
|
346
|
+
// Scenes made for the run (#1107): the graphics they draw, by `beat:graphic`, leave the gap list and are recorded as scenes in the plan.
|
|
347
|
+
const scenesOn = longForm && executor?.field_dispositions.scenes === 'implemented', scened = new Set();
|
|
198
348
|
// The reference's caption height, where the run draws caption_position (#884); otherwise the style's own place.
|
|
199
349
|
const captionsPlaced = executor?.field_dispositions.caption_position === 'implemented';
|
|
200
|
-
// A wide reference asks for
|
|
201
|
-
|
|
202
|
-
|
|
350
|
+
// A wide or full reference asks for that view where the field is honoured; graphics follow that face (#884 S5d). A personal actor asks too
|
|
351
|
+
// (#1175 S7): one with the view renders it, one without renders close and admission's warnings[] say so.
|
|
352
|
+
const framing = (profile.presenter_framing === 'wide' || profile.presenter_framing === 'full')
|
|
353
|
+
&& executor?.field_dispositions.presenter_framing === 'implemented' ? profile.presenter_framing : 'close';
|
|
354
|
+
// Every library actor has a wide view; a personal one may not, so its graphics keep off the close face as well.
|
|
355
|
+
const placeFramings = framing !== 'close' && actorIdScope(request.actor_id) !== 'clipwright' ? ['close', framing] : [framing];
|
|
203
356
|
// Library mic views are wide only, so a close plan asks for none.
|
|
204
357
|
const mic = framing === 'wide' && profile.presenter_mic === true && executor?.field_dispositions.presenter_mic === 'implemented';
|
|
205
358
|
const notes = [];
|
|
206
359
|
const note = (beat, what, substitute) => notes.push({ what: what.slice(0, 200), beat_index: beat, suggested_substitute: substitute.slice(0, 200) });
|
|
360
|
+
// A mic the plan cannot ask for is named, never dropped silently (rule 3): mic views are waist-up only (#1175 S7 acceptance r1).
|
|
361
|
+
if (!mic && profile.presenter_mic === true && executor?.field_dispositions.presenter_mic === 'implemented') {
|
|
362
|
+
note(0, 'the reference presenter\'s handheld mic', `no mic: mic views are waist-up only, and this plan frames the presenter ${framing}`);
|
|
363
|
+
}
|
|
207
364
|
const quote = (from, limit, end) => {
|
|
208
365
|
let start = from;
|
|
209
366
|
while (start < end && (words[start].tag || !words[start].first))
|
|
@@ -245,6 +402,24 @@ export function compileReferenceRemake(input, capabilities, planSpans = proporti
|
|
|
245
402
|
return ranges.filter(range => words.slice(range.start, range.end).some(word => !word.tag));
|
|
246
403
|
};
|
|
247
404
|
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);
|
|
405
|
+
/** A word in capitals the closing words ask the viewer to write ("FİYAT"), with a tag of its words: the word before it to its clause's end. */
|
|
406
|
+
const commentCall = (span, clauses) => {
|
|
407
|
+
for (let at = span.start; at < span.end; at++) {
|
|
408
|
+
const word = words[at], written = script.slice(word.from, word.to).replace(/^[^\p{L}]+|[^\p{L}]+$/gu, '');
|
|
409
|
+
if (word.tag || !word.first || !word.last || written.length < 3 || !/^\p{Lu}+$/u.test(written))
|
|
410
|
+
continue;
|
|
411
|
+
const clause = clauses.find(range => range.start <= at && at < range.end);
|
|
412
|
+
const from = clause && at > clause.start ? at - 1 : at, text = clause && label(from, clause.end, 32), found = clause && quote(from, 4, clause.end);
|
|
413
|
+
if (!clause || !text || !found)
|
|
414
|
+
continue;
|
|
415
|
+
return { word: written, text: text.charAt(0).toLocaleUpperCase(/[ıİşŞğĞ]/u.test(script) ? 'tr' : 'en') + text.slice(1), anchor: found.anchor,
|
|
416
|
+
cover_words: clause.end - found.start };
|
|
417
|
+
}
|
|
418
|
+
return undefined;
|
|
419
|
+
};
|
|
420
|
+
/** The words of what our shots can show: the script, the assets' names and the presenter (#1172). */
|
|
421
|
+
const shownWords = () => [...tokens.flatMap(token => token.split(/[^\p{L}\p{N}]+/u)),
|
|
422
|
+
...request.assets.flatMap(asset => nameWords(facts?.get(asset.asset_id)?.name ?? '')), ...PRESENTER_WORDS].filter(word => word.length >= 3);
|
|
248
423
|
// Measured staging drawn as the reference drew it (#973 S5): a box under the captions, on the face or on the frame, its entry from our list.
|
|
249
424
|
const canvas = frameCanvasOf(DEFAULT_ASPECT_RATIO), measuredFace = referenceFaceOf(profile);
|
|
250
425
|
const presenterOk = longForm && executor?.field_dispositions.presenter_place === 'implemented';
|
|
@@ -256,8 +431,9 @@ export function compileReferenceRemake(input, capabilities, planSpans = proporti
|
|
|
256
431
|
}
|
|
257
432
|
const face = plannedFace(DEFAULT_ASPECT_RATIO, canvas, framing, presenter);
|
|
258
433
|
/** Elements whose staging the plan draws or names on its own; the rest get the one note below. */
|
|
259
|
-
const drawn = new Set();
|
|
260
|
-
const gaps = { entry: [], exit: [], styles: new Map(), templates: [], inferred: []
|
|
434
|
+
const drawn = new Set(), touched = [];
|
|
435
|
+
const gaps = { entry: [], exit: [], styles: new Map(), templates: [], inferred: [],
|
|
436
|
+
fitted: [] };
|
|
261
437
|
// A box never measured on the frames is an estimate, and a note says so (#930 S2).
|
|
262
438
|
const sourceOf = (element) => element.rect_method?.startsWith('estimated') ? 'vision-pass' : 'measured';
|
|
263
439
|
const elementOf = (beat, graphic) => profile.staging?.elements.find(entry => entry.beat_index === beat && entry.graphic_index === graphic && entry.rect_hold);
|
|
@@ -287,18 +463,28 @@ export function compileReferenceRemake(input, capabilities, planSpans = proporti
|
|
|
287
463
|
const box = resolvePlace(parsed.data, { canvas, face }), shown = own ? fitPlacedRect(box, ownPixels(own, canvas), canvas) : box;
|
|
288
464
|
if (sharedArea(shown, FRAME) < (shown.width * shown.height) / 2)
|
|
289
465
|
return undefined;
|
|
290
|
-
|
|
466
|
+
const onFace = sharedArea(shown, face);
|
|
467
|
+
if (onFace > FACE_COVER_SHARE * face.width * face.height)
|
|
468
|
+
return 'face';
|
|
469
|
+
// The reference's own touch of its presenter's face is kept on purpose and said once in the plan, not on every render (#1049).
|
|
470
|
+
if (onFace > 0)
|
|
471
|
+
touched.push(element.beat_index);
|
|
472
|
+
return { place: onFace > 0 ? { ...parsed.data, allow_face: true } : parsed.data, inferred: false };
|
|
473
|
+
};
|
|
474
|
+
/** Where a reference moment falls in its beat: the share of the beat's spoken words up to it, else of its seconds. */
|
|
475
|
+
const shareAt = (beat, t) => {
|
|
476
|
+
const source = profile.beats[beat], said = (profile.transcript ?? []).filter(word => word.start_s >= source.start_s && word.start_s < source.end_s);
|
|
477
|
+
let at = 0;
|
|
478
|
+
said.forEach((word, index) => { if (word.start_s <= t)
|
|
479
|
+
at = index; });
|
|
480
|
+
return { said, at, share: said.length ? (at + 0.5) / said.length : Math.min(1, Math.max(0, (t - source.start_s) / (source.end_s - source.start_s))) };
|
|
291
481
|
};
|
|
292
482
|
/** The word the reference showed it on, at the same share of our beat's words, and its measured lead or lag on that word. */
|
|
293
483
|
const enterOf = (element, beat, span) => {
|
|
294
|
-
const shown = element.entry?.first_visible_s
|
|
484
|
+
const shown = element.entry?.first_visible_s;
|
|
295
485
|
if (shown === undefined)
|
|
296
486
|
return {};
|
|
297
|
-
const said
|
|
298
|
-
let at = 0;
|
|
299
|
-
said.forEach((word, index) => { if (word.start_s <= shown)
|
|
300
|
-
at = index; });
|
|
301
|
-
const share = said.length ? (at + 0.5) / said.length : Math.min(1, Math.max(0, (shown - source.start_s) / (source.end_s - source.start_s)));
|
|
487
|
+
const { said, at, share } = shareAt(beat, shown);
|
|
302
488
|
const found = quote(span.start + Math.min(span.end - span.start - 1, Math.floor(share * (span.end - span.start))), 2, span.end);
|
|
303
489
|
if (!found)
|
|
304
490
|
return {};
|
|
@@ -320,38 +506,212 @@ export function compileReferenceRemake(input, capabilities, planSpans = proporti
|
|
|
320
506
|
return { ...(enter ? { enter, ...(duration ? { enter_duration_s: Math.min(1.5, Math.max(0.05, round(duration, 2))) } : {}) } : {}),
|
|
321
507
|
...(exit && exit !== 'cut' ? { exit } : {}) };
|
|
322
508
|
};
|
|
509
|
+
/** A split's or full-frame picture's measured entry and exit: a split enters as an overlay does, an insert cuts; the rest is named (rule 3). */
|
|
510
|
+
const cutsOf = (element, beat, kind, enters) => {
|
|
511
|
+
const style = element.entry?.style, leave = element.exit?.style, enter = enters && style ? ENTER_OF[style] : undefined;
|
|
512
|
+
const moved = (value) => value !== undefined && value !== 'cut' && value !== 'unknown';
|
|
513
|
+
const lost = [moved(style) && !enter ? `${style} entry` : '', moved(leave) ? `${leave} exit` : ''].filter(Boolean);
|
|
514
|
+
if (lost.length)
|
|
515
|
+
note(beat, `${kind}: its ${lost.join(' and ')} (reference staging)`, 'a cut');
|
|
516
|
+
return enter ? { enter } : {};
|
|
517
|
+
};
|
|
518
|
+
/** The split's media side and share from the measured box (#1030): a band from the frame edge it touches, else from the side its centre is on. */
|
|
519
|
+
const splitOf = (element, beat, kind) => {
|
|
520
|
+
const rect = element.rect_hold, bottom = rect.top + rect.height, edge = 0.05;
|
|
521
|
+
const media_side = rect.top <= edge ? 'top' : bottom >= 1 - edge ? 'bottom' : rect.top + rect.height / 2 < 0.5 ? 'top' : 'bottom';
|
|
522
|
+
const share = rect.top <= edge ? bottom : bottom >= 1 - edge ? 1 - rect.top : rect.height;
|
|
523
|
+
const media_share = round(Math.min(SPLIT_MEDIA_SHARE.max, Math.max(SPLIT_MEDIA_SHARE.min, share)));
|
|
524
|
+
const off = [rect.top > edge && bottom < 1 - edge ? 'off the frame\'s top and bottom edges' : '', rect.width < 0.9 ? `${round(rect.width, 2)} of the frame's width` : '',
|
|
525
|
+
media_share !== round(share) ? `${round(share, 2)} of the frame's height` : ''].filter(Boolean);
|
|
526
|
+
if (off.length)
|
|
527
|
+
note(beat, `${kind}: its ${sourceOf(element)} box, ${off.join(', ')}`, `a full-width band on the ${media_side}, ${media_share} of the height`);
|
|
528
|
+
return { media_side, media_share, ...cutsOf(element, beat, kind, true) };
|
|
529
|
+
};
|
|
530
|
+
/** A product screen's split (#1161): the band on the side the reference showed it, half the frame's height, so a screenshot reads. */
|
|
531
|
+
const screenSplitOf = (element, position, beat) => {
|
|
532
|
+
const rect = element?.rect_hold, media_side = rect ? (rect.top + rect.height / 2 < 0.5 ? 'top' : 'bottom')
|
|
533
|
+
: position.startsWith('bottom') ? 'bottom' : 'top';
|
|
534
|
+
note(beat, `overlay_media ${rect ? `in its ${sourceOf(element)} box` : `at ${position}`}`, `a split: your screen fills the ${media_side} half and the presenter the other, so the screen reads and the face stays clear`);
|
|
535
|
+
return { layout: 'split', media_side, media_share: SPLIT_MEDIA_SHARE.default, ...(element ? cutsOf(element, beat, 'overlay_media', true) : {}) };
|
|
536
|
+
};
|
|
537
|
+
/** A full-frame picture takes its measured box when the box is smaller than the frame (#1030) and fit auto: the run then places it by its own shape,
|
|
538
|
+
* in that box or the whole frame, scrolled or panned to show all of it (#1031, #1051 S2). */
|
|
539
|
+
const insertFrameOf = (element, media, beat, kind) => {
|
|
540
|
+
const rect = element?.rect_hold, margin = 0.02;
|
|
541
|
+
const inset = rect && (rect.left > margin || rect.top > margin || rect.left + rect.width < 1 - margin || rect.top + rect.height < 1 - margin);
|
|
542
|
+
const box = inset ? insertBox.safeParse({ left: round(rect.left), top: round(rect.top), width: round(Math.min(rect.width, 1 - rect.left)),
|
|
543
|
+
height: round(Math.min(rect.height, 1 - rect.top)) }).data : undefined;
|
|
544
|
+
if (element)
|
|
545
|
+
cutsOf(element, beat, kind, false);
|
|
546
|
+
if (media === 'video') {
|
|
547
|
+
if (inset)
|
|
548
|
+
note(beat, `${kind}: its ${sourceOf(element)} box, smaller than the frame`, 'the whole frame: a video insert fills it');
|
|
549
|
+
return {};
|
|
550
|
+
}
|
|
551
|
+
if (inset && !box)
|
|
552
|
+
note(beat, `${kind}: its ${sourceOf(element)} box (outside what an insert's box takes)`, 'the whole frame');
|
|
553
|
+
gaps.fitted.push(beat);
|
|
554
|
+
return { fit: 'auto', ...(box ? { box } : {}) };
|
|
555
|
+
};
|
|
323
556
|
const spans = planSpans({ profile, words });
|
|
557
|
+
const beatAt = (word) => Math.max(0, spans.findIndex(span => span !== null && word >= span.start && word < span.end));
|
|
558
|
+
/** The first script words a quote of the request names, on the beat holding them. */
|
|
559
|
+
const find = (text) => {
|
|
560
|
+
const matches = quoteOccurrences(tokens, text);
|
|
561
|
+
return matches[0] && { anchor: { quote: text, ...(matches.length > 1 ? { occurrence: 0 } : {}) }, start: matches[0].startWord,
|
|
562
|
+
end: matches[0].endWord, beat: beatAt(matches[0].startWord) };
|
|
563
|
+
};
|
|
564
|
+
const missing = (path, text) => ({ kind: 'invalid', issues: [{ kind: 'invalid_request', path,
|
|
565
|
+
message: `"${text}" is not in the script: quote its words as written.` }] });
|
|
566
|
+
// An asset pinned to its words always shows there (#1170): in the reference's picture place on that beat, else over the whole frame.
|
|
567
|
+
const pins = [];
|
|
568
|
+
for (const [at, asset] of request.assets.entries()) {
|
|
569
|
+
const found = asset.anchor === undefined ? undefined : find(asset.anchor);
|
|
570
|
+
if (asset.anchor !== undefined && !found)
|
|
571
|
+
return missing(`assets.${at}.anchor`, asset.anchor);
|
|
572
|
+
if (found)
|
|
573
|
+
pins.push({ ...found, asset, at, used: false });
|
|
574
|
+
}
|
|
575
|
+
// The user's shot notes (#1172): the reference's graphics on their words go, and their template shows there.
|
|
576
|
+
const edits = [];
|
|
577
|
+
for (const [at, edit] of (request.line_edits ?? []).entries()) {
|
|
578
|
+
const found = find(edit.quote);
|
|
579
|
+
if (!found)
|
|
580
|
+
return missing(`line_edits.${at}.quote`, edit.quote);
|
|
581
|
+
edits.push({ ...found, at, ...(edit.template ? { template: edit.template } : {}) });
|
|
582
|
+
}
|
|
583
|
+
const editsOn = (span) => edits.filter(edit => edit.start < span.end && span.start < edit.end);
|
|
324
584
|
const layers = [];
|
|
325
|
-
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
585
|
+
/** Layers the request named itself: a run refusing one refuses the compile, never drops it (#1172). */
|
|
586
|
+
const requested = new Map();
|
|
587
|
+
const pool = request.assets.filter(asset => asset.anchor === undefined), images = () => pool.filter(asset => asset.media_type === 'image').length;
|
|
588
|
+
// An unpinned asset whose name says a beat's words waits for that beat (#1170): the beat matching most of its name, the earliest on a tie.
|
|
589
|
+
// Only beats that take a picture count; a word every asset's name holds (the brand) says nothing.
|
|
590
|
+
const slots = profile.beats.flatMap((beat, index) => spans[index] && !editsOn(spans[index]).length && (beat.presenter_behaviour === 'absent'
|
|
591
|
+
|| beat.graphics.some(graphic => (drawnKindOf(graphic) ?? graphic.kind) in MEDIA_KINDS || graphic.source_kind === 'logo_card')) ? [index] : []);
|
|
592
|
+
const namesOf = new Map(pool.flatMap(asset => { const fact = facts?.get(asset.asset_id); return fact ? [[asset, nameWords(fact.name)]] : []; }));
|
|
593
|
+
const everywhere = namesOf.size > 1 ? [...namesOf.values()].reduce((common, own) => common.filter(word => own.includes(word))) : [];
|
|
594
|
+
const lineOf = new Map();
|
|
595
|
+
for (const [asset, own] of namesOf) {
|
|
596
|
+
const telling = own.filter(word => !everywhere.includes(word));
|
|
597
|
+
for (const beat of slots) {
|
|
598
|
+
const said = words.slice(spans[beat].start, spans[beat].end).flatMap(word => word.tag ? [] : word.token.split(/[^\p{L}\p{N}]+/u))
|
|
599
|
+
.filter(token => token.length >= 3);
|
|
600
|
+
const score = telling.filter(word => said.some(token => sameStem(word, token))).length;
|
|
601
|
+
if (score > (lineOf.get(asset)?.score ?? 0))
|
|
602
|
+
lineOf.set(asset, { beat, score });
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
/** How each asset not pinned got its place, for the note that names every guessed place (#1170). */
|
|
606
|
+
const guessed = [];
|
|
607
|
+
/** Beats compiled to the end; a beat the presenter leaves with nothing of its own is compiled after the rest, so beat order says nothing here.
|
|
608
|
+
* A beat with a pinned asset counts from the start: the pin takes its picture place. */
|
|
609
|
+
const served = new Set(pins.map(pin => pin.beat));
|
|
610
|
+
/** The beat's own named asset first (a product photo on a beat that shows the product breaks a tie), else the first no other beat still waits for. */
|
|
611
|
+
const usableFor = (image, beat) => pool.filter(asset => {
|
|
612
|
+
const named = lineOf.get(asset)?.beat;
|
|
613
|
+
return (!image || asset.media_type === 'image') && (named === undefined || named === beat || served.has(named));
|
|
614
|
+
});
|
|
615
|
+
const take = (image, beat, strict = true) => {
|
|
616
|
+
const suits = (asset) => facts?.get(asset.asset_id)?.role === 'prop' && SHOWN_ROLES.has(profile.beats[beat].role) ? 1 : 0;
|
|
617
|
+
const own = pool.filter(asset => (!image || asset.media_type === 'image') && lineOf.get(asset)?.beat === beat)
|
|
618
|
+
.sort((left, right) => lineOf.get(right).score - lineOf.get(left).score || suits(right) - suits(left));
|
|
619
|
+
const asset = own[0] ?? usableFor(image, beat)[0] ?? (strict ? undefined : pool.find(entry => !image || entry.media_type === 'image'));
|
|
620
|
+
if (!asset)
|
|
621
|
+
return undefined;
|
|
622
|
+
pool.splice(pool.indexOf(asset), 1);
|
|
623
|
+
guessed.push({ asset, beat, how: asset === own[0] ? 'name' : 'order' });
|
|
624
|
+
return asset;
|
|
329
625
|
};
|
|
626
|
+
const pinOn = (beat, image) => pins.find(pin => !pin.used && pin.beat === beat && (!image || pin.asset.media_type === 'image'));
|
|
627
|
+
/** The layer drawing a graphic the analyser could not name a kind for; it counts as drawn only if the layer survives to the plan. */
|
|
628
|
+
const drawnDevices = new Map();
|
|
629
|
+
const waiting = [];
|
|
330
630
|
const anchored = [];
|
|
331
631
|
/** Beats whose words keep the presenter in the frame: no full-frame layer and no window, so a framing may sit on them. */
|
|
332
632
|
const onScreen = [];
|
|
633
|
+
const stressed = [];
|
|
634
|
+
/** The closing beat a site's end card goes on: the last call to action with words of its own, else the last beat with words (#1161). */
|
|
635
|
+
const worded = (at) => { const span = spans[at]; return Boolean(span && quote(span.start, 6, span.end)); };
|
|
636
|
+
const cta = profile.beats.findLastIndex((beat, at) => beat.role === 'cta' && worded(at));
|
|
637
|
+
const closing = cta >= 0 ? cta : profile.beats.findLastIndex((_beat, at) => worded(at));
|
|
333
638
|
profile.beats.forEach((beat, index) => {
|
|
334
639
|
const span = spans[index];
|
|
335
640
|
const lead = span ? quote(span.start, 6, span.end) : undefined;
|
|
336
641
|
if (!span || !lead) {
|
|
337
642
|
note(index, 'this beat (the script has fewer sentence and clause breaks than the reference has beats)', 'its words join a neighbouring beat');
|
|
643
|
+
served.add(index);
|
|
338
644
|
return;
|
|
339
645
|
}
|
|
340
646
|
anchored.push({ beat: index, anchor: lead.anchor });
|
|
341
647
|
const own = quote(span.start, 4, span.end), envelope = (kind) => ({ id: `ref-${index + 1}-${kind.replaceAll('_', '-')}`, kind,
|
|
342
648
|
anchor: own.anchor, cover_words: span.end - own.start });
|
|
649
|
+
const beatWords = own;
|
|
343
650
|
const clauses = parts(span);
|
|
344
|
-
|
|
651
|
+
const gapOf = (graphic) => graphic.kind === 'unmapped' ? sceneGapWhat(graphic) : drawnKindOf(graphic) ?? graphic.kind;
|
|
652
|
+
// A line the user edited shows what the edit says (#1172): the reference's graphics on the beat go, each named.
|
|
653
|
+
const edited = editsOn(span);
|
|
654
|
+
for (const graphic of edited.length ? beat.graphics : []) {
|
|
655
|
+
if (graphic.kind !== 'particles')
|
|
656
|
+
note(index, gapOf(graphic), `your line edit on "${edited[0].anchor.quote}"`);
|
|
657
|
+
}
|
|
658
|
+
// `hidden` also counts a template whose kind draws full-frame by default, as quote and compare do: captions are hidden under it too.
|
|
659
|
+
let placed, layered = false, fullFrame = edited.some(edit => edit.template && (edit.template.placement
|
|
660
|
+
?? TEMPLATE_KINDS.find(entry => entry.kind === edit.template.kind)?.placements[0]) === 'full'), windowed = false, hidden = fullFrame;
|
|
661
|
+
// The reference's own call for likes or follows, on a closing beat whose words ask for a comment word of ours, becomes a tag of our words (#1172).
|
|
662
|
+
const asked = beat.role === 'cta' && !edited.length ? commentCall(span, clauses) : undefined;
|
|
663
|
+
const engaged = asked && !templatesOff ? beat.graphics.filter(graphic => ENGAGEMENT.test(graphic.label_hint) && !((drawnKindOf(graphic) ?? graphic.kind) in MEDIA_KINDS)) : [];
|
|
664
|
+
if (asked && engaged.length) {
|
|
665
|
+
for (const graphic of engaged)
|
|
666
|
+
note(index, `${gapOf(graphic)} asking for the reference's own likes or follows`, `a tag with your comment word ${asked.word}`);
|
|
667
|
+
layers.push({ field: 'templates', beat: index, kind: 'tag', item: { id: `ref-${index + 1}-tag`, kind: 'tag', anchor: asked.anchor,
|
|
668
|
+
cover_words: asked.cover_words, data: { text: asked.text } } });
|
|
669
|
+
placed = 'tag';
|
|
670
|
+
}
|
|
345
671
|
// Specific graphics first: a text card is what a beat falls back to.
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
672
|
+
// A scene comes after the library's own kinds, a device a drawn kind shows among them (#1112), and before a text card (#1107).
|
|
673
|
+
const rank = (graphic) => {
|
|
674
|
+
const kind = drawnKindOf(graphic) ?? graphic.kind;
|
|
675
|
+
return TEXT_KINDS.has(graphic.kind) ? 2 : kind === 'unmapped' || kind === 'scene' ? 1 : 0;
|
|
676
|
+
};
|
|
677
|
+
for (const graphic of edited.length ? [] : beat.graphics.filter(entry => !engaged.includes(entry)).sort((left, right) => rank(left) - rank(right))) {
|
|
678
|
+
const component = drawnKindOf(graphic), kind = component ?? graphic.kind;
|
|
679
|
+
if (kind === 'particles')
|
|
680
|
+
continue;
|
|
681
|
+
// A graphic no kind draws becomes a scene made for the run where it can be drawn (#1107); the analyser's word on it then goes.
|
|
682
|
+
if (kind === 'unmapped' || kind === 'scene') {
|
|
683
|
+
const made = scenesOn ? sceneBriefOf(graphic, kind === 'scene', request.site, beatWords.anchor.quote, beat.role) : undefined;
|
|
684
|
+
// A mark on something in the reference's shot (a circle around a device) is drawn only where our shot can hold that thing (#1172).
|
|
685
|
+
const marked = made && !layered ? markedObject(graphic.label_hint) : undefined;
|
|
686
|
+
if (marked && !marked.some(word => shownWords().some(token => sameStem(word, token)))) {
|
|
687
|
+
note(index, `${gapOf(graphic)} marking the ${marked.join(' ')} in the reference's shot`, 'not drawn: none of your shots or assets shows it');
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
if (!made) {
|
|
691
|
+
if (kind === 'scene')
|
|
692
|
+
note(index, `a graphic made for this video (${scenesOn ? 'nothing to draw it from' : 'not available on this server'})`, 'the presenter speaks these words without it');
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
if (placed || layers.filter(layer => layer.field === 'scenes').length >= MAX_SCENES) {
|
|
696
|
+
if (kind === 'scene')
|
|
697
|
+
note(index, `a graphic made for this video (${placed ? 'one graphic per beat' : `a run draws at most ${MAX_SCENES}`})`, placed ? `the ${placed} on these words` : 'the presenter speaks these words without it');
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
const placement = beat.presenter_behaviour === 'absent' ? 'full' : 'over';
|
|
701
|
+
layers.push({ field: 'scenes', beat: index, kind: 'scene', item: { id: `ref-${index + 1}-scene`, anchor: beatWords.anchor,
|
|
702
|
+
cover_words: span.end - beatWords.start, placement, ...made } });
|
|
703
|
+
scened.add(`${index}:${beat.graphics.indexOf(graphic)}`);
|
|
704
|
+
placed = 'graphic made for this video';
|
|
705
|
+
fullFrame ||= placement === 'full';
|
|
349
706
|
continue;
|
|
707
|
+
}
|
|
350
708
|
// A logo card is a brand picture: it shows as an overlay while an image is left in assets, else as the text card it maps to (#973 S5).
|
|
351
709
|
const room = layers.filter(layer => layer.field === 'overlays').length < MAX_OVERLAYS;
|
|
352
|
-
const wantsLogo = graphic.source_kind === 'logo_card' && longForm && !layered
|
|
710
|
+
const wantsLogo = graphic.source_kind === 'logo_card' && longForm && !layered;
|
|
711
|
+
const logo = wantsLogo && (usableFor(true, index).length > 0 || pinOn(index, true) !== undefined) && room;
|
|
353
712
|
if (wantsLogo && !logo) {
|
|
354
|
-
note(index, `logo_card (${room ?
|
|
713
|
+
note(index, `logo_card (${!room ? `a run shows at most ${MAX_OVERLAYS} overlays` : images() ? 'the images left are named for other lines'
|
|
714
|
+
: 'no image left in assets'})`, 'a text card with its words');
|
|
355
715
|
}
|
|
356
716
|
if (kind in MEDIA_KINDS || logo) {
|
|
357
717
|
const media = logo ? 'logo_card' : kind;
|
|
@@ -359,20 +719,37 @@ export function compileReferenceRemake(input, capabilities, planSpans = proporti
|
|
|
359
719
|
note(index, `${kind} (one media layer per beat)`, 'the first one shows here');
|
|
360
720
|
continue;
|
|
361
721
|
}
|
|
362
|
-
|
|
722
|
+
// Where the reference's presenter is gone, its split or overlay picture fills the frame (#1060). On a beat that shows the product, the
|
|
723
|
+
// product's screen takes half the frame as a split, so it reads and the face stays clear by layout, not by a corner (#1161).
|
|
724
|
+
const screen = !logo && kind === 'overlay_media' && SHOWN_ROLES.has(beat.role) && beat.presenter_behaviour !== 'absent'
|
|
725
|
+
&& layers.filter(layer => layer.field === 'layout_windows').length < MAX_LAYOUT_WINDOWS;
|
|
726
|
+
const field = logo ? 'overlays' : beat.presenter_behaviour === 'absent' ? 'inserts' : screen ? 'layout_windows'
|
|
727
|
+
: MEDIA_KINDS[kind];
|
|
728
|
+
if (field === 'overlays' && !room) {
|
|
363
729
|
note(index, `${kind} (a run shows at most ${MAX_OVERLAYS} overlays)`, 'the presenter stays on screen on these words');
|
|
364
730
|
continue;
|
|
365
731
|
}
|
|
366
|
-
|
|
367
|
-
|
|
732
|
+
const pin = pinOn(index, logo);
|
|
733
|
+
if (!pin && !usableFor(logo, index).length) {
|
|
734
|
+
note(index, `${kind} (${pool.length ? 'the assets left are named for other lines' : 'no image or video left in assets'})`, GENERATE);
|
|
368
735
|
continue;
|
|
369
736
|
}
|
|
370
737
|
if (!longForm) {
|
|
371
738
|
note(index, `${kind} (media layers need long-form qualification, not available right now)`, 'the presenter stays on screen on these words');
|
|
372
739
|
continue;
|
|
373
740
|
}
|
|
374
|
-
|
|
375
|
-
|
|
741
|
+
if (pin)
|
|
742
|
+
pin.used = true;
|
|
743
|
+
const asset = pin?.asset ?? take(logo, index);
|
|
744
|
+
const { kind: _kind, ...item } = { ...envelope(field === 'inserts' ? 'insert_media' : media), asset_id: asset.asset_id, media_type: asset.media_type };
|
|
745
|
+
// A pinned asset keeps the reference's place on the beat and shows on its own words.
|
|
746
|
+
const push = (value) => {
|
|
747
|
+
const { enter_offset_s: _offset, ...rest } = value, layer = { field, beat: index, kind: media,
|
|
748
|
+
item: pin ? { ...rest, anchor: pin.anchor, cover_words: pin.end - pin.start } : value };
|
|
749
|
+
layers.push(layer);
|
|
750
|
+
if (pin)
|
|
751
|
+
requested.set(layer, `assets.${pin.at}.anchor`);
|
|
752
|
+
};
|
|
376
753
|
const element = field === 'overlays' ? elementOf(index, beat.graphics.indexOf(graphic)) : undefined, staged = element && stagedPlace(element);
|
|
377
754
|
if (element && staged)
|
|
378
755
|
drawn.add(element.id);
|
|
@@ -380,20 +757,25 @@ export function compileReferenceRemake(input, capabilities, planSpans = proporti
|
|
|
380
757
|
if (element && staged && staged !== 'face') {
|
|
381
758
|
if (staged.inferred)
|
|
382
759
|
gaps.inferred.push(index);
|
|
383
|
-
|
|
760
|
+
push({ ...item, ...enterOf(element, index, span), ...motionOf(element, index), place: staged.place });
|
|
384
761
|
continue;
|
|
385
762
|
}
|
|
386
763
|
const position = graphic.position.replace(/^split_/, ''), wanted = OVERLAY_POSITIONS.includes(position) ? position : 'top_right';
|
|
387
764
|
// Off the face: the reference place, else a corner on its side (right by default), a band last.
|
|
388
765
|
const side = wanted.endsWith('left') ? 'left' : 'right', rank = (place) => place.endsWith(side) ? 0 : place.includes('_') ? 1 : 2;
|
|
389
|
-
const clear = (place) => !rectsOverlap(overlayRect(place, frameCanvasOf(DEFAULT_ASPECT_RATIO)), faceZone(DEFAULT_ASPECT_RATIO,
|
|
766
|
+
const clear = (place) => placeFramings.every(each => !rectsOverlap(overlayRect(place, frameCanvasOf(DEFAULT_ASPECT_RATIO)), faceZone(DEFAULT_ASPECT_RATIO, each)));
|
|
390
767
|
const placed = clear(wanted) ? wanted : [...OVERLAY_POSITIONS].sort((left, right) => rank(left) - rank(right)).find(clear) ?? wanted;
|
|
391
768
|
if (staged === 'face')
|
|
392
769
|
note(index, `${media}: its ${sourceOf(element)} place, over the presenter's face`, `${placed}, to keep the face clear`);
|
|
393
770
|
else if (field === 'overlays' && placed !== position)
|
|
394
771
|
note(index, `${media} at ${graphic.position}`, `${placed}, to keep the face clear`);
|
|
395
|
-
|
|
396
|
-
|
|
772
|
+
const measured = field === 'overlays' ? undefined : elementOf(index, beat.graphics.indexOf(graphic));
|
|
773
|
+
if (measured)
|
|
774
|
+
drawn.add(measured.id);
|
|
775
|
+
push(field === 'overlays' ? { ...item, position: placed }
|
|
776
|
+
: screen ? { ...item, ...screenSplitOf(measured, graphic.position, index) }
|
|
777
|
+
: field === 'layout_windows' ? { ...item, layout: 'split', ...(measured ? splitOf(measured, index, media) : {}) }
|
|
778
|
+
: { ...item, ...insertFrameOf(measured, asset.media_type, index, media) });
|
|
397
779
|
layered = true;
|
|
398
780
|
fullFrame ||= field === 'inserts';
|
|
399
781
|
windowed ||= field === 'layout_windows';
|
|
@@ -414,6 +796,8 @@ export function compileReferenceRemake(input, capabilities, planSpans = proporti
|
|
|
414
796
|
}
|
|
415
797
|
if (built.instead)
|
|
416
798
|
note(index, `${kind} (${built.instead})`, `a ${built.kind} with the same words`);
|
|
799
|
+
if ('first' in built && built.first)
|
|
800
|
+
note(index, `${built.kind} timing`, built.first);
|
|
417
801
|
if ((built.kind === 'phone_call' || built.kind === 'notification') && /[^\p{Script=Latin}\p{N}\p{P}\p{S}\s]/u.test(script)) {
|
|
418
802
|
note(index, `${built.kind} card text stays English ("${built.kind === 'phone_call' ? 'Incoming call' : 'Messages'}")`, 'edit the plan to change it');
|
|
419
803
|
}
|
|
@@ -435,27 +819,177 @@ export function compileReferenceRemake(input, capabilities, planSpans = proporti
|
|
|
435
819
|
gaps.templates.push(index);
|
|
436
820
|
}
|
|
437
821
|
// A reference place over the face gives way to the kind's default, which every kind keeps off it.
|
|
438
|
-
if (!item.place && 'position' in built.data && templateFaceWarnings({ templates: [item], presenter_framing:
|
|
822
|
+
if (!item.place && 'position' in built.data && placeFramings.some(each => templateFaceWarnings({ templates: [item], presenter_framing: each }).length)) {
|
|
439
823
|
const { position: _position, ...data } = built.data;
|
|
440
824
|
item.data = data;
|
|
441
825
|
note(index, `${built.kind} at ${graphic.position}`, `the ${built.kind}'s default place, to keep the face clear`);
|
|
442
826
|
}
|
|
443
|
-
|
|
827
|
+
const layer = { field: 'templates', beat: index, kind: built.kind, item };
|
|
828
|
+
layers.push(layer);
|
|
829
|
+
if (component === built.kind && graphic.kind === 'unmapped')
|
|
830
|
+
drawnDevices.set(layer, graphic);
|
|
444
831
|
placed = built.kind;
|
|
445
832
|
fullFrame ||= built.placement === 'full';
|
|
833
|
+
hidden ||= (built.placement ?? definition?.placements[0]) === 'full';
|
|
446
834
|
}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
const
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
if (
|
|
456
|
-
|
|
835
|
+
// A site asks for an end card on the closing beat even where the reference shows none (#1161); it takes the place of a text card there.
|
|
836
|
+
if (request.site && index === closing && edited.length)
|
|
837
|
+
note(index, 'an end card for your site', `your line edit on "${edited[0].anchor.quote}"`);
|
|
838
|
+
else if (request.site && index === closing && !layers.some(layer => layer.kind === 'end_card')) {
|
|
839
|
+
const text = layers.findIndex(layer => layer.beat === index && layer.field === 'templates' && TEXT_KINDS.has(layer.kind));
|
|
840
|
+
const full = layers.filter(layer => layer.field === 'templates').length - (text < 0 ? 0 : 1) >= MAX_TEMPLATES;
|
|
841
|
+
const built = templatesOff || (placed && text < 0) || full ? undefined
|
|
842
|
+
: template('end_card', { kind: 'end_card', source_kind: 'end_card', position: 'center', state_changes: [], label_hint: '' }, span, clauses, false);
|
|
843
|
+
if (built && 'data' in built && built.kind === 'end_card') {
|
|
844
|
+
// A text card is the beat's only graphic (it ranks last), so what stays full-frame after it goes is an insert.
|
|
845
|
+
if (text >= 0) {
|
|
846
|
+
layers.splice(text, 1);
|
|
847
|
+
fullFrame = layers.some(layer => layer.beat === index && layer.field === 'inserts');
|
|
848
|
+
hidden = false;
|
|
849
|
+
}
|
|
850
|
+
layers.push({ field: 'templates', beat: index, kind: 'end_card', item: { ...envelope('end_card'), data: built.data } });
|
|
851
|
+
placed = 'end_card';
|
|
852
|
+
}
|
|
853
|
+
else {
|
|
854
|
+
note(index, `an end card for your site (${templatesOff ? 'templates are not available right now' : full ? `a run shows at most ${MAX_TEMPLATES} templates`
|
|
855
|
+
: 'one graphic per beat'})`, placed ? `the ${placed} on these words` : 'the presenter speaks these words without it');
|
|
856
|
+
}
|
|
457
857
|
}
|
|
858
|
+
// A beat the presenter leaves with nothing of its own on the frame waits for the media every graphic leaves over, else a title card (#1060).
|
|
859
|
+
const waits = beat.presenter_behaviour === 'absent' && !fullFrame && !placed && !layered && !edited.length;
|
|
860
|
+
const finish = () => {
|
|
861
|
+
if (waits) {
|
|
862
|
+
const pin = longForm ? pinOn(index, false) : undefined;
|
|
863
|
+
if (pin)
|
|
864
|
+
pin.used = true;
|
|
865
|
+
const asset = pin?.asset ?? (longForm ? take(false, index) : undefined);
|
|
866
|
+
const card = asset || templatesOff || SHOWN_ROLES.has(beat.role) || layers.filter(layer => layer.field === 'templates').length >= MAX_TEMPLATES ? undefined
|
|
867
|
+
: template('title_card', { kind: 'title_card', source_kind: 'title_card', position: 'full_screen', state_changes: [], label_hint: '' }, span, clauses, true);
|
|
868
|
+
if (asset) {
|
|
869
|
+
const { kind: _kind, ...item } = { ...envelope('insert_media'), asset_id: asset.asset_id, media_type: asset.media_type,
|
|
870
|
+
...(pin ? { anchor: pin.anchor, cover_words: pin.end - pin.start } : {}) };
|
|
871
|
+
const layer = { field: 'inserts', beat: index, kind: 'insert_media', item: { ...item, ...insertFrameOf(undefined, asset.media_type, index, 'insert_media') } };
|
|
872
|
+
layers.push(layer);
|
|
873
|
+
if (pin)
|
|
874
|
+
requested.set(layer, `assets.${pin.at}.anchor`);
|
|
875
|
+
fullFrame = true;
|
|
876
|
+
}
|
|
877
|
+
else if (card && 'data' in card && card.placement === 'full') {
|
|
878
|
+
layers.push({ field: 'templates', beat: index, kind: card.kind, item: { ...envelope(card.kind), placement: 'full', data: card.data } });
|
|
879
|
+
fullFrame = hidden = true;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
// A pin still unplaced fills the frame on its words below.
|
|
883
|
+
fullFrame ||= pins.some(pin => !pin.used && pin.beat === index);
|
|
884
|
+
if (beat.presenter_behaviour === 'absent' && !fullFrame) {
|
|
885
|
+
const over = placed ?? (layered ? 'logo_card' : undefined);
|
|
886
|
+
note(index, 'the presenter leaving the frame', over ? `the presenter stays on screen behind the ${over}`
|
|
887
|
+
: edited.length ? `the presenter stays on screen, as your line edit on "${edited[0].anchor.quote}" leaves it`
|
|
888
|
+
: !longForm ? 'the presenter stays on screen: full-frame media needs long-form qualification, not available right now'
|
|
889
|
+
: 'the presenter stays on screen: pass one more ready ast_* image or video and compile again to show it full-frame here');
|
|
890
|
+
}
|
|
891
|
+
if (!fullFrame && !windowed)
|
|
892
|
+
onScreen.push({ beat: index, anchor: own.anchor, cover_words: span.end - own.start });
|
|
893
|
+
const emphasised = profile.captions.present && profile.captions.emphasis_style !== 'none' && (beat.role === 'hook' || beat.role === 'cta');
|
|
894
|
+
// A full-frame layer covers the beat's every word, and captions are hidden under it (#1049).
|
|
895
|
+
if (emphasised && (fullFrame || hidden))
|
|
896
|
+
note(index, 'caption emphasis on this beat (a full-frame insert or template covers its words, and captions are hidden there)', 'none here');
|
|
897
|
+
else if (emphasised) {
|
|
898
|
+
const sentence = clauses.find(range => words[range.end - 1].stop === 'sentence') ?? clauses[0];
|
|
899
|
+
const key = quote(Math.max(sentence.start, sentence.end - 3), 3, sentence.end);
|
|
900
|
+
const shared = layers.some(layer => layer.field === 'caption_emphasis' && layer.item.quote === key?.anchor.quote);
|
|
901
|
+
if (key && !shared)
|
|
902
|
+
layers.push({ field: 'caption_emphasis', beat: index, kind: 'caption emphasis', item: { quote: key.anchor.quote, look: profile.captions.emphasis_style } });
|
|
903
|
+
if (key)
|
|
904
|
+
stressed.push({ from: key.start, count: key.end - key.start });
|
|
905
|
+
}
|
|
906
|
+
served.add(index);
|
|
907
|
+
};
|
|
908
|
+
if (waits)
|
|
909
|
+
waiting.push(finish);
|
|
910
|
+
else
|
|
911
|
+
finish();
|
|
458
912
|
});
|
|
913
|
+
// Each waiting beat goes in beat order, as if it had been compiled in place.
|
|
914
|
+
if (waiting.length) {
|
|
915
|
+
for (const finish of waiting)
|
|
916
|
+
finish();
|
|
917
|
+
layers.sort((left, right) => left.beat - right.beat);
|
|
918
|
+
onScreen.sort((left, right) => left.beat - right.beat);
|
|
919
|
+
stressed.sort((left, right) => left.from - right.from);
|
|
920
|
+
}
|
|
921
|
+
// A pin no picture place of the reference took fills the frame on its words; an edit's template shows on its own (#1170, #1172).
|
|
922
|
+
for (const pin of pins.filter(entry => !entry.used)) {
|
|
923
|
+
if (!longForm)
|
|
924
|
+
return { kind: 'invalid', issues: [{ kind: 'invalid_request', path: `assets.${pin.at}.anchor`,
|
|
925
|
+
message: 'A picture on a line needs long-form qualification, not available right now.' }] };
|
|
926
|
+
const layer = { field: 'inserts', beat: pin.beat, kind: 'insert_media', item: { id: `asset-${pin.at + 1}-insert-media`, asset_id: pin.asset.asset_id,
|
|
927
|
+
media_type: pin.asset.media_type, anchor: pin.anchor, cover_words: pin.end - pin.start, ...insertFrameOf(undefined, pin.asset.media_type, pin.beat, 'insert_media') } };
|
|
928
|
+
layers.push(layer);
|
|
929
|
+
requested.set(layer, `assets.${pin.at}.anchor`);
|
|
930
|
+
}
|
|
931
|
+
for (const edit of edits) {
|
|
932
|
+
if (!edit.template)
|
|
933
|
+
continue;
|
|
934
|
+
if (templatesOff)
|
|
935
|
+
return { kind: 'invalid', issues: [{ kind: 'invalid_request', path: `line_edits.${edit.at}.template`, message: 'Templates are not available right now.' }] };
|
|
936
|
+
const item = { id: `edit-${edit.at + 1}-${edit.template.kind.replaceAll('_', '-')}`, kind: edit.template.kind, anchor: edit.anchor,
|
|
937
|
+
cover_words: edit.end - edit.start, ...(edit.template.placement ? { placement: edit.template.placement } : {}), data: edit.template.data };
|
|
938
|
+
const checked = ugcTemplate.safeParse(item);
|
|
939
|
+
if (!checked.success)
|
|
940
|
+
return { kind: 'invalid', issues: checked.error.issues.map(issue => ({ kind: 'invalid_request',
|
|
941
|
+
path: ['line_edits', edit.at, 'template', ...issue.path].join('.'), message: issue.message })) };
|
|
942
|
+
const layer = { field: 'templates', beat: edit.beat, kind: edit.template.kind, item };
|
|
943
|
+
layers.push(layer);
|
|
944
|
+
requested.set(layer, `line_edits.${edit.at}`);
|
|
945
|
+
}
|
|
946
|
+
if (requested.size)
|
|
947
|
+
layers.sort((left, right) => left.beat - right.beat);
|
|
948
|
+
// The reference's cuts and flashes inside a beat its presenter leaves cut our picture there too (#1085): to a spare one of its kind, else the
|
|
949
|
+
// same picture at a new crop; a clip cuts only to a spare clip. Moments a flash's length apart are one cut, on the cut if they hold one.
|
|
950
|
+
const moments = (profile.staging?.anchors ?? []).filter(entry => /^(cut|flash)#/.test(entry.id)).sort((left, right) => left.t_s - right.t_s);
|
|
951
|
+
const uncut = [];
|
|
952
|
+
for (const layer of layers.filter(entry => entry.field === 'inserts' && profile.beats[entry.beat].presenter_behaviour === 'absent' && !requested.has(entry))) {
|
|
953
|
+
const beat = profile.beats[layer.beat], span = spans[layer.beat], item = layer.item;
|
|
954
|
+
const cuts = [];
|
|
955
|
+
let group = [];
|
|
956
|
+
for (const moment of [...moments.filter(entry => entry.t_s - beat.start_s > FLASH_SNAP_S && beat.end_s - entry.t_s > FLASH_SNAP_S), undefined]) {
|
|
957
|
+
if (moment && group.length && moment.t_s - group.at(-1).t_s <= FLASH_SNAP_S) {
|
|
958
|
+
group.push(moment);
|
|
959
|
+
continue;
|
|
960
|
+
}
|
|
961
|
+
if (group.length)
|
|
962
|
+
cuts.push((group.find(entry => entry.id.startsWith('cut#')) ?? group[0]).t_s);
|
|
963
|
+
group = moment ? [moment] : [];
|
|
964
|
+
}
|
|
965
|
+
const pieces = [], length = span.end - span.start;
|
|
966
|
+
let from = span.end - item.cover_words;
|
|
967
|
+
for (const t of cuts) {
|
|
968
|
+
const found = quote(span.start + Math.min(length - 1, Math.max(1, Math.round(shareAt(layer.beat, t).share * length))), 2, span.end);
|
|
969
|
+
// The shot before the cut keeps only words before it in its anchor.
|
|
970
|
+
const kept = found && found.start > from ? quote(from, Math.min(4, found.start - from), found.start) : undefined;
|
|
971
|
+
if (!found || kept?.start !== from)
|
|
972
|
+
continue;
|
|
973
|
+
const spare = pool.findIndex(asset => asset.media_type === item.media_type);
|
|
974
|
+
const full = layers.filter(entry => entry.field === 'inserts').length + pieces.length >= MAX_INSERTS;
|
|
975
|
+
if (full || (spare < 0 && item.media_type !== 'image')) {
|
|
976
|
+
if (!full)
|
|
977
|
+
uncut.push(layer.beat);
|
|
978
|
+
else
|
|
979
|
+
note(layer.beat, `the cuts inside the shot on beat ${layer.beat + 1} (a run shows at most ${MAX_INSERTS} inserts)`, 'one shot there');
|
|
980
|
+
break;
|
|
981
|
+
}
|
|
982
|
+
const before = pieces.at(-1) ?? layer, asset = spare < 0 ? undefined : pool.splice(spare, 1)[0];
|
|
983
|
+
before.item = { ...before.item, anchor: kept.anchor, cover_words: found.start - from };
|
|
984
|
+
pieces.push({ ...layer, from_s: t, item: { ...item, id: `${item.id}-${pieces.length + 2}`, anchor: found.anchor, cover_words: span.end - found.start,
|
|
985
|
+
...(asset ? { asset_id: asset.asset_id } : {}) } });
|
|
986
|
+
from = found.start;
|
|
987
|
+
}
|
|
988
|
+
layers.splice(layers.indexOf(layer) + 1, 0, ...pieces);
|
|
989
|
+
}
|
|
990
|
+
if (uncut.length) {
|
|
991
|
+
note(uncut[0], `the cuts inside the shots on beats ${[...new Set(uncut)].map(beat => beat + 1).join(', ')}`, 'one shot there: pass one more ready ast_* media of its kind and compile again to cut to it');
|
|
992
|
+
}
|
|
459
993
|
function template(kind, graphic, span, clauses, absent) {
|
|
460
994
|
const first = clauses[0], fullScreen = graphic.position === 'full_screen';
|
|
461
995
|
const textCard = (instead) => {
|
|
@@ -469,25 +1003,51 @@ export function compileReferenceRemake(input, capabilities, planSpans = proporti
|
|
|
469
1003
|
return text ? { kind: 'tag', data: { text, ...(position ? { position } : {}) }, ...(instead ? { instead } : {}) }
|
|
470
1004
|
: { missing: 'no words short enough for a label', substitute: 'the presenter speaks these words without it' };
|
|
471
1005
|
};
|
|
472
|
-
const rows = (limit, most) => {
|
|
1006
|
+
const rows = (limit, most, kind) => {
|
|
473
1007
|
const pieces = clauses.length >= 2 ? clauses : Array.from({ length: 3 }, (_, part) => ({
|
|
474
1008
|
start: span.start + Math.floor((part * (span.end - span.start)) / 3), end: span.start + Math.floor(((part + 1) * (span.end - span.start)) / 3)
|
|
475
1009
|
}));
|
|
476
|
-
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
|
|
1010
|
+
const pick = (ready, resolved = true) => {
|
|
1011
|
+
const first = Math.max(0, pieces.findIndex(piece => piece.end > ready));
|
|
1012
|
+
let after = span.start;
|
|
1013
|
+
return pieces.slice(first, first + most).flatMap(piece => {
|
|
1014
|
+
// A row's quote names no word: the run takes its first match from the row before on, so the word chosen must be that match.
|
|
1015
|
+
let at;
|
|
1016
|
+
for (let from = Math.max(piece.start, ready); from < piece.end && !at; from++) {
|
|
1017
|
+
const found = quote(from, 2, piece.end);
|
|
1018
|
+
if (!resolved) {
|
|
1019
|
+
at = found;
|
|
1020
|
+
break;
|
|
1021
|
+
}
|
|
1022
|
+
if (found && quoteOccurrences(tokens, found.anchor.quote).find(match => match.startWord >= after)?.startWord === found.start)
|
|
1023
|
+
at = found;
|
|
1024
|
+
}
|
|
1025
|
+
const text = label(piece.start, piece.end, limit);
|
|
1026
|
+
if (!text || !at)
|
|
1027
|
+
return [];
|
|
1028
|
+
after = at.start + 1;
|
|
1029
|
+
return [{ text, quote: at.anchor.quote }];
|
|
1030
|
+
});
|
|
1031
|
+
};
|
|
1032
|
+
// The first row is clicked once the kind is drawn, at the fastest pace a voice reads, with a word to spare (#1049); a phrase spoken
|
|
1033
|
+
// before then introduces the rows. Without two rows that way the rows are chosen as before, and the run moves a click itself and says so.
|
|
1034
|
+
const shownAt = (TEMPLATE_KINDS.find(entry => entry.kind === kind)?.beatsFrom ?? TEMPLATE_ENTER_FRAMES + 1) / CANVAS_FPS;
|
|
1035
|
+
const led = pick(span.start + Math.ceil(shownAt * WORDS_PER_SECOND_MAX) + 1), kept = led.length >= 2 ? led : pick(span.start);
|
|
1036
|
+
return kept.length >= 2 ? kept : pick(span.start, false);
|
|
480
1037
|
};
|
|
481
1038
|
const number = words.slice(span.start, span.end).find(word => !word.tag && /^\d+(?:[.,]\d+)?$/.test(word.token));
|
|
1039
|
+
// A screen of two parts (a prompt and its replies, a command and its status) takes the clauses, or a one-clause beat cut at its middle word.
|
|
1040
|
+
const middle = span.start + Math.floor((span.end - span.start) / 2);
|
|
1041
|
+
const halves = clauses.length >= 2 ? clauses : [{ start: span.start, end: middle }, { start: middle, end: span.end }];
|
|
482
1042
|
switch (kind) {
|
|
483
1043
|
case 'checklist': {
|
|
484
|
-
const items = rows(24, 4);
|
|
1044
|
+
const items = rows(24, 4, kind);
|
|
485
1045
|
if (items.length < 2)
|
|
486
1046
|
return { missing: 'fewer than two short phrases for its rows', substitute: 'the presenter speaks these words without it' };
|
|
487
1047
|
return { kind, data: { items: items.map(item => ({ label: item.text, on: false })), clicks: items.map((item, at) => ({ item: at, quote: item.quote })) } };
|
|
488
1048
|
}
|
|
489
1049
|
case 'progress': {
|
|
490
|
-
const steps = rows(20, 5);
|
|
1050
|
+
const steps = rows(20, 5, kind);
|
|
491
1051
|
if (steps.length < 2)
|
|
492
1052
|
return { missing: 'fewer than two short phrases for its steps', substitute: 'the presenter speaks these words without it' };
|
|
493
1053
|
return { kind, data: { steps: steps.map(step => step.text), advance_at: steps.map(step => step.quote) } };
|
|
@@ -502,7 +1062,9 @@ export function compileReferenceRemake(input, capabilities, planSpans = proporti
|
|
|
502
1062
|
const reveal = wordAt(span, Math.min(0.8, Math.max(0.2, graphic.state_changes[0]?.at_frac ?? 0.5)));
|
|
503
1063
|
if (!reveal)
|
|
504
1064
|
return textCard('no word to reveal on');
|
|
505
|
-
|
|
1065
|
+
const at = spans.indexOf(span);
|
|
1066
|
+
return { kind, data: { before: { asset_id: take(true, at, false).asset_id }, after: { asset_id: take(true, at, false).asset_id },
|
|
1067
|
+
reveal_at: reveal.anchor.quote } };
|
|
506
1068
|
}
|
|
507
1069
|
case 'notification': {
|
|
508
1070
|
const title = label(first.start, first.end, 32), body = label(span.start, span.end, 80);
|
|
@@ -535,6 +1097,68 @@ export function compileReferenceRemake(input, capabilities, planSpans = proporti
|
|
|
535
1097
|
const cta = until > first.start ? label(first.start, until, 48)?.replace(/\s+(at|on|via|from|to)$/iu, '') || undefined : undefined;
|
|
536
1098
|
return request.site ? { kind, data: { site: request.site, ...(cta ? { cta } : {}) } } : textCard('no site: pass site');
|
|
537
1099
|
}
|
|
1100
|
+
case 'chat_window': {
|
|
1101
|
+
const prompt = label(halves[0].start, halves[0].end, 120);
|
|
1102
|
+
const replies = halves.slice(1, 4).flatMap(clause => { const title = label(clause.start, clause.end, 32); return title ? [{ title }] : []; });
|
|
1103
|
+
return prompt && replies.length ? { kind, placement: 'full', data: { prompt, replies } }
|
|
1104
|
+
: textCard('fewer than two short phrases for its prompt and a reply');
|
|
1105
|
+
}
|
|
1106
|
+
case 'terminal': {
|
|
1107
|
+
const command = label(halves[0].start, halves[0].end, 100), last = halves.at(-1);
|
|
1108
|
+
const status = halves.length > 1 ? label(last.start, last.end, 40) : undefined;
|
|
1109
|
+
return command && status ? { kind, placement: 'full', data: { command, status } }
|
|
1110
|
+
: textCard('fewer than two short phrases for its command and status');
|
|
1111
|
+
}
|
|
1112
|
+
case 'phone_screen': {
|
|
1113
|
+
// The reel plays on the phone, its phrases chips on the screen's side; a ready clip fills the screen, a gradient without one.
|
|
1114
|
+
// One phrase plays on its own, from the phone's first frame: a beat of one clause is not cut into single words.
|
|
1115
|
+
const whole = label(span.start, span.end, 24);
|
|
1116
|
+
const [first, ...more] = clauses.length >= 2 ? rows(24, 3, kind) : whole ? [{ text: whole }] : [];
|
|
1117
|
+
const clip = pool.find(asset => asset.media_type === 'video');
|
|
1118
|
+
if (!first)
|
|
1119
|
+
return textCard('no words short enough for the phone\'s states');
|
|
1120
|
+
if (clip)
|
|
1121
|
+
pool.splice(pool.indexOf(clip), 1);
|
|
1122
|
+
return { kind, placement: 'full', data: { ...(clip ? { screen: { asset_id: clip.asset_id } } : {}),
|
|
1123
|
+
states: [{ state: 'play', ...first }, ...more.map(row => ({ state: 'tag', ...row }))]
|
|
1124
|
+
.map(({ quote, ...state }) => ({ ...state, ...(quote ? { at: quote } : {}) })) } };
|
|
1125
|
+
}
|
|
1126
|
+
case 'pointer_chips': {
|
|
1127
|
+
// Chips on the arrow's side point across the presenter's body, below the face; a measured box places them whole, as any template.
|
|
1128
|
+
const items = rows(20, 3, kind), side = /left/.test(graphic.position) ? 'left' : 'right';
|
|
1129
|
+
if (!items.length)
|
|
1130
|
+
return textCard('no words short enough for its chips');
|
|
1131
|
+
const target = (at) => ({ x: side === 'right' ? 36 + 7 * at : 64 - 7 * at, y: 60 + 10 * at });
|
|
1132
|
+
return { kind, data: { side, items: items.map((item, at) => ({ target: target(at), text: item.text, at: item.quote })) } };
|
|
1133
|
+
}
|
|
1134
|
+
case 'video_grid': {
|
|
1135
|
+
const videos = pool.filter(asset => asset.media_type === 'video');
|
|
1136
|
+
if (videos.length < 3)
|
|
1137
|
+
return { missing: 'needs three video clips in assets for its tiles', substitute: GENERATE };
|
|
1138
|
+
const cells = videos.slice(0, videos.length >= 4 ? 4 : 3), title = label(first.start, first.end, 40);
|
|
1139
|
+
for (const cell of cells)
|
|
1140
|
+
pool.splice(pool.indexOf(cell), 1);
|
|
1141
|
+
return { kind, placement: 'full', data: { ...Object.fromEntries(cells.map((cell, at) => [`cell_${at + 1}`, { asset_id: cell.asset_id }])),
|
|
1142
|
+
...(title ? { title } : {}) } };
|
|
1143
|
+
}
|
|
1144
|
+
case 'word_title': {
|
|
1145
|
+
// The beat's words in turn, a word or two each, each on its own words as spoken.
|
|
1146
|
+
const entries = [];
|
|
1147
|
+
for (let at = span.start; at < span.end && entries.length < 8;) {
|
|
1148
|
+
const found = quote(at, 2, span.end);
|
|
1149
|
+
if (!found)
|
|
1150
|
+
break;
|
|
1151
|
+
const text = label(found.start, found.end, 16) ?? label(found.start, found.start + 1, 16);
|
|
1152
|
+
if (text)
|
|
1153
|
+
entries.push({ text, at: found.anchor.quote });
|
|
1154
|
+
at = found.end;
|
|
1155
|
+
}
|
|
1156
|
+
// The first entry shows from the template's first frame (#1129): words before its own are said under it.
|
|
1157
|
+
const late = entries.length && quote(span.start, 2, span.end)?.anchor.quote !== entries[0].at;
|
|
1158
|
+
return entries.length ? { kind, placement: 'full', data: { words: entries },
|
|
1159
|
+
...(late ? { first: `its first entry "${entries[0].text}" shows from the title's first frame, before its words are spoken` } : {}) }
|
|
1160
|
+
: textCard('no words short enough for it');
|
|
1161
|
+
}
|
|
538
1162
|
case 'title_card': return fullScreen || absent ? textCard() : textCard('a band over the presenter, not a full frame');
|
|
539
1163
|
case 'tag': return textCard();
|
|
540
1164
|
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' };
|
|
@@ -567,18 +1191,20 @@ export function compileReferenceRemake(input, capabilities, planSpans = proporti
|
|
|
567
1191
|
for (const entry of notes.filter(entry => entry.beat_index === listening && entry.what.startsWith('phone_call: its ')))
|
|
568
1192
|
notes.splice(notes.indexOf(entry), 1);
|
|
569
1193
|
}
|
|
570
|
-
// Measured staging is drawn for overlays, templates over the presenter and the call's phone: any other measured graphic
|
|
571
|
-
// default place, and says so once per beat and kind (rule 3).
|
|
1194
|
+
// Measured staging is drawn for overlays, templates over the presenter, splits, full-frame media and the call's phone: any other measured graphic
|
|
1195
|
+
// the plan places keeps its default place, and says so once per beat and kind (rule 3).
|
|
572
1196
|
const placed = new Set(layers.filter(layer => layer.field !== 'framing' && layer.field !== 'caption_emphasis').map(layer => layer.beat));
|
|
573
1197
|
const named = new Set();
|
|
574
1198
|
for (const element of profile.staging?.elements ?? []) {
|
|
575
1199
|
const graphic = element.graphic_index === null ? undefined : profile.beats[element.beat_index]?.graphics[element.graphic_index];
|
|
576
1200
|
const key = `${element.beat_index}:${graphic?.kind}`;
|
|
577
|
-
|
|
578
|
-
|
|
1201
|
+
// A graphic the analysis could not name is the analysis's own gap, not a place the plan moved (#1060).
|
|
1202
|
+
const span = spans[element.beat_index];
|
|
1203
|
+
if (!graphic || graphic.kind === 'unmapped' || graphic.kind === 'particles' || !placed.has(element.beat_index) || named.has(key) || drawn.has(element.id)
|
|
1204
|
+
|| (call && element.beat_index === listening && element.kind === 'phone_call') || (span && editsOn(span).length))
|
|
579
1205
|
continue;
|
|
580
1206
|
named.add(key);
|
|
581
|
-
note(element.beat_index, `${graphic.kind}: its ${sourceOf(element)} place and entry (reference staging)`, 'its default place; measured staging is drawn for overlays, templates over the presenter and the call\'s phone');
|
|
1207
|
+
note(element.beat_index, `${graphic.kind}: its ${sourceOf(element)} place and entry (reference staging)`, 'its default place; measured staging is drawn for overlays, templates over the presenter, splits, full-frame media and the call\'s phone');
|
|
582
1208
|
}
|
|
583
1209
|
const beatsOf = (list) => [...new Set(list)].map(beat => beat + 1).join(', ');
|
|
584
1210
|
if (gaps.entry.length)
|
|
@@ -588,43 +1214,135 @@ export function compileReferenceRemake(input, capabilities, planSpans = proporti
|
|
|
588
1214
|
for (const [what, list] of gaps.styles) {
|
|
589
1215
|
note(list[0], `${what} of the overlays on beats ${beatsOf(list)}`, what.endsWith('exits') ? 'a cut' : `a cut: overlays enter by ${LAYER_ENTERS.join(', ')}`);
|
|
590
1216
|
}
|
|
1217
|
+
if (gaps.fitted.length) {
|
|
1218
|
+
note(gaps.fitted[0], `the pictures on beats ${beatsOf(gaps.fitted)} filling their box`, 'each in the box or the frame, whichever cuts it ' +
|
|
1219
|
+
'less, scrolled or panned to show all of it; whole over a blurred copy only if too little would show, and warnings[] names it');
|
|
1220
|
+
}
|
|
1221
|
+
if (touched.length)
|
|
1222
|
+
note(touched[0], `the graphics on beats ${beatsOf(touched)} touching the presenter's face at its edge, as the reference's do`, 'kept where the reference has them');
|
|
591
1223
|
if (gaps.templates.length)
|
|
592
1224
|
note(gaps.templates[0], `the measured entry of the templates on beats ${beatsOf(gaps.templates)}`, 'each template\'s own entry');
|
|
593
1225
|
if (gaps.inferred.length) {
|
|
594
1226
|
note(gaps.inferred[0], `the reference's caption box, for the graphics on beats ${beatsOf(gaps.inferred)} (not measured)`, `placed under the captions: each sits centred just below where the reference's ${profile.captions.position} captions are`);
|
|
595
1227
|
}
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
1228
|
+
// Camera, transitions, flashes and edit sounds go to the layer that owns each beat (#1051 S1); what the plan adds is listed in brief.added.
|
|
1229
|
+
const motion = planMotion({ beats: profile.beats, flashes: profile.staging?.anchors.flatMap(entry => entry.id.startsWith('flash#') ? [entry.t_s] : []) ?? [],
|
|
1230
|
+
worded: spans.map(Boolean), layers, onScreen, longForm, addMoves: request.add_moves, ...(choose ? { choose } : {}),
|
|
1231
|
+
script: { words: words.map(word => ({ token: word.token, ...(word.stop ? { stop: word.stop } : {}),
|
|
1232
|
+
...(word.stop === 'sentence' && /\?["'”’»)\]]*$/u.test(script.slice(word.from, word.to)) ? { question: true } : {}) })),
|
|
1233
|
+
spans, word_s: duration / words.length, emphasis: stressed, quote, cards: !templatesOff } });
|
|
1234
|
+
// A device the director adds goes whole, every layer of it, when the run refuses one, and its line in brief.added with it (#1051 S5).
|
|
1235
|
+
const moves = new Map(), ours = new Map();
|
|
1236
|
+
for (const { layer: index, set, added } of motion.patches) {
|
|
1237
|
+
const layer = layers[index];
|
|
1238
|
+
layer.item = { ...layer.item, ...set };
|
|
1239
|
+
moves.set(layer, { keys: Object.keys(set), added });
|
|
1240
|
+
}
|
|
1241
|
+
layers.push(...motion.framing);
|
|
1242
|
+
const count = (field) => layers.filter(layer => layer.field === field).length;
|
|
1243
|
+
for (const { layers: device, added } of motion.framed) {
|
|
1244
|
+
if (count('framing') + device.length > MAX_FRAMING)
|
|
1245
|
+
continue;
|
|
1246
|
+
layers.push(...device);
|
|
1247
|
+
for (const layer of device)
|
|
1248
|
+
ours.set(layer, device);
|
|
1249
|
+
moves.set(device[0], { keys: [], added: [added] });
|
|
1250
|
+
}
|
|
1251
|
+
for (const { beat, word, added } of motion.cards) {
|
|
1252
|
+
const span = spans[beat], found = quote(word, 4, span.end);
|
|
1253
|
+
const built = template('stat', { kind: 'stat', source_kind: 'stat', position: 'center', state_changes: [], label_hint: '' }, span, parts(span), false);
|
|
1254
|
+
const item = found && 'data' in built && built.kind === 'stat' && { id: `ref-${beat + 1}-stat`, kind: 'stat', anchor: found.anchor, cover_words: span.end - found.start,
|
|
1255
|
+
...(built.placement ? { placement: built.placement } : {}), data: built.data };
|
|
1256
|
+
if (!item || count('templates') >= MAX_TEMPLATES)
|
|
1257
|
+
continue;
|
|
1258
|
+
const layer = { field: 'templates', beat, kind: 'stat', item };
|
|
1259
|
+
layers.push(layer);
|
|
1260
|
+
ours.set(layer, [layer]);
|
|
1261
|
+
moves.set(layer, { keys: [], added: [added] });
|
|
1262
|
+
}
|
|
1263
|
+
for (const entry of motion.notes)
|
|
1264
|
+
note(entry.beat, entry.what, entry.substitute);
|
|
1265
|
+
// Live shots, when asked (#1103): a beat whose presenter speaks or reacts on screen, with no full-frame layer or window on its words, is a shot of
|
|
1266
|
+
// our actor; the longest are kept, and a framing on a shot's words gives way to it.
|
|
1267
|
+
// On by default since #1175 S7, on the beats where the reference presenter moves: the gestures the analysis saw, each with its frames.
|
|
1268
|
+
const shotsOn = longForm && executor?.field_dispositions.shots === 'implemented';
|
|
1269
|
+
const shotsAsked = request.live_shots !== false;
|
|
1270
|
+
if (request.live_shots && !shotsOn) {
|
|
1271
|
+
note(0, 'live shots of the presenter', `the presenter's usual take: live shots ${longForm ? 'are not available on this server' : 'need long-form qualification'}`);
|
|
1272
|
+
}
|
|
1273
|
+
const rangeOf = (layer) => { const range = layerWordRange(tokens, layer.item); return typeof range === 'string' ? undefined : range; };
|
|
1274
|
+
const meets = (layer, start, end) => { const range = rangeOf(layer); return !!range && range.startWord < end && start < range.coverWord; };
|
|
1275
|
+
const covers = (layer) => layer.field === 'inserts' || layer.field === 'layout_windows' || ((layer.field === 'templates' || layer.field === 'scenes')
|
|
1276
|
+
&& (layer.item.placement ?? TEMPLATE_KINDS.find(entry => entry.kind === layer.kind)?.placements[0]) === 'full');
|
|
1277
|
+
const wanted = !shotsAsked || !shotsOn ? [] : onScreen.flatMap(({ beat: index }, at) => {
|
|
1278
|
+
const beat = profile.beats[index], span = spans[index];
|
|
1279
|
+
if (beat.presenter_behaviour !== 'speaking' && beat.presenter_behaviour !== 'reacting')
|
|
1280
|
+
return [];
|
|
1281
|
+
if (request.live_shots === undefined && !beat.gestures?.length)
|
|
1282
|
+
return [];
|
|
1283
|
+
// Beats sharing one span of words take one shot, and a call never opens inside a shot's words.
|
|
1284
|
+
if (onScreen.slice(0, at).some(entry => spans[entry.beat].start === span.start) || (call && resumeAt > span.start && resumeAt < span.end))
|
|
1285
|
+
return [];
|
|
1286
|
+
if (layers.some(layer => covers(layer) && meets(layer, span.start, span.end)))
|
|
1287
|
+
return [];
|
|
1288
|
+
const action = [...new Set([...beat.gestures ?? []].sort((a, b) => a.at_s - b.at_s).flatMap(entry => GESTURE_ACTIONS[entry.kind] ?? []))].join(', then ');
|
|
1289
|
+
const acted = beat.presenter_behaviour === 'reacting' || !!action;
|
|
1290
|
+
const wider = shotFramingOf(beat.shot_size, framing);
|
|
1291
|
+
const shot = { id: `ref-${index + 1}-shot`, anchor: { startWord: span.start, endWord: span.end },
|
|
1292
|
+
...beat.presenter_behaviour === 'reacting' ? { kind: 'action', action: action || REACTION }
|
|
1293
|
+
: action ? { kind: 'talking_action', action } : { kind: 'talking' },
|
|
1294
|
+
...acted && wider ? { framing: wider } : {} };
|
|
1295
|
+
return [{ beat: index, words: span.end - span.start, shot }];
|
|
1296
|
+
});
|
|
1297
|
+
const kept = new Set([...wanted].sort((a, b) => b.words - a.words || a.beat - b.beat).slice(0, MAX_LIVE_SHOTS));
|
|
1298
|
+
for (const entry of wanted) {
|
|
1299
|
+
const { beat, shot } = entry;
|
|
1300
|
+
if (!kept.has(entry)) {
|
|
1301
|
+
note(beat, `a live shot on this beat (a run draws at most ${MAX_LIVE_SHOTS}; the longest are kept)`, 'the presenter\'s usual take');
|
|
1302
|
+
continue;
|
|
600
1303
|
}
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
1304
|
+
const { startWord: start, endWord: end } = shot.anchor;
|
|
1305
|
+
for (const layer of layers.filter(entry => entry.field === 'framing' && meets(entry, start, end))) {
|
|
1306
|
+
if (!layers.includes(layer))
|
|
1307
|
+
continue;
|
|
1308
|
+
const device = ours.get(layer);
|
|
1309
|
+
for (const gone of device ?? [layer]) {
|
|
1310
|
+
layers.splice(layers.indexOf(gone), 1);
|
|
1311
|
+
moves.delete(gone);
|
|
1312
|
+
ours.delete(gone);
|
|
1313
|
+
}
|
|
1314
|
+
const item = layer.item;
|
|
1315
|
+
if (!device)
|
|
1316
|
+
note(layer.beat, `the reference's cut to a ${item.size} framing${item.transition ? ` with a ${item.transition}` : ''}`, 'the live shot on these words');
|
|
1317
|
+
}
|
|
1318
|
+
const layer = { field: 'shots', beat, kind: `${shot.kind} shot`, item: shot };
|
|
1319
|
+
// A hook shot cuts to the presenter on a flash, ours unless add_moves is false (#1129); a chooser may pick another cut it draws (#1121).
|
|
1320
|
+
if (request.add_moves !== false && profile.beats[beat].role === 'hook' && end < words.length) {
|
|
1321
|
+
const cue = beatCue(profile.beats[beat]);
|
|
1322
|
+
const [look] = preferChosen(HOOK_SHOT_CUTS, { slot: 'live_shot_transition', beat, role: 'hook', ...(cue ? { cue } : {}) }, choose);
|
|
1323
|
+
layer.item = { ...shot, transition: look };
|
|
1324
|
+
moves.set(layer, { keys: ['transition'], added: [{ beat_index: beat,
|
|
1325
|
+
what: `${lookName(look)} on the cuts into and out of the hook's live shot (beat ${beat + 1})` }] });
|
|
619
1326
|
}
|
|
620
|
-
|
|
621
|
-
previous = at;
|
|
1327
|
+
layers.push(layer);
|
|
622
1328
|
}
|
|
623
|
-
|
|
624
|
-
|
|
1329
|
+
const soundsOn = longForm && !['warned', 'rejected', 'stored'].includes(executor?.field_dispositions.edit_sounds ?? '');
|
|
1330
|
+
const cutToSound = () => layers.some(layer => ['inserts', 'layout_windows'].includes(layer.field)
|
|
1331
|
+
|| (layer.field === 'framing' && layer.item.id !== MARKER));
|
|
625
1332
|
const music = profile.beats.findIndex(beat => beat.sounds.some(sound => sound.kind === 'music'));
|
|
626
|
-
|
|
627
|
-
|
|
1333
|
+
const musicOn = executor?.field_dispositions.music === 'implemented';
|
|
1334
|
+
if (request.music !== undefined || music >= 0) {
|
|
1335
|
+
const what = music >= 0 ? 'the music bed' : 'music under the voice';
|
|
1336
|
+
// Without music on the server a reference's bed keeps its line word for word, so today's plans stay byte for byte.
|
|
1337
|
+
if (!musicOn)
|
|
1338
|
+
note(Math.max(music, 0), what, music >= 0 ? 'no music; Clipwright adds none yet' : 'no music: music is not available on this server');
|
|
1339
|
+
else if (request.music === undefined)
|
|
1340
|
+
note(music, what, `none unless you ask: compile again with music: "auto" or { mood } for music ` +
|
|
1341
|
+
`under the voice, ${MUSIC_BED_CREDITS} credits on its own quote line`);
|
|
1342
|
+
}
|
|
1343
|
+
const stylesOn = executor?.field_dispositions.motion_style === 'implemented';
|
|
1344
|
+
if (request.motion_style !== undefined && !stylesOn)
|
|
1345
|
+
note(0, 'the motion style you asked for', 'plain: motion styles are not available on this server');
|
|
628
1346
|
// The narration chases the presenter's measured pace through the voice's speed; a model without one leaves the voice as the lever.
|
|
629
1347
|
const speechModel = resolveTtsModel({ voice: request.voice, voice_id: request.voice_id, script });
|
|
630
1348
|
const pace = narrationPaceFromVoice(profile.voice), fixedPace = FIXED_PACE_MODELS.has(speechModel);
|
|
@@ -636,7 +1354,7 @@ export function compileReferenceRemake(input, capabilities, planSpans = proporti
|
|
|
636
1354
|
const nearer = ['female', 'male'].map(gender => [gender, voicesNearestPace(pace.target_wps, gender, 2, named?.voice)
|
|
637
1355
|
.filter(entry => !named || gap(entry.words_per_second) < gap(named.words_per_second))])
|
|
638
1356
|
.filter(([, voices]) => voices.length).map(([gender, voices]) => `${gender} ${voices.map(entry => `${entry.voice} ${entry.words_per_second}`).join(', ')}`);
|
|
639
|
-
const own = named ? `${named.voice} speaks ${named.words_per_second}` : `English voices
|
|
1357
|
+
const own = named ? `${named.voice} speaks ${named.words_per_second}` : `English voices: ${VOICE_PACE_RANGE} words/s`;
|
|
640
1358
|
if (!named || gap(named.words_per_second) > PACE_MATCH_SHARE * pace.target_wps) {
|
|
641
1359
|
note(0, `narration pace ${pace.target_wps} words/s of the reference presenter`, isEnglishScript(script, language === 'en')
|
|
642
1360
|
? `${speechModel} has no speed; ${own}. ${nearer.length ? `Nearer voices: ${nearer.join('; ')}` : 'No measured voice is nearer'}`
|
|
@@ -647,23 +1365,43 @@ export function compileReferenceRemake(input, capabilities, planSpans = proporti
|
|
|
647
1365
|
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`);
|
|
648
1366
|
}
|
|
649
1367
|
// Speed first, then the residual: pauses longer than the presenter's word-gap medians are cut in silence only.
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
1368
|
+
// A remake is an ad: without the reference's own pauses it takes the ad-style default (#1136).
|
|
1369
|
+
const pauseTrim = pauseTrimFromVoice(profile.voice), pauseTargets = pauseTrim?.targets ?? { ...AD_PAUSE_TRIM };
|
|
1370
|
+
if (!pauseTrim?.targets)
|
|
1371
|
+
note(0, 'narration pauses (the reference presenter\'s pauses were not measured)', `pause_trim ${AD_PAUSE_TRIM.sentence_end_s} s after a sentence and ${AD_PAUSE_TRIM.comma_s} s after a comma, the default for an ad`);
|
|
653
1372
|
for (const { key, median_s } of pauseTrim?.capped ?? []) {
|
|
654
1373
|
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`);
|
|
655
1374
|
}
|
|
1375
|
+
const captionStyle = profile.captions.words_per_caption_max <= 2 ? 'tiktok' : profile.captions.words_per_caption_max <= 4 ? 'hormozi' : 'youtube';
|
|
1376
|
+
// The reference's caption height, unless the band would sit on our presenter's face there: then the nearest clear height, lower on a tie (#1049).
|
|
1377
|
+
const height = CAPTION_POSITION_VERTICAL, referenceAt = profile.captions.position;
|
|
1378
|
+
const onFace = (position) => sharedArea(captionBandBox(captionStyle, DEFAULT_ASPECT_RATIO, canvas, { vertical: height[position] }), face) > 0;
|
|
1379
|
+
// A graphic placed by the captions keeps the reference's relation to them, so the band stays and the plan names the face it covers.
|
|
1380
|
+
const byCaptions = layers.some(layer => layer.item.place?.relative_to === 'captions');
|
|
1381
|
+
const captionPosition = byCaptions ? referenceAt : Object.keys(height)
|
|
1382
|
+
.sort((a, b) => Math.abs(height[a] - height[referenceAt]) - Math.abs(height[b] - height[referenceAt]) || height[b] - height[a])
|
|
1383
|
+
.find(position => !onFace(position)) ?? referenceAt;
|
|
1384
|
+
if (captionsPlaced && profile.captions.present && onFace(referenceAt)) {
|
|
1385
|
+
note(0, `captions at the reference's ${referenceAt} height (there they would cover the presenter's face)`, captionPosition !== referenceAt
|
|
1386
|
+
? `${captionPosition} captions, clear of the face` : byCaptions ? 'kept there: graphics stand by the captions' : 'kept there: no caption height is clear of the face');
|
|
1387
|
+
}
|
|
656
1388
|
let native = {}, marked = false;
|
|
657
1389
|
// Every pass that does not break drops one layer or adds the one marker, so the loop ends.
|
|
658
1390
|
for (;;) {
|
|
659
1391
|
const field = (name) => { const items = layers.filter(layer => layer.field === name).map(layer => layer.item); return items.length ? { [name]: items } : {}; };
|
|
660
|
-
native = { script, actor_id: request.actor_id, ...(framing
|
|
1392
|
+
native = { script, actor_id: request.actor_id, ...(framing !== 'close' ? { presenter_framing: framing } : {}), ...(mic ? { presenter_mic: true } : {}),
|
|
661
1393
|
...(request.voice ? { voice: request.voice } : {}), ...(request.voice_id ? { voice_id: request.voice_id } : {}),
|
|
662
1394
|
...(pace.kind === 'paced' && !fixedPace ? { voice_pace: pace.voice_pace } : {}), ...(pauseTargets ? { pause_trim: pauseTargets } : {}),
|
|
663
|
-
...(profile.captions.present ? { captions: true, caption_style:
|
|
664
|
-
|
|
665
|
-
...
|
|
666
|
-
|
|
1395
|
+
...(profile.captions.present ? { captions: true, caption_style: captionStyle, ...(captionsPlaced ? { caption_position: captionPosition } : {}) } : {}),
|
|
1396
|
+
...field('caption_emphasis'), ...field('templates'), ...field('scenes'), ...field('overlays'), ...field('inserts'), ...field('layout_windows'), ...field('framing'),
|
|
1397
|
+
...(call ? { call } : {}), ...field('shots') };
|
|
1398
|
+
// Cut sounds only when the client asks: on a remake they sounded too loud on every cut (#1076).
|
|
1399
|
+
if (request.edit_sounds === true && soundsOn && cutToSound())
|
|
1400
|
+
native.edit_sounds = true;
|
|
1401
|
+
if (request.music !== undefined && musicOn)
|
|
1402
|
+
native.music = request.music;
|
|
1403
|
+
if (request.motion_style !== undefined && stylesOn)
|
|
1404
|
+
native.motion_style = request.motion_style;
|
|
667
1405
|
if (presenter && isNarratedUgcInput(native))
|
|
668
1406
|
native.presenter_place = presenter;
|
|
669
1407
|
// The quote refuses customer media without this policy; the note below discloses it (rule 3).
|
|
@@ -683,11 +1421,39 @@ export function compileReferenceRemake(input, capabilities, planSpans = proporti
|
|
|
683
1421
|
if (!issue)
|
|
684
1422
|
return { kind: 'invalid', issues: checked.error.issues.map(entry => ({ kind: 'invalid_request', path: entry.path.join('.'), message: entry.message })) };
|
|
685
1423
|
const dropped = layers.filter(layer => layer.field === issue.path[0])[issue.path[1]];
|
|
686
|
-
|
|
687
|
-
|
|
1424
|
+
const device = ours.get(dropped);
|
|
1425
|
+
if (device) {
|
|
1426
|
+
for (const layer of device) {
|
|
1427
|
+
layers.splice(layers.indexOf(layer), 1);
|
|
1428
|
+
moves.delete(layer);
|
|
1429
|
+
ours.delete(layer);
|
|
1430
|
+
}
|
|
1431
|
+
continue;
|
|
1432
|
+
}
|
|
1433
|
+
// A move the run refuses goes before the layer does: the refused layer's own, else one on a layer the refusal names.
|
|
1434
|
+
const moved = [dropped, ...moves.keys()].find(layer => moves.has(layer) && (layer === dropped || issue.message.includes(`"${layer.item.id}"`)));
|
|
1435
|
+
if (moved) {
|
|
1436
|
+
const { keys } = moves.get(moved), item = { ...moved.item };
|
|
1437
|
+
for (const key of keys)
|
|
1438
|
+
delete item[key];
|
|
1439
|
+
moved.item = item;
|
|
1440
|
+
moves.delete(moved);
|
|
1441
|
+
note(moved.beat, `${moved.kind}: its ${keys.map(key => key.replace('_', ' ')).join(' and ')} (${issue.message})`, keys.map(key => key === 'transition' ? 'a cut' : 'a still picture').join(' and '));
|
|
1442
|
+
continue;
|
|
1443
|
+
}
|
|
1444
|
+
// A layer the request named stays: a reference layer the refusal also names goes instead, else the compile is refused (#1172).
|
|
1445
|
+
const cited = (layer) => issue.message.includes(`"${layer.item.id}"`);
|
|
1446
|
+
const instead = requested.has(dropped) ? layers.find(layer => layer !== dropped && !requested.has(layer) && cited(layer)) : dropped;
|
|
1447
|
+
if (!instead)
|
|
1448
|
+
return { kind: 'invalid', issues: [{ kind: 'invalid_request', path: requested.get(dropped), message: issue.message }] };
|
|
1449
|
+
layers.splice(layers.indexOf(instead), 1);
|
|
1450
|
+
for (const layer of ours.get(instead) ?? [])
|
|
1451
|
+
if (layers.includes(layer))
|
|
1452
|
+
layers.splice(layers.indexOf(layer), 1);
|
|
1453
|
+
note(instead.beat, `${instead.kind} (${issue.message})`, 'left out; edit the plan to place it');
|
|
688
1454
|
continue;
|
|
689
1455
|
}
|
|
690
|
-
// The quote's actor-shot limit: the insert covering the fewest words goes first.
|
|
1456
|
+
// The quote's actor-shot limit: the insert covering the fewest words goes first, a requested one only when no other is left.
|
|
691
1457
|
const inserts = layers.filter(layer => layer.field === 'inserts');
|
|
692
1458
|
const plan = inserts.length ? planSemanticInserts(script, inserts.map(layer => {
|
|
693
1459
|
const item = layer.item;
|
|
@@ -695,8 +1461,14 @@ export function compileReferenceRemake(input, capabilities, planSpans = proporti
|
|
|
695
1461
|
})) : undefined;
|
|
696
1462
|
const shots = plan ? countActorShots(plan) : 1;
|
|
697
1463
|
if (plan && (shots === 0 || shots > MAX_NARRATED_ACTOR_SHOTS)) {
|
|
698
|
-
const
|
|
699
|
-
const
|
|
1464
|
+
const layerOf = (entry) => inserts.find(layer => layer.item.id === entry.insertId);
|
|
1465
|
+
const candidates = plan.inserts.filter(entry => !requested.has(layerOf(entry)));
|
|
1466
|
+
if (!candidates.length) {
|
|
1467
|
+
return { kind: 'invalid', issues: [{ kind: 'invalid_request', path: requested.get(layerOf(plan.inserts[0])),
|
|
1468
|
+
message: `the pictures on lines leave ${shots} presenter appearances; a run renders 1 to ${MAX_NARRATED_ACTOR_SHOTS}` }] };
|
|
1469
|
+
}
|
|
1470
|
+
const fewest = candidates.reduce((best, entry) => coverWordOf(entry) - entry.startWord < coverWordOf(best) - best.startWord ? entry : best);
|
|
1471
|
+
const dropped = layerOf(fewest);
|
|
700
1472
|
layers.splice(layers.indexOf(dropped), 1);
|
|
701
1473
|
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');
|
|
702
1474
|
continue;
|
|
@@ -710,7 +1482,38 @@ export function compileReferenceRemake(input, capabilities, planSpans = proporti
|
|
|
710
1482
|
}
|
|
711
1483
|
break;
|
|
712
1484
|
}
|
|
1485
|
+
const sounded = [...new Set(motion.sounded)].map(beat => beat + 1).join(', ');
|
|
1486
|
+
if ((motion.sounded.length || request.edit_sounds === true) && !native.edit_sounds) {
|
|
1487
|
+
note(motion.sounded[0] ?? 0, motion.sounded.length ? `the whoosh and click sounds on beats ${sounded}` : 'edit_sounds, a sound on every cut', !soundsOn ? (request.edit_sounds !== true ? 'none' : longForm ? 'none: edit_sounds is not available on this server' : 'none: edit_sounds needs a long-form run')
|
|
1488
|
+
: !cutToSound() ? 'none: the plan has no insert, window or framing cut to sound them on'
|
|
1489
|
+
: 'none unless you ask: compile again with edit_sounds: true for a sound on every cut');
|
|
1490
|
+
}
|
|
1491
|
+
const added = [...layers.flatMap(layer => moves.get(layer)?.added ?? []).sort((a, b) => a.beat_index - b.beat_index).map(entry => entry.what),
|
|
1492
|
+
...native.edit_sounds ? [`a sound on every cut, as you asked${motion.sounded.length ? `; the reference has a whoosh or a click on beats ${sounded}` : ''}`] : [],
|
|
1493
|
+
...native.music ? [`music under the voice, as you asked${music >= 0 ? '; the reference has a music bed' : ''}`] : [],
|
|
1494
|
+
...native.shots ? [`live shots of the presenter on beats ${beatsOf(layers.filter(layer => layer.field === 'shots').map(layer => layer.beat))}, as you asked; ` +
|
|
1495
|
+
'each shot kind bills its own presenter-second price'] : []];
|
|
1496
|
+
// Every place the compiler chose for an unpinned asset is named (#1170): the line its name matches, else its order in assets.
|
|
1497
|
+
const shownBy = (asset) => layers.find(layer => JSON.stringify(layer.item).includes(`"${asset.asset_id}"`));
|
|
1498
|
+
for (const { asset, beat } of guessed.filter(entry => entry.how === 'name' && shownBy(entry.asset))) {
|
|
1499
|
+
const name = facts?.get(asset.asset_id)?.name, at = shownBy(asset).item.anchor?.quote;
|
|
1500
|
+
note(beat, `an anchor for your ${asset.media_type} ${asset.asset_id}${name ? ` ("${name.slice(0, 60)}")` : ''}`, `shown${at ? ` on "${at}"` : ''}, the line its name matches; pass its anchor to place it elsewhere`);
|
|
1501
|
+
}
|
|
1502
|
+
// An asset whose line showed something else is told apart from one no line names.
|
|
1503
|
+
for (const named of [false, true]) {
|
|
1504
|
+
const byOrder = guessed.filter(entry => entry.how === 'order' && shownBy(entry.asset) && lineOf.has(entry.asset) === named);
|
|
1505
|
+
if (byOrder.length) {
|
|
1506
|
+
note(byOrder[0].beat, `a line for your assets ${byOrder.map(entry => entry.asset.asset_id).join(', ')}`, `placed in their order in assets, as ${named
|
|
1507
|
+
? 'the line each one\'s name matches did not show it' : 'no line names them'}: pass each one's anchor with the words it shows`);
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
713
1510
|
const disclosed = notes.length;
|
|
1511
|
+
// A reference with almost no presenter is closer to a faceless video, and the cover over ours is still billed (#1060).
|
|
1512
|
+
const away = profile.beats.reduce((sum, beat) => sum + (beat.presenter_behaviour === 'absent' ? beat.end_frac - beat.start_frac : 0), 0);
|
|
1513
|
+
if (profile.format === 'faceless' || profile.format === 'broll' || away >= FACELESS_SHARE - 1e-9) {
|
|
1514
|
+
note(0, 'a video with no presenter on screen for most of its length', 'a presenter video that covers its presenter there, and a covered presenter ' +
|
|
1515
|
+
'is still billed; make_faceless makes narrated videos with no presenter');
|
|
1516
|
+
}
|
|
714
1517
|
if (layers.some(layer => layer.item.id === MARKER)) {
|
|
715
1518
|
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');
|
|
716
1519
|
}
|
|
@@ -729,10 +1532,29 @@ export function compileReferenceRemake(input, capabilities, planSpans = proporti
|
|
|
729
1532
|
catch {
|
|
730
1533
|
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');
|
|
731
1534
|
}
|
|
1535
|
+
// A device's layer the run schema dropped above leaves the device undrawn: its line and its analyser kind stay.
|
|
1536
|
+
const converted = new Map([...drawnDevices].filter(([layer]) => layers.includes(layer)).map(([layer, graphic]) => [graphic, layer.kind]));
|
|
732
1537
|
// 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).
|
|
733
|
-
|
|
1538
|
+
// A scene the run refused went with its layer, and its graphic stays a gap.
|
|
1539
|
+
for (const key of [...scened])
|
|
1540
|
+
if (!layers.some(layer => layer.field === 'scenes' && `${layer.beat}` === key.split(':')[0]))
|
|
1541
|
+
scened.delete(key);
|
|
1542
|
+
const needsEntry = (beat) => profile.beats[beat].graphics.some((graphic, at) => (graphic.kind === 'particles' || graphic.kind === 'unmapped')
|
|
1543
|
+
&& !converted.has(graphic) && !scened.has(`${beat}:${at}`));
|
|
734
1544
|
// 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.
|
|
735
|
-
const
|
|
1545
|
+
const heard = call ? profile.unmapped.filter(entry => entry.beat_index !== listening || !/recorded/i.test(entry.what)) : profile.unmapped;
|
|
1546
|
+
// A device a drawn kind now shows, or a graphic drawn as a scene, takes its analyser line with it: one line for each, named as the analysis names it.
|
|
1547
|
+
const shown = [...converted.keys()].map(graphic => ({ beat: profile.beats.findIndex(beat => beat.graphics.includes(graphic)), what: sceneGapWhat(graphic) }))
|
|
1548
|
+
.concat([...scened].map(key => {
|
|
1549
|
+
const [beat, at] = key.split(':').map(Number);
|
|
1550
|
+
return { beat, what: sceneGapWhat(profile.beats[beat].graphics[at]) };
|
|
1551
|
+
}));
|
|
1552
|
+
const analysed = heard.filter(entry => {
|
|
1553
|
+
const at = shown.findIndex(device => device.beat === entry.beat_index && device.what === entry.what);
|
|
1554
|
+
if (at >= 0)
|
|
1555
|
+
shown.splice(at, 1);
|
|
1556
|
+
return at < 0;
|
|
1557
|
+
});
|
|
736
1558
|
const unmapped = (() => {
|
|
737
1559
|
if (analysed.length + notes.length <= 40)
|
|
738
1560
|
return [...analysed, ...notes];
|
|
@@ -756,7 +1578,9 @@ export function compileReferenceRemake(input, capabilities, planSpans = proporti
|
|
|
756
1578
|
message: request.message ?? script.slice(words[firstSentence.start].from, words[firstSentence.end - 1].to),
|
|
757
1579
|
script, native: { skill: 'make_ugc', input: native },
|
|
758
1580
|
reference_profile: { ...withoutWords(profile), music: '', unmapped,
|
|
759
|
-
beats: profile.beats.map(beat => ({ ...beat, spoken_summary: '', graphics: beat.graphics.map(graphic => ({ ...graphic, label_hint: ''
|
|
1581
|
+
beats: profile.beats.map((beat, index) => ({ ...beat, spoken_summary: '', graphics: beat.graphics.map((graphic, at) => ({ ...graphic, label_hint: '',
|
|
1582
|
+
...(converted.has(graphic) ? { kind: converted.get(graphic) } : scened.has(`${index}:${at}`) ? { kind: 'scene' } : {}) })) })) },
|
|
1583
|
+
...(added.length ? { added: added.slice(0, 40) } : {}), ...(request.review_notes?.length ? { review_notes: request.review_notes } : {}) },
|
|
760
1584
|
beats, claims: [], assets: [] });
|
|
761
1585
|
return document.success ? { kind: 'compiled', document: document.data }
|
|
762
1586
|
: { kind: 'invalid', issues: document.error.issues.map(issue => ({ kind: 'invalid_plan', path: issue.path.join('.'), message: issue.message })) };
|