Da Vinci 4744 — portable code reference
Each FILE section is a separate UTF-8 source file. Preserve the relative paths when recreating the package. Binary assets and pre-recorded voices are supplied in the skill ZIP files; they are not encoded in this text. Execute code only when the current task requires it and the environment supports it.


===== FILE: davinci-presentations/scripts/pptx_rtl.py =====
"""Inspect or repair Hebrew native paragraph direction; preserve all other ZIP parts."""
from pathlib import Path
from zipfile import ZipFile,ZIP_DEFLATED
import argparse,json,re
try:
    from lxml import etree as E
except ImportError:
    raise RuntimeError('Install the presentation helper dependency: pip install lxml') from None

A='http://schemas.openxmlformats.org/drawingml/2006/main'
P='http://schemas.openxmlformats.org/presentationml/2006/main'
NS={'a':A,'p':P}
def run(source,output=None):
    source=Path(source)
    if output and Path(output).resolve()==source.resolve():raise ValueError('Use a new output filename.')
    if output and Path(output).exists():raise FileExistsError(output)
    with ZipFile(source) as z:parts={n:z.read(n) for n in z.namelist()}
    slides=sorted(n for n in parts if re.fullmatch(r'ppt/slides/slide\d+\.xml',n))
    result={'slides':len(slides),'hebrew_paragraphs':0,'missing_rtl':0,'repaired_paragraphs':0,'native_text_shapes':0,'images':0,'fonts':set(),'claim':'Direction/package inspection; not a visual layout check.'}
    for name in slides:
        tree=E.fromstring(parts[name]);changed=False
        result['native_text_shapes']+=len(tree.findall('.//p:sp/p:txBody',NS))
        result['images']+=len(tree.findall('.//p:pic',NS))
        for latin in tree.findall('.//a:latin',NS):result['fonts'].add(latin.get('typeface',''))
        for para in tree.findall('.//a:p',NS):
            text=''.join(t.text or '' for t in para.findall('.//a:t',NS))
            if not re.search('[\u0590-\u05ff]',text):continue
            result['hebrew_paragraphs']+=1;pp=para.find('a:pPr',NS)
            if pp is None or pp.get('rtl')!='1':
                result['missing_rtl']+=1
                if output:
                    if pp is None:pp=E.Element('{'+A+'}pPr');para.insert(0,pp)
                    pp.set('rtl','1');changed=True;result['repaired_paragraphs']+=1
            if output:
                for run in para.findall('a:r',NS):
                    rp=run.find('a:rPr',NS)
                    if rp is None:rp=E.Element('{'+A+'}rPr');run.insert(0,rp)
                    if rp.get('lang')!='he-IL':rp.set('lang','he-IL');changed=True
        if changed:parts[name]=E.tostring(tree,encoding='UTF-8',xml_declaration=True)
    result['fonts']=sorted(result['fonts']);result['embedded_gifs']=sum(n.lower().endswith('.gif') for n in parts)
    if output:
        Path(output).parent.mkdir(parents=True,exist_ok=True)
        with ZipFile(output,'w',ZIP_DEFLATED) as z:
            for name,data in parts.items():z.writestr(name,data)
    return result
if __name__=='__main__':
    p=argparse.ArgumentParser();p.add_argument('source');p.add_argument('--output');p.add_argument('--check',action='store_true');args=p.parse_args()
    result=run(args.source,args.output);print(json.dumps(result,ensure_ascii=False,indent=2))
    if args.check and result['missing_rtl'] and not args.output:raise SystemExit(2)

===== END FILE =====

===== FILE: davinci-presentations/scripts/requirements.txt =====
lxml>=5

===== END FILE =====

===== FILE: davinci-videos/examples/new-topic.json =====
{
  "title": "מרעיון לאלגוריתם",
  "scenes": [
    {
      "id": "open",
      "layout": "title",
      "duration": 6,
      "speaker": "male",
      "heading": "מרעיון לאלגוריתם",
      "subtitle": "מפגש ראשון בצוות התוכנה",
      "text": "במפגש הזה נעבור מרעיון לפתרון שאפשר לתאר ולתכנת."
    },
    {
      "id": "process",
      "layout": "steps",
      "duration": 9,
      "speaker": "female",
      "heading": "שלושה שלבים לפתרון",
      "items": [
        "מבינים את הבעיה",
        "מגדירים רצף פעולות",
        "בודקים את התוצאה"
      ],
      "text": "מתחילים בהבנת הבעיה, מגדירים רצף פעולות, ואז בודקים אם קיבלנו את התוצאה שרצינו."
    },
    {
      "id": "example",
      "layout": "points",
      "duration": 8,
      "speaker": "male",
      "heading": "דוגמה מהעבודה על הרובוט",
      "items": [
        "איזה מידע אנחנו מקבלים?",
        "איזו החלטה צריך לקבל?",
        "איזו פעולה נרצה לבצע?"
      ],
      "text": "חשבו על פעולה שהרובוט צריך לבצע. איזה מידע נחוץ כדי להחליט מה לעשות?"
    },
    {
      "id": "activity",
      "layout": "question",
      "duration": 7,
      "speaker": "female",
      "heading": "איך תתארו את הפתרון?",
      "subtitle": "בחרו פעולה ונסחו רצף הוראות ברור",
      "text": "בחרו פעולה אחת, ונסחו בזוגות רצף הוראות ברור. אחר כך נבדוק אותו יחד."
    }
  ]
}

===== END FILE =====

===== FILE: davinci-videos/examples/spaceship.json =====
{
  "title": "אתגר החללית",
  "scenes": [
    {
      "id": "intro",
      "duration": 7,
      "speaker": "male",
      "text": "אתגר החללית משלב תכנון הנדסי, פתרון בעיות ועבודת צוות. נתחיל בהגדרת המשימה.",
      "layout": "spaceship",
      "example_index": 0
    },
    {
      "id": "mission",
      "duration": 11,
      "speaker": "female",
      "text": "עליכם לתכנן חללית שתגן על ביצה בנפילה מגובה חמישה מטרים. המטרה היא לבלום את הפגיעה ולשמור על הביצה שלמה.",
      "layout": "spaceship",
      "example_index": 1
    },
    {
      "id": "kit",
      "duration": 10,
      "speaker": "male",
      "text": "כל קבוצה מקבלת חלקי חללית, חומרי עבודה, מצנח וכלים. יש להשתמש אך ורק בציוד ובחומרים שקיבלתם.",
      "layout": "spaceship",
      "example_index": 2
    },
    {
      "id": "time",
      "duration": 12,
      "speaker": "female",
      "text": "לרשותכם שישים דקות: עשר דקות לתכנון בלבד, שלושים וחמש דקות לבנייה ולהרכבה, וחמש עשרה דקות לבדיקה ולהגשה.",
      "layout": "spaceship",
      "example_index": 3
    },
    {
      "id": "height",
      "duration": 9,
      "speaker": "male",
      "text": "מגבלת הגובה היא עשרים וחמישה סנטימטרים, כשהחללית במצב עומד. ודאו שהתכנון שלכם עומד בדרישה.",
      "layout": "spaceship",
      "example_index": 4
    },
    {
      "id": "crash",
      "duration": 7,
      "speaker": "female",
      "text": "הניקוד נקבע לפי תוצאת הנפילה. אם גם החללית וגם הביצה נשברות, לא מתקבלות נקודות.",
      "layout": "spaceship",
      "example_index": 5
    },
    {
      "id": "survive",
      "duration": 8,
      "speaker": "male",
      "text": "אם החללית נשארת שלמה והביצה נשברת, הניקוד הוא חמש נקודות.",
      "layout": "spaceship",
      "example_index": 6
    },
    {
      "id": "success",
      "duration": 12,
      "speaker": "female",
      "text": "נחיתה בעמידה מזכה בעשרים נקודות. שמירה על ביצה שלמה מוסיפה עשרים נקודות. שילוב שתי התוצאות מזכה בארבעים נקודות.",
      "layout": "spaceship",
      "example_index": 7
    },
    {
      "id": "outro",
      "duration": 9,
      "speaker": "male",
      "text": "לפני הבנייה, הגדירו כיוון תכנוני וחלקו תחומי אחריות. הביאו בחשבון את היציבות, את בלימת הפגיעה ואת מגבלות הזמן והגובה. אפשר להתחיל.",
      "layout": "spaceship",
      "example_index": 8
    }
  ],
  "speech": {
    "style": "Native modern Israeli Hebrew. Mature professional engineering workshop narrator addressing adults and university students. Grounded, composed, conversational and matter-of-fact. Confident low-to-mid register, measured natural pace around 145 words per minute, with restrained emphasis and short pauses at sentence boundaries. Speak to the audience as capable peers. No sing-song cadence, theatrical excitement, exaggerated pitch changes, children's show delivery or sales-pitch intonation. Read the exact Hebrew transcript without additions. Do not read instructions."
  }
}
===== END FILE =====

