@clipwright/core 0.38.2 → 0.40.0

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