// Coordinates use the verified 945 x 1680 source PNG canvas. // Each region changes only an explanatory element; the camera stays fixed. export const plans = { c01: {kind: 'patch', regions: [[420, 160, 480, 610]]}, c02: {kind: 'patch', regions: [[540, 200, 340, 340]]}, c03: {kind: 'annotation'}, c04: {kind: 'annotation'}, c05: {kind: 'hold'}, c06: {kind: 'annotation'}, c07: {kind: 'annotation', direction: 'x'}, c08: {kind: 'hold'}, c09: {kind: 'annotation'}, c10: {kind: 'annotation'}, c11: {kind: 'annotation', direction: 'y'}, c12: {kind: 'annotation', direction: 'x'}, c13: {kind: 'hold'}, c14: {kind: 'annotation'}, c15: {kind: 'annotation', direction: 'y'}, c16: {kind: 'annotation'}, c17: {kind: 'hold'}, c18: {kind: 'deform'}, c19: {kind: 'annotation'}, c20: {kind: 'deform'}, c21: {kind: 'deform'}, c22: {kind: 'annotation'}, c23: {kind: 'hold'}, c24: {kind: 'annotation'}, c25: {kind: 'hold'}, c26: {kind: 'patch', regions: [[400, 1030, 495, 225]], delayMs: 1900}, c27: {kind: 'annotation'}, c28: {kind: 'patch', regions: [[420, 160, 480, 610]]}, c29: {kind: 'annotation', direction: 'x'}, c30: {kind: 'deform'}, c31: {kind: 'deform', regions: [[510, 150, 395, 425], [430, 395, 140, 260], [515, 500, 230, 170]]}, }; // Spoken-term timing comes from the approved ASR-aligned 07/08 stages. export const labels = [ {start: 4380, end: 7920, text: '확대 개념도', x: 704, y: 600}, {start: 7920, end: 11920, text: '산소 1개 · 수소 2개', x: 472, y: 430}, {start: 11920, end: 18080, text: '부분 전하 · 분자 전체는 중성', x: 472, y: 450}, {start: 11920, end: 18080, text: 'δ−', x: 472, y: 710, size: 56}, {start: 14780, end: 18080, text: 'δ+', x: 263, y: 920, size: 52}, {start: 14780, end: 18080, text: 'δ+', x: 677, y: 920, size: 52}, {start: 24380 + 1800, end: 32720, text: '수소결합', x: 510, y: 575}, {start: 28480, end: 32720, text: '분자 안', x: 177, y: 475}, {start: 28480, end: 32720, text: '분자 사이', x: 530, y: 985}, {start: 45120, end: 49360, text: '물끼리 뭉치는 성질', x: 472, y: 1220}, {start: 52080, end: 56120, text: '물 안쪽', x: 472, y: 1085}, {start: 56120, end: 63440, text: '공기 쪽', x: 472, y: 375}, {start: 56120, end: 63440, text: '표면', x: 820, y: 435}, {start: 61000, end: 63440, text: '물 분자 이웃이 적음', x: 472, y: 250}, {start: 67440 + 700, end: 70820, text: '에너지', x: 203, y: 665, size: 32}, {start: 67440, end: 70820, text: '에너지를 보태야 함', x: 472, y: 1080}, {start: 63440, end: 81520, text: '같은 물의 양 · 입체 물방울의 개념도', x: 472, y: 1190, size: 28}, {start: 63440 + 16760, end: 81520, text: '표면장력', x: 472, y: 570}, {start: 81520, end: 87380, text: '같은 물의 양 · 개념도', x: 472, y: 115}, {start: 82960 + 1200, end: 87380, text: '겉면이 가장 작음', x: 670, y: 940}, {start: 87380, end: 97760, text: '외부 영향이 작을 때', x: 225, y: 955, size: 30}, {start: 93420, end: 97760, text: '큰 물방울 · 바닥 접촉 예시', x: 641, y: 955, size: 29}, {start: 96160, end: 97760, text: '중력·바닥의 영향', x: 641, y: 1340, size: 30}, {start: 97760 + 700, end: 107360, text: '확대 개념도', x: 704, y: 130, size: 30}, {start: 101980 + 700, end: 104560, text: '서로 끌림', x: 704, y: 600}, {start: 104560 + 600, end: 107360, text: '표면 넓힘에 저항', x: 472, y: 1080}, ]; // 새 합성 좌표는 Blender의 실제 카메라 투영(1080×1920)과 논리 프레임 키다. export function anchorAt(cut, id, frame) { const motion = cut.motion.find((m) => m.anchor_id === id); if (!motion) throw new Error(`${cut.cut_id}: 없는 실제 앵커 ${id}`); const keys = motion.keys; const after = keys.findIndex((k) => k[0] > frame); const a = after < 0 ? keys.at(-1) : keys[Math.max(0, after - 1)]; const b = after < 0 ? a : keys[after]; const t = a[0] === b[0] ? 0 : Math.max(0, Math.min(1, (frame - a[0]) / (b[0] - a[0]))); return {x: a[1] + (b[1] - a[1]) * t, y: a[2] + (b[2] - a[2]) * t, target: [a[3] + (b[3] - a[3]) * t, a[4] + (b[4] - a[4]) * t]}; } export function blenderLabels(clip, frame) { const ms = frame * 20, result = []; const at = (id, text, {dx = 0, dy = 0, size = 42, leader = false} = {}) => { const a = anchorAt(clip.anchors, id, frame); // 연결선은 mesh 위쪽 가장자리까지만 닿아 원자를 가리지 않는다. result.push({...a, target: [a.target[0], a.y + 32], x: a.x + dx, y: a.y + dy, text, size, leader}); }; const heading = (text, y = 450, size = 42) => result.push({text, x: 540, y, size}); switch (clip.scene_name) { case 'OpeningDroplet': if (ms >= 4380) heading('이제 물속 분자로 확대', 560, 40); break; case 'SingleMolecule': if (ms < 11920) { heading('산소 1개 · 수소 2개', 500); at('single_O_delta_minus', '산소', {dy: -15}); at('single_H_delta_plus', '수소'); at('single_H2_delta_plus', '수소'); } else { heading('부분 전하 · 분자 전체는 중성', 500, 38); at('single_O_delta_minus', 'δ−', {dy: -12, size: 58}); if (ms >= 14780) { at('single_H_delta_plus', 'δ+', {size: 56}); at('single_H2_delta_plus', 'δ+', {size: 56}); } } break; case 'BondPair': at('pair_H_delta_plus', 'δ+', {size: 52}); at('pair_O_delta_minus', 'δ−', {size: 54}); if (ms >= 24380) heading('수소결합', 530, 48); if (ms >= 28480) heading('실선: 분자 안 · 점선: 분자 사이', 620, 36); break; case 'LiquidNetwork': heading(ms < 45120 ? '연결이 생겼다 끊어져요' : '물끼리 함께 뭉쳐요', 500, 44); break; case 'Interface': heading('물 표면의 개념도', 460, 38); if (ms >= 52080 && ms < 56120) at('water_interior', '물 안쪽', {dx: -240, dy: 450, size: 38}); if (ms >= 56120) { at('air_region', '공기 쪽', {dy: -80, size: 40}); at('water_surface', '표면', {dx: 300, dy: -35, size: 38, leader: true}); } if (ms >= 61000) heading('공기 쪽에 물 분자 이웃이 적음', 560, 36); break; case 'DropletStretch': at('same_volume_drop', '같은 물의 양 · 개념도', {dy: -140, size: 36}); if (ms >= 67440 && ms < 70820) heading('표면을 넓히려면 에너지가 필요', 1380, 38); if (ms >= 80200) heading('표면장력', 1400, 48); break; case 'EqualVolumeComparison': heading('같은 물의 양 · 개념도', 500, 40); at('equal_volume_left', '늘어난 모양', {dy: -70, size: 38}); at('equal_volume_right', '둥근 모양', {dy: -70, size: 38}); if (ms >= 84160) at('equal_volume_right', '겉면이 가장 작음', {dy: 490, size: 36}); break; case 'ShapeConditions': heading('크기·바닥 조건이 다른 예시', 470, 38); at('small_free_condition', '작은 물방울', {dy: -110, size: 36}); at('small_free_condition', '외부 영향이 작을 때', {dy: 510, size: 32}); if (ms >= 93420) at('large_contact_condition', '큰 물방울 · 바닥 접촉', {dy: -120, size: 32}); if (ms >= 96160) at('contact_floor', '중력·바닥의 영향', {dy: 400, size: 34}); break; case 'AnswerDroplet': // 확대도를 처음 보여주는 프레임부터 명시한다. 공기 중 실제 분자 배치가 아니다. at('answer_inset_O', '확대개념도', {dy: -95, size: 40}); if (ms >= 102680 && ms < 104560) at('answer_inset_O', '서로 끌림', {dy: 170, size: 36}); if (ms >= 105160 && ms < 107360) heading('표면 넓힘에 저항', 1390, 40); break; default: throw new Error(`미지원 실제 Scene ${clip.scene_name}`); } return result; }