===== FILE: davinci-videos/notebooks/DaVinci-Video-Studio.ipynb =====
{
  "nbformat": 4,
  "nbformat_minor": 5,
  "metadata": {
    "kernelspec": {
      "display_name": "Python 3",
      "language": "python",
      "name": "python3"
    },
    "language_info": {
      "name": "python"
    },
    "colab": {
      "name": "DaVinci-Video-Studio.ipynb"
    }
  },
  "cells": [
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "# Da Vinci 4744 — סטודיו סרטונים בדפדפן\n",
        "הריצו את התאים לפי הסדר. העלו את `davinci-videos.zip`, ולא את החבילה המלאה.\n",
        "אפשר להעלות תוכנית JSON שנוצרה ב־ChatGPT או ב־Claude, או להריץ את דוגמת החללית עם הקריינות שכבר צורפה.\n",
        "יצירת קריינות חדשה דורשת מפתח Gemini API. המנוי לצ׳אט אינו מחליף אותו. המפתח נקלט דרך סודות Colab או שדה מוסתר, ולא נכתב לקובצי התוצאה.\n"
      ],
      "id": "davinci-01"
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {},
      "outputs": [],
      "source": [
        "from pathlib import Path\n",
        "from zipfile import ZipFile\n",
        "import sys, subprocess, json, os, uuid\n",
        "from google.colab import files\n",
        "subprocess.check_call([sys.executable, '-m', 'pip', 'install', '-q', 'Pillow>=11', 'numpy>=2', 'imageio-ffmpeg>=0.6', 'python-bidi>=0.6'])\n",
        "print('העלו את davinci-videos.zip')\n",
        "uploaded = files.upload()\n",
        "archive_name = next((name for name in uploaded if name.endswith('.zip')), None)\n",
        "if not archive_name: raise ValueError('נדרש קובץ ZIP של סקיל הסרטונים')\n",
        "destination = Path('/content') / ('davinci-studio-' + uuid.uuid4().hex[:8])\n",
        "destination.mkdir()\n",
        "with ZipFile(archive_name) as archive:\n",
        "    for item in archive.infolist():\n",
        "        if not (destination / item.filename).resolve().is_relative_to(destination):\n",
        "            raise ValueError('Invalid archive path')\n",
        "        if ((item.external_attr >> 16) & 0o170000) == 0o120000:\n",
        "            raise ValueError('Symlink entries are not supported')\n",
        "    archive.extractall(destination)\n",
        "skill = destination / 'davinci-videos'\n",
        "assert (skill / 'scripts/render_video.py').is_file(), 'נדרש ZIP שבו תיקיית davinci-videos בשורש'\n",
        "print('הערכה מוכנה')\n"
      ],
      "id": "davinci-02"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## תוכן הסרטון\n",
        "הקלידו `1` להעלאת תוכנית JSON חדשה, או `2` לדוגמת החללית המוכנה.\n",
        "התוכנית לדוגמה היא אותה פעילות בלבד. לשינוי נושא, השתמשו בתוכנית חדשה מהצ׳אט.\n"
      ],
      "id": "davinci-03"
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {},
      "outputs": [],
      "source": [
        "choice = input('1 = העלאת תוכנית חדשה, 2 = דוגמת החללית: ').strip()\n",
        "if choice == '1':\n",
        "    uploaded_plan = files.upload()\n",
        "    name = next((name for name in uploaded_plan if name.endswith('.json')), None)\n",
        "    if not name: raise ValueError('נדרש קובץ JSON של תוכנית הסצנות')\n",
        "    plan = skill / 'plans' / Path(name).name\n",
        "    plan.parent.mkdir(exist_ok=True)\n",
        "    plan.write_bytes(uploaded_plan[name])\n",
        "elif choice == '2':\n",
        "    plan = skill / 'examples/spaceship.json'\n",
        "else:\n",
        "    raise ValueError('בחרו 1 או 2')\n",
        "sys.path.insert(0, str(skill / 'scripts'))\n",
        "sys.modules.pop('plan_io', None)\n",
        "from plan_io import load_plan, valid_clip\n",
        "_, document, speech, work = load_plan(plan)\n",
        "print(document['title'], '|', len(document['scenes']), 'סצנות')\n"
      ],
      "id": "davinci-04"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## קריינות Gemini\n",
        "התא יוצר רק קטעים חדשים או ששונו. בדוגמה ללא שינויים הוא אינו פונה ל־API.\n",
        "לנוחותכם אפשר להוסיף מראש סוד ב־Colab בשם `GEMINI_API_KEY`.\n"
      ],
      "id": "davinci-05"
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {},
      "outputs": [],
      "source": [
        "missing = [scene for scene in document['scenes'] if not valid_clip(scene, speech, work)]\n",
        "if missing:\n",
        "    from getpass import getpass\n",
        "    try:\n",
        "        from google.colab import userdata\n",
        "        api_key = userdata.get('GEMINI_API_KEY')\n",
        "    except Exception:\n",
        "        api_key = None\n",
        "    if not api_key: api_key = getpass('Gemini API key (קלט מוסתר): ')\n",
        "    if not api_key: raise ValueError('לא הוגדר מפתח Gemini')\n",
        "    private_env = dict(os.environ)\n",
        "    private_env['GEMINI_API_KEY'] = api_key\n",
        "    try:\n",
        "        subprocess.run([sys.executable, str(skill / 'scripts/gemini_tts.py'), '--plan', str(plan)], env=private_env, check=True)\n",
        "    finally:\n",
        "        private_env.pop('GEMINI_API_KEY', None)\n",
        "        api_key = None\n",
        "else:\n",
        "    print('כל קטעי הקריינות תואמים לתוכנית. לא בוצעו קריאות API.')\n"
      ],
      "id": "davinci-06"
    },
    {
      "cell_type": "markdown",
      "metadata": {},
      "source": [
        "## רינדור והורדה\n",
        "הפקת Full HD יכולה לקחת זמן בהתאם לאורך הסרטון ולמשאבים הזמינים.\n",
        "התוצאה כוללת MP4, כתוביות SRT ותוכנית JSON. כתוביות אוטומטיות מתוזמנות בקירוב למשפטים.\n"
      ],
      "id": "davinci-07"
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {},
      "outputs": [],
      "source": [
        "subprocess.run([sys.executable, str(skill / 'scripts/render_video.py'), '--plan', str(plan), '--final'], check=True)\n",
        "result_zip = Path('/content/DaVinci-Video-Result.zip')\n",
        "with ZipFile(result_zip, 'w') as archive:\n",
        "    for file in (work / 'output').iterdir():\n",
        "        if file.is_file(): archive.write(file, file.name)\n",
        "    archive.write(plan, 'scene-plan.json')\n",
        "files.download(str(result_zip))\n"
      ],
      "id": "davinci-08"
    }
  ]
}

