@clipwright/core 0.39.0 → 0.41.0

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