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