===== END FILE =====

===== FILE: davinci-videos/scripts/gemini_tts.py =====
"""Generate only changed Gemini voice clips; never embeds or prints credentials."""
from pathlib import Path
import argparse,base64,json,os,time,urllib.request,urllib.error,wave
from plan_io import load_plan,settings,fingerprint,valid_clip

def generate(plan,force=False):
    _,doc,speech,work=load_plan(plan)
    key=os.environ.get('GEMINI_API_KEY') or os.environ.get('GOOGLE_API_KEY')
    todo=[s for s in doc['scenes'] if force or not valid_clip(s,speech,work)]
    if not todo:print('All narration clips match the current plan. No API calls made.');return
    if not key:raise RuntimeError('Set GEMINI_API_KEY in the runtime secret store/environment. Do not paste it into chat. The Colab notebook provides secure input.')
    clips=work/'audio/voice';clips.mkdir(parents=True,exist_ok=True)
    meta=work/'audio/cache.json';cache=json.loads(meta.read_text()) if meta.exists() else {}
    for scene in todo:
        cfg=settings(scene,speech)
        payload={'model':cfg['model'],'input':[{'type':'user_input','content':[{'type':'text','text':cfg['text'],'annotations':[{'type':'speech_metadata','style':cfg['style']}]}]}],'response_format':{'type':'audio','mime_type':'audio/wav','sample_rate':24000},'generation_config':{'speech_config':[{'voice':cfg['voice']}]}}
        request=urllib.request.Request('https://generativelanguage.googleapis.com/v1beta/interactions',data=json.dumps(payload,ensure_ascii=False).encode(),headers={'Content-Type':'application/json','x-goog-api-key':key},method='POST')
        for attempt in range(3):
            try:
                with urllib.request.urlopen(request,timeout=180) as response:result=json.load(response)
                break
            except urllib.error.HTTPError as error:
                if error.code in [429,500,502,503,504] and attempt<2:time.sleep(3*(attempt+1));continue
                raise RuntimeError(f'Gemini TTS returned HTTP {error.code}. Check the configured Google project, API access and quota. No alternate provider was used.') from None
        blocks=[base64.b64decode(b['data']) for step in result.get('steps',[]) for b in step.get('content',[]) if b.get('type')=='audio' and b.get('data')]
        if not blocks:raise RuntimeError('Gemini returned no audio for '+scene['id'])
        out=clips/(scene['id']+'.wav');tmp=out.with_suffix('.tmp');tmp.write_bytes(blocks[-1])
        with wave.open(str(tmp)) as audio:
            if not audio.getnframes():raise RuntimeError('Empty voice clip for '+scene['id'])
        tmp.replace(out)
        cache[scene['id']]={'fingerprint':fingerprint(scene,speech),'provider':'Google Gemini TTS','model':cfg['model'],'voice':cfg['voice']}
        meta.write_text(json.dumps(cache,ensure_ascii=False,indent=2))
        print('Created',scene['id'],'with Gemini',cfg['voice'],flush=True)
    (work/'audio/provenance.json').write_text(json.dumps({'provider':'Google Gemini TTS','model':speech['model'],'male_voice':speech['male_voice'],'female_voice':speech['female_voice'],'language':'Hebrew'},ensure_ascii=False,indent=2))

if __name__=='__main__':
    p=argparse.ArgumentParser();p.add_argument('--plan',default='examples/spaceship.json');p.add_argument('--force',action='store_true');args=p.parse_args()
    generate(args.plan,args.force)

===== END FILE =====

===== FILE: davinci-videos/scripts/plan_io.py =====
from pathlib import Path
import json,hashlib,re

ROOT=Path(__file__).resolve().parents[1]
DEFAULT_STYLE='Native modern Israeli Hebrew. Mature professional engineering workshop narrator. Grounded, composed, conversational and matter-of-fact. Confident low-to-mid register, measured natural pace around 145 words per minute, restrained emphasis and short natural pauses. Speak to a capable audience as peers. No sing-song cadence, theatrical excitement or exaggerated pitch changes. Read the exact Hebrew transcript without additions. Do not read instructions.'
DEFAULT_SPEECH={'model':'gemini-3.8-flash-tts','male_voice':'Charon','female_voice':'Gacrux','style':DEFAULT_STYLE}
LAYOUTS={'title','points','steps','timeline','metric','question','image','spaceship'}

def load_plan(value='examples/spaceship.json'):
    path=Path(value)
    if not path.is_absolute():path=ROOT/path
    path=path.resolve()
    if not path.is_relative_to(ROOT):raise ValueError('Place the plan inside the extracted davinci-videos folder.')
    doc=json.loads(path.read_text(encoding='utf-8'))
    scenes=doc.get('scenes',[])
    if not scenes:raise ValueError('The plan needs at least one scene.')
    seen=set()
    for scene in scenes:
        ident=scene.get('id','')
        if not re.fullmatch('[a-zA-Z0-9_-]{1,64}',ident) or ident in seen:raise ValueError('Scene ids must be unique simple filenames.')
        seen.add(ident)
        if scene.get('layout') not in LAYOUTS:raise ValueError('Unknown layout in '+ident)
        if scene.get('speaker') not in ['male','female']:raise ValueError('Expected male or female in '+ident)
        if not isinstance(scene.get('duration'),(int,float)) or scene['duration']<=0:raise ValueError('Positive duration required in '+ident)
        if not isinstance(scene.get('text'),str) or not scene['text'].strip():raise ValueError('Exact narration text required in '+ident)
        if scene['layout']=='spaceship' and scene.get('example_index') not in range(9):raise ValueError('Spaceship example_index must be 0–8.')
        if scene['layout'] in ['steps','timeline'] and not 1<=len(scene.get('items',[]))<=4:raise ValueError('Split this timeline/step scene into groups of 1–4 items.')
        if scene['layout']=='points' and not 1<=len(scene.get('items',[]))<=5:raise ValueError('Split this points scene into groups of 1–5 items.')
    speech={**DEFAULT_SPEECH,**doc.get('speech',{})}
    work=ROOT/'work'/path.stem
    return path,doc,speech,work

def settings(scene,speech):
    voice=speech['male_voice'] if scene['speaker']=='male' else speech['female_voice']
    style=speech['style']+(' Adult male voice.' if scene['speaker']=='male' else ' Adult female voice.')
    return {'model':speech['model'],'voice':voice,'style':style,'text':scene['text']}

def fingerprint(scene,speech):
    return hashlib.sha256(json.dumps(settings(scene,speech),ensure_ascii=False,sort_keys=True).encode()).hexdigest()

def valid_clip(scene,speech,work):
    file=work/'audio/voice'/(scene['id']+'.wav');meta=work/'audio/cache.json'
    if not file.exists() or not meta.exists():return False
    try:return json.loads(meta.read_text()).get(scene['id'],{}).get('fingerprint')==fingerprint(scene,speech)
    except (ValueError,OSError):return False

===== END FILE =====

===== FILE: davinci-videos/scripts/render_video.py =====
"""Da Vinci 4744 motion picture. Python/Pillow/NumPy graphics and original audio."""
from pathlib import Path
from functools import lru_cache
import argparse, json, math, re, subprocess, wave, hashlib
import numpy as np
from PIL import Image, ImageDraw, ImageFont, ImageFilter
from bidi.algorithm import get_display
import imageio_ffmpeg

from plan_io import ROOT,load_plan,valid_clip
PARSER=argparse.ArgumentParser()
PARSER.add_argument('--plan',default='examples/spaceship.json')
PARSER.add_argument('--final',action='store_true')
PARSER.add_argument('--preview',action='store_true')
PARSER.add_argument('--force',action='store_true')
PARSER.add_argument('--width',type=int,default=1920,choices=[640,960,1280,1920])
PARSER.add_argument('--fps',type=int,default=30,choices=[24,30])
OPTIONS,_=PARSER.parse_known_args()
PLAN_PATH,PLAN,SPEECH,WORK=load_plan(OPTIONS.plan)
ASSETS=ROOT/'assets';BUILD=WORK/'build';OUTPUT=WORK/'output'
for path in [BUILD,OUTPUT]:path.mkdir(parents=True,exist_ok=True)
FFMPEG=imageio_ffmpeg.get_ffmpeg_exe()
FPS=OPTIONS.fps
W,H=OPTIONS.width,round(OPTIONS.width*9/16)
S=W/1280
GOLD=(255,219,162)
WHITE=(248,251,255)
BLUE=(105,190,237)
GREEN=(105,235,174)
RED=(255,115,110)
NAVY=(9,28,51)
SR=48000
MANIFEST=PLAN['scenes']

def clamp(x,a=0,b=1):return max(a,min(b,x))
def ease(x):x=clamp(x);return 1-(1-x)**3
def smooth(x):x=clamp(x);return x*x*(3-2*x)
def P(x):return round(x*S)
def rect(box):return tuple(P(x) for x in box)
def draw_shape(im,kind,coords,**kwargs):
    transparent=any(isinstance(v,tuple) and len(v)==4 and v[3]<255 for v in kwargs.values())
    if not transparent:
        getattr(ImageDraw.Draw(im),kind)(coords,**kwargs);return
    points=coords if kind in ['line','polygon'] else [(coords[0],coords[1]),(coords[2],coords[3])]
    pad=kwargs.get('width',1)+2
    left=max(0,math.floor(min(p[0] for p in points)-pad));top=max(0,math.floor(min(p[1] for p in points)-pad))
    right=min(im.width,math.ceil(max(p[0] for p in points)+pad));bottom=min(im.height,math.ceil(max(p[1] for p in points)+pad))
    if right<=left or bottom<=top:return
    shifted=[(x-left,y-top) for x,y in coords] if kind in ['line','polygon'] else (coords[0]-left,coords[1]-top,coords[2]-left,coords[3]-top)
    layer=Image.new('RGBA',(right-left,bottom-top))
    getattr(ImageDraw.Draw(layer),kind)(shifted,**kwargs)
    im.alpha_composite(layer,(left,top))
def line(im,pts,fill=BLUE,width=1):draw_shape(im,'line',[(P(x),P(y)) for x,y in pts],fill=fill,width=max(1,P(width)))
def ellipse(im,box,fill=None,outline=None,width=1):draw_shape(im,'ellipse',rect(box),fill=fill,outline=outline,width=max(1,P(width)))
def poly(im,pts,fill,outline=None,width=1):draw_shape(im,'polygon',[(P(x),P(y)) for x,y in pts],fill=fill,outline=outline,width=max(1,P(width)))

@lru_cache(maxsize=128)
def font(size,bold=False):return ImageFont.truetype(str(ASSETS/('Rubik-Bold.ttf' if bold else 'Rubik-Regular.ttf')),P(size))

def text(im,txt,x,y,size=32,color=WHITE,bold=False,anchor='mm',alpha=255):
    if not txt or alpha<=0:return
    layer=Image.new('RGBA',im.size) if alpha<255 else im
    d=ImageDraw.Draw(layer)
    for i,part in enumerate(txt.split('\n')):
        d.text((P(x),P(y+i*size*1.22)),get_display(part),font=font(size,bold),fill=(*color,alpha),anchor=anchor,stroke_width=0)
    if alpha<255:im.alpha_composite(layer)

def glow(im,x,y,r,color=GOLD,alpha=80):
    rr=P(r); tile=Image.new('RGBA',(rr*4,rr*4))
    ImageDraw.Draw(tile).ellipse((rr,rr,rr*3,rr*3),fill=(*color,alpha))
    tile=tile.filter(ImageFilter.GaussianBlur(rr*.6))
    im.alpha_composite(tile,(P(x)-rr*2,P(y)-rr*2))

def paste(im,asset,x,y,w,h=None,angle=0,opacity=255):
    hh=h if h else w*asset.height/asset.width
    pic=asset.resize((max(1,P(w)),max(1,P(hh))),Image.Resampling.LANCZOS)
    if opacity<255:pic.putalpha(pic.getchannel('A').point(lambda a:round(a*opacity/255)))
    if angle:pic=pic.rotate(angle,resample=Image.Resampling.BICUBIC,expand=True)
    im.alpha_composite(pic,(P(x)-pic.width//2,P(y)-pic.height//2))

LOGO=Image.open(ASSETS/'logo.png').convert('RGBA')
WORDMARK=Image.open(ASSETS/'wordmark.png').convert('RGBA')
BACKGROUNDS={}
for key,file in [('hero','space-hero.png'),('content','space-content.png')]:
    im=Image.open(ASSETS/file).convert('RGBA').resize((W,H),Image.Resampling.LANCZOS)
    im.alpha_composite(Image.new('RGBA',(W,H),(4,20,38,65 if key=='content' else 35)))
    BACKGROUNDS[key]=im

def rocket_asset():
    # Faithful reference-style transparent sprite, prepared with built-in ImageGen.
    asset=Image.open(ASSETS/'rocket-reference-style.png').convert('RGBA')
    box=asset.getchannel('A').getbbox()
    if not box:raise RuntimeError('Rocket asset has no visible pixels.')
    asset=asset.crop(box)
    scale=min(570/asset.width,1160/asset.height)
    asset=asset.resize((round(asset.width*scale),round(asset.height*scale)),Image.Resampling.LANCZOS)
    canvas=Image.new('RGBA',(600,1200))
    canvas.alpha_composite(asset,((600-asset.width)//2,1200-20-asset.height))
    return canvas

ROCKET=rocket_asset()

def parachute(im,x,apex,width=155,deploy=1):
    if deploy<=0:return
    rx=width/2*deploy;ry=rx*.57
    y=apex-118*deploy
    for q in np.linspace(-1,1,7):line(im,[(x+rx*q,y),(x,apex)],(*WHITE,195),1)
    ImageDraw.Draw(im).pieslice(rect((x-rx,y-ry,x+rx,y+ry)),180,360,fill=(43,108,154,255),outline=(*GOLD,255),width=P(2))
    line(im,[(x-rx,y),(x+rx,y)],GOLD,2)
    for q in [-.5,0,.5]:
        draw_shape(im,'arc',rect((x-rx*(1-abs(q)*.25),y-ry,x+rx*(1-abs(q)*.25),y+ry)),start=185,end=355,fill=(*GOLD,150),width=P(1))

def rocket(im,x,bottom,h=300,tilt=0,chute=0,fire=0,t=0):
    if chute:parachute(im,x,bottom-h*.97,155*h/300,chute)
    if fire:
        fy=bottom-h*.03;fh=fire*(46+12*math.sin(t*32));fw=12*h/300
        glow(im,x,fy+fh/2,30,GOLD,75)
        poly(im,[(x-fw,fy),(x,fy+fh),(x+fw,fy)],(*GOLD,230))
        poly(im,[(x-fw*.45,fy),(x,fy+fh*.65),(x+fw*.45,fy)],(255,250,224,255))
    paste(im,ROCKET,x,bottom-h/2,h*.5,h,tilt)

def egg(im,x,y,r=45,crack=0):
    pts=[]
    for a in np.linspace(0,2*math.pi,80):
        yy=math.cos(a);xx=math.sin(a)*(.80+.18*(1+yy)/2)
        pts.append((x+xx*r*.72,y+yy*r))
    poly(im,pts,(249,235,201,255))
    ellipse(im,(x-r*.29,y-r*.66,x-r*.02,y-r*.15),(255,250,230,180))
    if crack:
        line(im,[(x,y-r*.75),(x-r*.12,y-r*.26),(x+r*.17,y-r*.05),(x-r*.17,y+r*.24),(x+r*.07,y+r*.74)],(105,73,64,255),2)
        if crack>1:
            for i in range(7):
                a=i*2.4;dx=math.cos(a)*r*.8*crack;dy=abs(math.sin(a))*r*.33*crack
                poly(im,[(x+dx,y+dy),(x+dx+6,y+dy-9),(x+dx+12,y+dy+3)],(245,225,190,235))

STARS=np.random.default_rng(4744).uniform(0,1,(62,4))
CHAPTERS=['אתגר החללית','המשימה','ציוד המשימה','שעון המשימה','מגבלת הגובה','בדיקת הפגיעה','ניקוד חלקי','נחיתה מוצלחת','מתכנון לביצוע']

def background(index,t,absolute,duration):
    item=MANIFEST[index]
    kind='hero' if item.get('layout') in ['title','question'] or (item.get('layout')=='spaceship' and item['example_index'] in [0,7,8]) else 'content'
    im=BACKGROUNDS[kind].copy()
    for x,y,depth,phase in STARS:
        px=(x*1280+math.sin(absolute*.2+phase*6)*depth*10)%1280
        py=(y*660+absolute*(1+depth*5))%660
        alpha=int(35+60*(.5+.5*math.sin(absolute*1.5+phase*8)))
        rr=.7+depth
        ellipse(im,(px-rr,py-rr,px+rr,py+rr),(*GOLD,alpha))
    paste(im,LOGO,88,58,53,53.4)
    paste(im,WORDMARK,231,58,205)
    chapter=item.get('chapter',CHAPTERS[item['example_index']] if item.get('layout')=='spaceship' else PLAN.get('title',''))
    text(im,chapter[:38],1212,58,23,GOLD,anchor='rm')
    line(im,[(64,106),(1216,106)],(*GOLD,85),1)
    # A quiet progress rail, with one pulse per chapter.
    line(im,[(64,699),(1216,699)],(110,153,183,95),2)
    done=(absolute/TOTAL)*1152
    line(im,[(64,699),(64+done,699)],GOLD,2)
    for i,start in enumerate(STARTS):
        xx=64+start/TOTAL*1152
        ellipse(im,(xx-2,697,xx+2,701),GOLD if i<=index else (95,132,162,255))
    return im

def title(im,txt,t,size=48):
    offset=15*(1-ease(t/.65))
    text(im,txt,640,155+offset,size,WHITE,True,alpha=round(255*ease(t/.5)))

def shadow(im,x,y,rx=75):ellipse(im,(x-rx,y-8,x+rx,y+8),(0,15,28,100))

def ring(im,x,y,r,t,color=GOLD):
    for j in range(3):
        start=(t*28+j*120)%360
        draw_shape(im,'arc',rect((x-r,y-r,x+r,y+r)),start=start,end=start+65,fill=(*color,150),width=P(2))

def ruler(im,x,top,bottom,label):
    line(im,[(x,top),(x,bottom)],GOLD,2)
    for j in range(11):
        yy=top+(bottom-top)*j/10
        line(im,[(x-10 if j in [0,10] else x-5,yy),(x+10 if j in [0,10] else x+5,yy)],GOLD,2 if j in [0,10] else 1)
    text(im,label,x-24,(top+bottom)/2,26,GOLD,True,'rm')

def burst(im,x,y,t,color=GOLD,count=30):
    if t<0:return
    for i in range(count):
        a=i*2.39996; speed=30+(i*47)%170
        xx=x+math.cos(a)*speed*t; yy=y+math.sin(a)*speed*t+70*t*t
        opacity=round(255*clamp(1-t/2.4))
        if opacity:
            ellipse(im,(xx-2,yy-2,xx+2,yy+2),(*color,opacity))

def spaceship_scene(index,t,absolute,duration,actual_index):
    im=background(actual_index,t,absolute,duration)
    # Scene animation follows the nominal chapter pace even if a voice requires extra time.
    q=t/duration*[7,11,10,12,9,7,8,12,9][index]
    if index==0:
        glow(im,350,390,110,BLUE,35)
        bottom=880-290*ease(q/2.1)+5*math.sin(q*2)
        rocket(im,340,bottom,360,tilt=-3*math.sin(q),fire=1,t=q)
        text(im,'אתגר החללית',925,240,72,WHITE,True)
        text(im,'אתגר בתכנון הנדסי',925,332,35,GOLD,True)
        if q>3.6:
            n=max(1,3-int(q-3.6));ring(im,925,479,64,q)
            text(im,str(n),925,479,78,GOLD,True)
        text(im,'תדריך המשימה שלכם',925,588,27,WHITE)
    elif index==1:
        title(im,'מתכננים פתרון לבלימת פגיעה',t)
        ruler(im,145,232,594,'5 מטרים')
        fall=ease((q-1.1)/7)
        bottom=315+275*fall
        shadow(im,365,602,40+40*fall)
        rocket(im,365,bottom,190,chute=smooth((q-1.3)/1.1),t=q)
        glow(im,920,345,95,GOLD,38);ring(im,920,345,96,q)
        egg(im,920,345,64)
        text(im,'להגן על הביצה',920,488,42,WHITE,True)
        text(im,'ולספוג את הפגיעה',920,541,32,GOLD,True)
    elif index==2:
        title(im,'מה מקבלים למשימה?',t)
        for j,(xx,lab) in enumerate([(1080,'חלקי חללית'),(790,'חומרים'),(500,'מצנח'),(210,'כלים')]):
            z=ease((q-.25-j*.35)/.8)
            yy=350+(1-z)*55+3*math.sin(q*1.7+j)
            if z<=0:continue
            ring(im,xx,yy,100,q*.4+j,BLUE)
            if j==0:
                # Separate the removable nose from the cylindrical body and fins.
                cap=ROCKET.crop((0,0,ROCKET.width,328))
                body=ROCKET.crop((0,328,ROCKET.width,ROCKET.height))
                gap=1+0.04*math.sin(q*1.7)
                paste(im,cap,xx,yy-76*gap,90,angle=2*math.sin(q))
                paste(im,body,xx,yy+37*gap,90)
            elif j==1:
                poly(im,[(xx-48,yy-55),(xx+48,yy-55),(xx+60,yy+63),(xx-60,yy+63)],(40,91,130,255),GOLD,2)
                line(im,[(xx-36,yy-53),(xx-23,yy-80),(xx+23,yy-80),(xx+36,yy-53)],GOLD,3)
                text(im,'?',xx,yy+5,62,GOLD,True)
            elif j==2:parachute(im,xx,yy+75,160)
            else:
                line(im,[(xx-40,yy-30),(xx+48,yy+64)],WHITE,8)
                line(im,[(xx+40,yy-30),(xx-48,yy+64)],BLUE,8)
                ellipse(im,(xx-65,yy-75,xx-15,yy-25),None,GOLD,7)
                ellipse(im,(xx+15,yy-75,xx+65,yy-25),None,GOLD,7)
            text(im,lab,xx,495,30,WHITE,True)
        text(im,'משתמשים רק בחומרים שקיבלתם',640,591,34,GOLD,True)
    elif index==3:
        title(im,'60 דקות. משימה אחת.',t,54)
        line(im,[(280,354),(1000,354)],(*BLUE,120),2)
        for j,(xx,num,label,c) in enumerate([(1000,'10','תכנון בלבד',GOLD),(640,'35','בנייה והרכבה',BLUE),(280,'15','בדיקה והגשה',GREEN)]):
            z=ease((q-.6-j*1.7)/1.1)
            ellipse(im,(xx-90,264,xx+90,444),(*NAVY,230),(*c,100),2)
            ImageDraw.Draw(im).arc(rect((xx-90,264,xx+90,444)),-90,-90+max(1,360*z),fill=c,width=P(5))
            text(im,num,xx,350,76,c,True,alpha=round(255*z))
            text(im,'דקות',xx,400,24,WHITE,alpha=round(255*z))
            text(im,label,xx,502,30,c,True,alpha=round(255*z))
        text(im,'קודם מתכננים. אחר כך בונים.',640,592,29,WHITE)
    elif index==4:
        title(im,'בודקים לפני שמגישים',t)
        shadow(im,435,599,95);rocket(im,435,600,350)
        ruler(im,615,267,600,'25 ס״מ')
        yy=205+62*ease(q/2)
        line(im,[(330,yy),(670,yy)],(*GOLD,180),2)
        glow(im,955,360,85,GOLD,24)
        text(im,'עד 25 ס״מ',958,350,70,GOLD,True)
        text(im,'גובה החללית במצב עומד',958,458,31,WHITE,True)
        text(im,'הגודל הוא חלק מהאתגר',958,519,26,BLUE)
    elif index==5:
        title(im,'כשהחללית והביצה נשברות',t)
        f=clamp((q-.6)/2.2);bottom=210+385*f*f
        shadow(im,355,603,50+35*f)
        if q<2.85:rocket(im,355,bottom,265,tilt=8*math.sin(q*2))
        else:
            dt=q-2.85
            # Separate existing rendered rocket artwork into two visibly broken pieces.
            top=ROCKET.crop((0,0,ROCKET.width,round(ROCKET.height*.48)))
            low=ROCKET.crop((0,round(ROCKET.height*.48),ROCKET.width,ROCKET.height))
            z=ease(dt/.8)
            paste(im,top,350-75*z,461+84*z,133,128,24*z)
            paste(im,low,355+55*z,545+38*z,133,138,-36*z)
            burst(im,355,571,dt,RED,35);egg(im,300,597,23,2)
            for rr in [1,1.4]:
                r=90*clamp(dt*rr)
                ellipse(im,(355-r,586-r*.18,355+r,586+r*.18),None,(*RED,round(200*clamp(1-dt/2))),2)
        text(im,'0',947,337,138,RED,True)
        text(im,'נקודות',947,452,35,WHITE,True)
        text(im,'מתכננים לבלימת הפגיעה',947,540,28,WHITE)
    elif index==6:
        title(im,'החללית שרדה. הביצה נשברה.',t,46)
        f=smooth((q-.5)/4.1);bottom=300+295*f
        shadow(im,355,602,85)
        rocket(im,355,bottom,255,tilt=12*smooth((q-4.4)/.7),chute=1-clamp((q-4.5)/1.4),t=q)
        if q>4.6:egg(im,476,584,35,1.4)
        text(im,'5',947,337,138,(255,186,111),True)
        text(im,'נקודות',947,452,35,WHITE,True)
        text(im,'חללית שלמה וביצה פגועה',947,540,28,WHITE)
    elif index==7:
        title(im,'נחיתה בעמידה. ביצה שלמה.',t,48)
        f=smooth((q-.4)/5.0);bottom=287+304*f
        bounce=7*math.exp(-max(0,q-5.4)*4)*math.sin(max(0,q-5.4)*10) if q>5.4 else 0
        shadow(im,350,601,80)
        for k in range(3):ellipse(im,(245-k*13,583-k*4,455+k*13,615+k*4),None,(*GOLD,70+40*k),1)
        rocket(im,350,bottom-bounce,255,chute=1-clamp((q-6.0)/1.7),t=q)
        if q>5.7:egg(im,505,552,33)
        text(im,'+20',1005,302,68,GOLD,True,'rm')
        text(im,'נחיתה בעמידה',1210,302,27,WHITE,True,'rm')
        text(im,'+20',1005,398,68,GREEN,True,'rm',alpha=round(255*ease((q-4.0)/1)))
        text(im,'הביצה שלמה',1210,398,27,WHITE,True,'rm',alpha=round(255*ease((q-4.0)/1)))
        if q>7.7:
            count=round(40*ease((q-7.7)/1.25))
            text(im,str(count),965,530,90,GOLD,True)
            text(im,'עד 40 נקודות',965,595,26,WHITE,True)
            for i in range(34):
                a=i*2.39996;dt=q-7.7
                xx=640+math.cos(a)*(65+i*4)*dt;yy=170+math.sin(a)*(70+i*2)*dt+50*dt*dt
                if 0<yy<610:
                    col=[GOLD,GREEN,BLUE][i%3]
                    poly(im,[(xx,yy),(xx+4,yy-6),(xx+9,yy+1),(xx+4,yy+7)],(*col,180))
    elif index==8:
        if q<4.2:
            title(im,'איך צוברים נקודות?',t,51)
            rows=[('חללית וביצה שבורות','0',RED),('חללית שרדה, הביצה נשברה','5',(255,186,111)),('נחיתה בעמידה','+20',GOLD),('הביצה נשארה שלמה','+20',GREEN)]
            for j,(label,num,c) in enumerate(rows):
                z=ease((q-j*.35)/.5)
                yy=266+j*78
                line(im,[(210,yy+33),(1070,yy+33)],(*c,90),1)
                text(im,label,1045,yy,29,WHITE,True,'rm',alpha=round(255*z))
                text(im,num,280,yy,42,c,True,alpha=round(255*z))
        else:
            text(im,'מתכנון לביצוע',640,210,53,WHITE,True)
            glow(im,640,352,80,GOLD,35)
            paste(im,LOGO,640,350,132,133)
            paste(im,WORDMARK,640,490,465)
            text(im,'תכנון. בנייה. בדיקה.',640,572,36,GOLD,True)
    return im

def wrap_words(value,size,width,bold=False):
    lines=[]
    for paragraph in str(value).split('\n'):
        current=''
        for word in paragraph.split():
            trial=(current+' '+word).strip()
            if current and font(size,bold).getlength(get_display(trial))>P(width):
                lines.append(current);current=word
            else:current=trial
        if current:lines.append(current)
    return lines or ['']

def copy_block(im,value,x,y,width,size=34,bold=False,color=WHITE,anchor='rm',max_lines=3,alpha=255):
    while size>=22:
        lines=wrap_words(value,size,width,bold)
        if len(lines)<=max_lines:break
        size-=1
    if len(lines)>max_lines:raise ValueError('Split this scene: text does not fit: '+str(value)[:50])
    text(im,'\n'.join(lines),x,y,size,color,bold,anchor,alpha)
    return len(lines)*size*1.22

@lru_cache(maxsize=24)
def image_asset(path):return Image.open(path).convert('RGBA')

def scene(index,t,absolute,duration):
    item=MANIFEST[index];layout=item['layout']
    if layout=='spaceship':return spaceship_scene(item['example_index'],t,absolute,duration,index)
    im=background(index,t,absolute,duration)
    reveal=round(255*ease(t/.65));offset=20*(1-ease(t/.65))
    if layout in ['title','question']:
        size=70 if layout=='title' else 78
        copy_block(im,item.get('heading',PLAN['title']),640,250+offset,1100,size,True,anchor='mm',max_lines=2,alpha=reveal)
        copy_block(im,item.get('subtitle',''),640,475,1020,33,color=GOLD,anchor='mm',max_lines=3,alpha=reveal)
    else:
        copy_block(im,item.get('heading',''),640,156+offset,1120,48,True,anchor='mm',max_lines=2,alpha=reveal)
    if layout=='points':
        items=item['items']
        for size in range(36,25,-1):
            groups=[wrap_words(v,size,1000) for v in items]
            if sum(len(g)*size*1.22+24 for g in groups)<=350:break
        else:raise ValueError('Split this points scene into shorter scenes.')
        yy=265
        for j,group in enumerate(groups):
            z=ease((t-.5-j*.5)/.6)
            ellipse(im,(1173,yy-4,1181,yy+4),(*GOLD,round(255*z)))
            text(im,'\n'.join(group),1146,yy+12*(1-z),size,WHITE,anchor='rm',alpha=round(255*z))
            yy+=len(group)*size*1.22+24
    elif layout in ['steps','timeline']:
        items=item['items'];centers=np.linspace(1100,180,len(items)) if len(items)>1 else [640]
        if len(items)>1:
            line(im,[(180,350),(1100,350)],(*GOLD,150),2)
            poly(im,[(160,350),(179,340),(179,360)],GOLD)
        for j,(xx,label) in enumerate(zip(centers,items)):
            z=ease((t-.4-j*.65)/.7)
            ellipse(im,(xx-53,297,xx+53,403),(*NAVY,230),(*GOLD,round(255*z)),2)
            text(im,str(j+1).zfill(2),xx,350,50,GOLD,True,alpha=round(255*z))
            copy_block(im,label,xx,463,250,28,True,anchor='mm',max_lines=3,alpha=round(255*z))
    elif layout=='metric':
        text(im,str(item.get('value','')),640,350,132,GOLD,True,alpha=reveal)
        copy_block(im,item.get('label',''),640,487,1100,36,True,anchor='mm',max_lines=2,alpha=reveal)
        copy_block(im,item.get('subtitle',''),640,572,1100,26,color=BLUE,anchor='mm',max_lines=2,alpha=reveal)
    elif layout=='image':
        file=(PLAN_PATH.parent/item['image']).resolve()
        if not file.is_relative_to(ROOT):raise ValueError('Copy the supplied image inside the skill folder.')
        pic=image_asset(str(file));ratio=min(500/pic.width,355/pic.height)
        paste(im,pic,330,412,pic.width*ratio,pic.height*ratio)
        yy=275
        for j,label in enumerate(item.get('items',[])[:4]):
            z=ease((t-.5-j*.55)/.6)
            yy+=copy_block(im,label,1200,yy,550,30,max_lines=3,alpha=round(255*z))+24
    return im


def caption_chunks(txt,limit=57):
    chunks=[];cur=''
    for word in txt.split():
        if len(cur)+len(word)+1>limit and cur:chunks.append(cur);cur=''
        cur=(cur+' '+word).strip()
        if word.endswith(('.','?','!')) and len(cur)>25:chunks.append(cur);cur=''
    if cur:chunks.append(cur)
    return chunks

def add_caption(im,index,t,duration):
    chunks=caption_chunks(MANIFEST[index]['text'])
    weights=np.array([len(x) for x in chunks]);bounds=np.cumsum(weights)/weights.sum()
    spoken=len(VOICES[index])/SR if index in VOICES else duration-1.1
    progress=clamp((t-.35)/max(.1,spoken))
    k=min(len(chunks)-1,int(np.searchsorted(bounds,progress)))
    if t<.3 or t>min(duration-.45,spoken+.65):return
    label=chunks[k]
    overlay=Image.new('RGBA',im.size)
    ImageDraw.Draw(overlay).rounded_rectangle(rect((126,639,1154,688)),radius=P(8),fill=(3,18,35,220))
    im.alpha_composite(overlay)
    text(im,label,640,663,25,WHITE)

def read_voice(p):
    out=subprocess.run([FFMPEG,'-v','error','-i',str(p),'-f','f32le','-ar',str(SR),'-ac','2','pipe:1'],capture_output=True,check=True).stdout
    return np.frombuffer(out,dtype='<f4').reshape(-1,2).copy()

VOICES={}
STARTS=[]
DURS=[]
TOTAL=0
for i,item in enumerate(MANIFEST):
    voice=WORK/'audio/voice'/(item['id']+'.wav')
    if valid_clip(item,SPEECH,WORK):VOICES[i]=read_voice(voice)
    duration=max(item['duration'],len(VOICES[i])/SR+1.1 if i in VOICES else 0)
    STARTS.append(TOTAL);DURS.append(duration);TOTAL+=duration

def make_audio(final):
    rng=np.random.default_rng(4744)
    n=round(TOTAL*SR);music=np.zeros((n,2),np.float32);sfx=np.zeros_like(music)
    def add(target,sig,start,pan=0,gain=1):
        at=round(start*SR);sig=np.asarray(sig,dtype=np.float32)*gain
        if at<0:sig=sig[-at:];at=0
        m=min(len(sig),n-at)
        if m<=0:return
        if sig.ndim==1:target[at:at+m,0]+=sig[:m]*math.sqrt((1-pan)/2);target[at:at+m,1]+=sig[:m]*math.sqrt((1+pan)/2)
        else:target[at:at+m]+=sig[:m]
    def bell(freq,d=.5):
        t=np.arange(round(d*SR))/SR
        return (np.sin(2*np.pi*freq*t)+.3*np.sin(2*np.pi*freq*2.01*t))*np.exp(-t*8)*.12
    def whoosh(at,d=.6,gain=.07):
        t=np.arange(round(d*SR))/SR;noise=rng.normal(0,1,len(t));sig=(noise-np.roll(noise,1))*.25*np.sin(np.pi*t/d)**2
        add(sfx,sig,at,gain=gain)
    beat=60/96;roots=[50,46,53,48];thirds=[3,4,4,4]
    for b in range(math.ceil(TOTAL/(beat*4))):
        at=b*beat*4;root=roots[b%4];third=thirds[b%4]
        t=np.arange(round((beat*4+.12)*SR))/SR
        env=np.minimum(1,t/.16)*np.minimum(1,np.maximum(0,(beat*4+.12-t)/.23))
        pad=sum(np.sin(2*np.pi*(440*2**((root+x-69)/12))*t) for x in [12,12+third,19])/3
        add(music,pad*env,at,gain=.067)
        for j in range(4):
            tt=np.arange(round(.37*SR))/SR
            ff=440*2**((root-12-69)/12)
            bass=(np.sin(2*np.pi*ff*tt)+.25*np.sin(2*np.pi*2*ff*tt))*np.exp(-tt*9)
            add(music,bass,at+j*beat,gain=.055)
            tt=np.arange(round(.23*SR))/SR
            kick=np.sin(2*np.pi*(47*tt+7*(1-np.exp(-tt*24))))*np.exp(-tt*18)
            add(music,kick,at+j*beat,gain=.12)
            if j%2:
                tt=np.arange(round(.13*SR))/SR
                noise=rng.normal(0,1,len(tt));snare=(noise-np.roll(noise,1))*.25*np.exp(-tt*28)
                add(music,snare,at+j*beat,gain=.06)
        for j in range(8):
            note=root+[24,third+24,31,36,31,third+24,36,31][j]
            freq=440*2**((note-69)/12)
            add(music,bell(freq,.33),at+j*beat/2,pan=(-.35 if j%2 else .35),gain=.3)
    # Original mission cues, timed to the picture edits.
    for at in STARTS[1:]:whoosh(at-.32,.65,.15)
    if len(MANIFEST)==9 and all(x['layout']=='spaceship' for x in MANIFEST):
        for j in range(3):add(sfx,bell(740+j*180,.25),STARTS[0]+DURS[0]*(3.6+j)/7,gain=.75)
        whoosh(.75,1.7,.16)
        for j in range(4):add(sfx,bell(660+j*130,.3),STARTS[2]+.4+j*.35,gain=.55)
        for j in range(3):add(sfx,bell(440*2**(j*4/12),.4),STARTS[3]+1+j*1.7,gain=.55)
        tt=np.arange(round(.65*SR))/SR
        thud=np.sin(2*np.pi*52*tt)*np.exp(-tt*12)+rng.normal(0,1,len(tt))*.18*np.exp(-tt*30)
        add(sfx,thud,STARTS[5]+DURS[5]*2.85/7,gain=.15)
        for j in range(5):add(sfx,bell(2700+j*170,.17),STARTS[5]+DURS[5]*2.86/7+j*.045,pan=-.3+j*.12,gain=.12)
        add(sfx,bell(1650,.2),STARTS[6]+DURS[6]*4.6/8,gain=.3)
        add(sfx,thud,STARTS[7]+DURS[7]*5.4/12,gain=.06)
        for j in range(5):add(sfx,bell(587.33*2**([0,4,7,12,16][j]/12),.8),STARTS[7]+DURS[7]*7.7/12+j*.14,pan=-.4+j*.2,gain=.65)
        for j in range(3):add(sfx,bell(587.33*2**([0,7,12][j]/12),1),STARTS[8]+DURS[8]*4.3/9+j*.16,gain=.55)
    else:
        for i,item in enumerate(MANIFEST):
            for j in range(min(4,len(item.get('items',[])))):
                add(sfx,bell(620+j*100,.25),STARTS[i]+.5+j*.55,gain=.38)
    voice_mix=np.zeros_like(music);duck=np.ones(n,np.float32)
    if final:
        if len(VOICES)!=len(MANIFEST):raise RuntimeError('Final render requires all genuine Gemini voice clips. Run gemini_tts.py first.')
        for i,data in VOICES.items():
            rms=float(np.sqrt(np.mean(data*data))+1e-8)
            data=data*min(8,.135/rms)
            peak=float(np.max(np.abs(data)))
            if peak>.83:data*=.83/peak
            at=round((STARTS[i]+.35)*SR)
            end=min(n,at+len(data));voice_mix[at:end]+=data[:end-at]
            a=max(0,at-round(.15*SR));b=min(n,end+round(.15*SR))
            duck[a:b]=np.minimum(duck[a:b],.34)
            ramp=min(round(.15*SR),(b-a)//2)
            if ramp:duck[a:a+ramp]=np.linspace(1,.34,ramp);duck[b-ramp:b]=np.linspace(.34,1,ramp)
    mix=music*duck[:,None]+sfx*.8+voice_mix
    mix*=np.minimum(1,np.arange(n)/SR/.25)[:,None]
    mix*=np.minimum(1,np.maximum(0,(n-np.arange(n))/SR/.65))[:,None]
    peak=float(np.max(np.abs(mix)))
    if peak>.94:mix*=.94/peak
    path=BUILD/'soundtrack.wav'
    with wave.open(str(path),'wb') as wf:
        wf.setnchannels(2);wf.setsampwidth(2);wf.setframerate(SR);wf.writeframes((mix*32767).astype('<i2').tobytes())
    print('Audio:',round(TOTAL,2),'seconds; stereo; Gemini clips:',len(VOICES) if final else 0,flush=True)
    return path

def write_srt():
    def fmt(t):
        ms=round(t*1000)
        return f'{ms//3600000:02}:{ms//60000%60:02}:{ms//1000%60:02},{ms%1000:03}'
    lines=[];num=0
    for i,item in enumerate(MANIFEST):
        chunks=caption_chunks(item['text']);total=sum(map(len,chunks));cursor=STARTS[i]+.35
        span=len(VOICES[i])/SR if i in VOICES else DURS[i]-1.1
        for chunk in chunks:
            end=cursor+span*len(chunk)/total;num+=1
            lines.append(f'{num}\n{fmt(cursor)} --> {fmt(end)}\n{chunk}\n');cursor=end
    (OUTPUT/(PLAN_PATH.stem+'.he.srt')).write_text('\n'.join(lines),encoding='utf-8')


def main():
    args=PARSER.parse_args()
    final=args.final
    if final and len(VOICES)!=len(MANIFEST):raise RuntimeError('Missing Gemini narration; refusing to label an unvoiced movie as final.')
    master=BUILD/'picture-master.mp4';timeline=BUILD/'picture-timeline.json'
    expected={'durations':DURS,'fps':FPS,'width':W,'height':H,'plan':hashlib.sha256(PLAN_PATH.read_bytes()).hexdigest(),'renderer':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),'assets':{p.name:hashlib.sha256(p.read_bytes()).hexdigest() for p in ASSETS.iterdir() if p.is_file()}}
    reuse=master.exists() and timeline.exists() and json.loads(timeline.read_text())==expected and not args.force
    if not reuse:
        cmd=[FFMPEG,'-hide_banner','-loglevel','error','-y','-f','rawvideo','-pixel_format','rgb24','-video_size',f'{W}x{H}','-framerate',str(FPS),'-i','pipe:0','-an','-c:v','libx264','-preset','veryfast','-crf','18','-pix_fmt','yuv420p','-movflags','+faststart',str(master)]
        proc=subprocess.Popen(cmd,stdin=subprocess.PIPE)
        cuts=[scene(i,DURS[i]-.01,STARTS[i]+DURS[i]-.01,DURS[i]) for i in range(len(MANIFEST))]
        total_frames=round(TOTAL*FPS)
        for f in range(total_frames):
            absolute=f/FPS;i=max(j for j,start in enumerate(STARTS) if start<=absolute);local=absolute-STARTS[i]
            im=scene(i,local,absolute,DURS[i])
            if i and local<.35:
                im=Image.blend(cuts[i-1],im,smooth(local/.35))
                if .08<local<.28:
                    xx=-250+1800*local/.35
                    line(im,[(xx-70,100),(xx+160,620)],(*GOLD,100),3)
            add_caption(im,i,local,DURS[i])
            if not final:text(im,'תצוגה מקדימה ללא קריינות',640,624,19,GOLD)
            proc.stdin.write(im.convert('RGB').tobytes())
            if f%(FPS*5)==0:print(f'Picture {f/total_frames:.0%} | {absolute:.1f}s | {MANIFEST[i]["id"]}',flush=True)
        proc.stdin.close()
        if proc.wait()!=0:raise RuntimeError('FFmpeg picture encoding failed.')
        timeline.write_text(json.dumps(expected))
    audio=make_audio(final)
    target=OUTPUT/(PLAN_PATH.stem+('-final.mp4' if final else '-preview.mp4'))
    subprocess.run([FFMPEG,'-hide_banner','-loglevel','error','-y','-i',str(master),'-i',str(audio),'-c:v','copy','-af','loudnorm=I=-16:TP=-1.5:LRA=9','-c:a','aac','-b:a','192k','-ar','48000','-t',str(TOTAL),'-movflags','+faststart',str(target)],check=True)
    provenance={'title':PLAN.get('title'),'duration_seconds':TOTAL,'dimensions':[W,H],'fps':FPS,'narration':'Google Gemini TTS' if final else 'No narration: preview','voices':{'male':SPEECH['male_voice'],'female':SPEECH['female_voice']} if final else None,'music':'Original NumPy synthesizer score','sound_effects':'Original Python synthesizer effects','brand':'Da Vinci 4744 supplied assets','scene_plan':PLAN_PATH.name,'captions':'Approximate phrase timing within the measured voice clips'}
    (OUTPUT/'production.json').write_text(json.dumps(provenance,ensure_ascii=False,indent=2))
    write_srt()
    print('Finished:',target,flush=True)

if __name__=='__main__':main()

===== END FILE =====

===== FILE: davinci-videos/scripts/requirements.txt =====
Pillow>=11
numpy>=2
imageio-ffmpeg>=0.6
python-bidi>=0.6

===== END FILE =====
