PivotCut
Desktop app di animazione 2D cut-out per macOS (Apple Silicon), ispirata a Pivot Animator. Personaggi composti da parti PNG trasparenti riutilizzabili, timeline a miniature, layer con parallasse 2.5D, export PNG sequence e MP4.
Stato attuale: Milestone 5B — Packaging macOS locale (Apple Silicon).
Import PNG, asset registry, rig cut-out gerarchico a cinematica diretta
(forward kinematics), layer di background/foreground con parallasse 2.5D
basata su profondità, camera virtuale per frame, inspector per layer e
camera, playback della timeline che rispetta FPS/exposure, export di una
sequenza PNG e export MP4 H.264 via FFmpeg, miniature timeline renderizzate
realmente dal contenuto scena, riordino frame via drag-and-drop, Undo/Redo
affidabile per tutte le operazioni di editing, un processo ripetibile per
costruire PivotCut come .app locale via PyInstaller, e una guida in-app
cliccabile (menu Help -> Getting Started…, più testi "What's This?" su
praticamente ogni pulsante/campo/pannello, richiamabili dal pulsante "?"
in toolbar o con Shift+F1) pensata per chi apre l'app per la prima volta.
Prerequisiti
- macOS su Apple Silicon
- Python 3.12 o superiore (verificato con
python3.12 --version) - FFmpeg opzionale: necessario solo per l'export MP4. Senza FFmpeg
installato l'app resta pienamente funzionante — playback ed export PNG
sequence non richiedono FFmpeg in alcun modo. Per installarlo:
brew install ffmpeg.
Installazione
cd /Users/alessio/Progetti/PivotCut python3.12 -m venv .venv source .venv/bin/activate pip install --upgrade pip pip install -r requirements.txt
Avvio dell'applicazione
source .venv/bin/activate PYTHONPATH=src python -m pivotcut.main
Esecuzione dei test
source .venv/bin/activate python -m pytest
pyproject.toml imposta pythonpath = ["src"] per pytest, quindi non serve
installare il pacchetto in modalità editable per eseguire i test.
Build macOS (Apple Silicon)
Produce un bundle PivotCut.app locale, non firmato, tramite
PyInstaller — pensato per l'uso sulla propria macchina, non ancora per la
distribuzione (firma, notarizzazione e DMG sono previsti separatamente,
Milestone 5C).
Prerequisiti
-
macOS su Apple Silicon.
-
Un interprete Python arm64 nativo (non x86_64 sotto Rosetta): lo script di build lo verifica ed esce con errore altrimenti. Controllo manuale:
python3 -c "import platform; print(platform.machine())" # deve stampare: arm64 -
Ambiente virtuale attivato con tutte le dipendenze installate (PyInstaller incluso — è nel
requirements.txt/negli extradevdipyproject.toml):source .venv/bin/activate pip install -r requirements.txt
Comando di build
./scripts/build_macos.sh # oppure: bash scripts/build_macos.sh
Lo script, in ordine:
- verifica di essere su macOS ed esce altrimenti;
- verifica che l'interprete Python sia arm64 nativo;
- verifica che PyInstaller sia installato;
- ripulisce
build//dist/preesistenti (con conferma interattiva, o automaticamente con--clean); - esegue l'intera suite
pyteste interrompe la build se un test fallisce; - invoca
python -m PyInstaller pivotcut.spec; - verifica che
dist/PivotCut.appesista davvero, poi ne stampa il path.
Opzioni: --clean (pulizia automatica senza prompt), --skip-tests (salta
la gate dei test — solo per iterare sul packaging stesso, mai per una build
da distribuire). scripts/clean_build.sh rimuove solo build//dist/,
per un cleanup indipendente dalla build.
Dove trovare l'app e come avviarla
Il bundle finito è in dist/PivotCut.app. Due modi di avviarlo:
- Da Finder: doppio click su
dist/PivotCut.app(prima apertura: vedi Gatekeeper sotto). - Da terminale:
open dist/PivotCut.app, oppure direttamente l'eseguibile interno (utile per vedere subito eventuali errori):dist/PivotCut.app/Contents/MacOS/PivotCut.
Problemi comuni
- Python x86_64/Rosetta: se
platform.machine()stampax86_64pur essendo su Apple Silicon, l'interprete attivo gira sotto Rosetta (spesso un venv creato con un Python Intel installato per errore). Ricrea il venv con un Python arm64 nativo (es.python3.12 -m venv .venvcon un Python scaricato per Apple Silicon) prima di ricostruire. - Errori di plugin/piattaforma Qt ("could not find or load the Qt
platform plugin cocoa"): quasi sempre sintomo di una build fatta con un
PySide6 non coerente con l'interprete usato (es. mescolando arch), o di
una
dist/"sporca" da un tentativo precedente — prova./scripts/build_macos.sh --clean. - Gatekeeper ("PivotCut.app è danneggiata/non può essere aperta"): atteso per una build non firmata/non notarizzata. Click destro sull'app -> Apri, poi conferma nel dialogo — necessario solo alla prima apertura. Questo NON è un limite risolvibile in questa milestone: firma e notarizzazione sono Milestone 5C.
- FFmpeg assente: il bundle non include FFmpeg e non lo richiede per
funzionare — l'export PNG Sequence funziona comunque; Export MP4 mostra
lo stesso messaggio "ffmpeg non trovato" già presente da Milestone 4,
con il suggerimento
brew install ffmpeg. - App avviata da Finder con
PATHdiverso dal terminale: su macOS le app avviate da Finder non ereditano ilPATH/le variabili d'ambiente della shell (a differenza di quando si lancia l'eseguibile da terminale). PivotCut non dipende dalPATHper le proprie funzionalità core, maservices/ffmpeg_export.find_ffmpeg()cercaffmpegsia suPATHsia esplicitamente in/opt/homebrew/bin/ffmpeg(il prefisso Homebrew standard su Apple Silicon) proprio per restare affidabile anche quando l'app è lanciata da Finder e un FFmpeg installato solo via unPATHdi shell personalizzato non sarebbe altrimenti visibile.
Struttura del progetto
pivotcut/
README.md
requirements.txt
pyproject.toml
.gitignore
pivotcut.spec # configurazione PyInstaller (bundle .app macOS, Milestone 5B)
scripts/
build_macos.sh # build ripetibile: check arm64, pytest gate, PyInstaller, verifica bundle
clean_build.sh # rimuove solo build/ e dist/
src/
pivotcut/
__init__.py
main.py # entry point applicazione
runtime_paths.py # resource_path()/package_root(): unico punto che ispeziona sys.frozen/_MEIPASS
app/
main_window.py # MainWindow: toolbar, menu Characters, splitter asset panel + canvas/timeline
domain/
models.py # SceneSettings, Frame, Project (dataclass, no Qt) + coordinate + rig_templates
assets.py # Asset (dataclass pura, leaf module)
rig.py # Bone, Rig, RigTemplate, Matrix2D, validazione, world transform, attach/pivot (no Qt)
layer.py # Layer (dataclass pura), validate_layer(s), create_layer_from_asset
camera.py # Camera, parallax_factor, screen_position, zoom, layer/character screen matrix
timeline.py # operazioni pure su Project: new/delete/move/select/reorder frame
playback.py # espansione pose->frame video, conteggio/durata output, validazione fps/exposure
commands.py # Command pattern + UndoRedoStack, senza Qt (inclusi i comandi rig/template)
services/
asset_manager.py # import PNG, dedup, path resolution (Pillow + un QFileDialog)
project_io.py # save/load JSON, eccezioni tipizzate (inclusi rig_templates)
rig_template_io.py # import/export .pivotcut-rig.json (template esterno condivisibile)
export_renderer.py # rendering headless Project/Frame -> QImage/PNG/thumbnail (no CanvasView/UI)
ffmpeg_export.py # rilevamento ffmpeg, subprocess, encoding H.264 MP4
playback_controller.py # QTimer-based playback (fps/exposure, loop, is_playing)
thumbnail_cache.py # cache miniature timeline, chiave (frame_id, revision, size, mode)
ui/
canvas_view.py # QGraphicsScene/View: zoom, pan, selezione/drag rig, selezione layer
graphics_items.py # BoneItem/LayerItem, placeholder asset mancante, RigRenderer/LayerRenderer
rig_builder_dialog.py # Character Rig Builder: import PNG, gerarchia parti, pivot/attach, preview
timeline_widget.py # miniature renderizzate, drag-and-drop reorder, pulsanti, fps/exposure
asset_panel.py # pannello Assets: import, lista, "Quick Create Single-Part Rig"/"Add Layer"
inspector_panel.py # pannello Inspector: proprietà layer selezionato, o camera del frame
resources/
tests/
test_timeline.py
test_project_io.py
test_rig_transforms.py
test_rig_template.py # dominio: attach/pivot, reparent/remove/duplicate, RigTemplate, istanziazione
test_rig_template_io.py # import/export .pivotcut-rig.json, asset mapping
test_parallax.py
test_playback.py
test_export_renderer.py
test_ffmpeg_export.py
test_history.py # UndoRedoStack + tutti i comandi (inclusi rig/template) + dirty state + MainWindow
test_thumbnail_cache.py # render_thumbnail + ThumbnailCache
conftest.py # fixture QApplication condivisa per i test che toccano Qt
Il dominio (domain/) non importa mai Qt: è testabile e usabile senza avviare
la GUI. La UI (ui/, app/) chiama sempre le funzioni pure di domain/timeline.py,
domain/rig.py e domain/camera.py per garantire lo stesso comportamento
validato dai test.
domain/assets.py esiste come modulo a sé stante (invece di stare in
models.py o in services/asset_manager.py) per evitare un import circolare:
models.py importa Rig da rig.py, Layer da layer.py, Camera da
camera.py e Asset da assets.py; rig.py e layer.py importano Asset
da assets.py per validare i propri riferimenti; camera.py importa Layer
da layer.py (per calcolare la trasformazione schermo di un layer) e
Matrix2D da rig.py (riuso della stessa matematica affine, invece di
duplicarla). La direzione delle dipendenze resta sempre a senso unico:
assets.py ← rig.py/layer.py ← camera.py ← models.py. Se Asset
fosse stato dentro models.py, si sarebbe creato un ciclo. services/asset_manager.py
resta il punto d'ingresso per l'import di un PNG (Pillow + QFileDialog):
contiene la logica, non il dato.
Funzionalità di questa milestone
- Canvas:
QGraphicsScene/QGraphicsView, scena 1920x1080 di default, zoom con rotellina/trackpad, pan con tasto centrale del mouse o Space+drag. - Rig cut-out PNG: import di PNG trasparenti,
AssetManagercon deduplica (reimportare lo stesso file riusa l'asset esistente), un rig per frame composto da bone gerarchici (forward kinematics), pivot per-immagine, z-index, visibilità e opacità. - Manipolazione base: click su una parte per selezionarla (bounding box evidenziato), drag normale per spostarla (in local space rispetto al parent; scene space per il root), Shift+drag orizzontale per ruotarla attorno al proprio pivot.
- Rendering: ogni bone è un
QGraphicsPixmapItem"flat" (senzasetParentItemper la gerarchia): la trasformazione mondo di ogni bone è calcolata in Python puro (domain.rig.world_transforms) e applicata comeQTransformdiretto — il modello di dominio resta l'unica fonte di verità e la scena Qt può sempre essere ricostruita da zero senza perdita di dati. Un asset PNG mancante mostra un placeholder tratteggiato con etichetta, senza bloccare l'apertura del progetto. - Timeline: striscia di miniature renderizzate realmente (160×90, aspect ratio della scena preservato con letterbox/pillarbox), frame selezionato evidenziato, riordino via drag-and-drop (indicatore chiaro del punto di inserimento) più i pulsanti Move Left/Move Right come fallback accessibile, pulsanti Previous/Next/New Frame/Delete Frame, controlli FPS ed Exposure a livello di progetto, ogni miniatura mostra "Pose N — label ×exposure". "New Frame" duplica anche i rig del frame corrente (deep copy: id di rig/bone invariati, ma modificare una posa nel nuovo frame non tocca quello sorgente). La timeline scrolla automaticamente per rendere visibile il frame corrente dopo ogni navigazione/riproduzione/ Undo/Redo/duplicazione/cancellazione/riordino.
- Undo/Redo:
Cmd+Z/Cmd+Shift+Z(convenzioni macOS/Qt standard), pulsanti e menu Edit con la descrizione dell'azione (es. "Undo Move Bone"). Copre New/Delete/Move/Reorder Frame, Create Rig, Add Layer, drag di un bone (move o rotate — una sola entry per l'intero gesto, non per ogni pixel di movimento), modifiche layer/camera dall'Inspector (una entry per modifica completata —editingFinished/focus-out/toggle, non per ogni tick dello spinner), e Reset Camera. Eseguire un nuovo comando dopo un Undo elimina il ramo Redo; storico limitato a 100 comandi. Vedi "Undo/Redo" più sotto per i dettagli architetturali. - Dirty state: il titolo finestra mostra
*se ci sono modifiche non salvate; Save/Save As lo rimuove. New Project/Open/chiusura finestra chiedono conferma (Save/Discard/Cancel) se il progetto ha modifiche non salvate. - Shortcut:
Nnuovo frame,Backspace/Deletecancella frame, frecce sinistra/destra navigazione,Spaceè il toggle globale Play/Stop (eccetto quando un campo di testo —QLineEdit/QSpinBox/QDoubleSpinBox/QComboBox— ha il focus, dove digita normalmente uno spazio). Se il focus è sul canvas,Spacetenuto premuto attiva comunque il pan: uno spazio+drag pan non attiva/disattiva il playback, solo pressione/rilascio senza drag lo fa. - Playback: Play/Stop dalla toolbar o con
Space. Riparte dal frame selezionato, mantiene ogni posa perexposuretick, avanza alla posa successiva, si ferma automaticamente all'ultimo frame (comportamento MVP). Il pulsante "Loop" (toggle in toolbar) fa ripartire dal primo frame dopo l'ultimo invece di fermarsi. Durante la riproduzione la timeline e l'inspector sono bloccati (nessuna modifica distruttiva mentre gioca), il canvas resta visibile ma non modificabile; New/Open/Save e gli asset restano bloccati fino allo Stop. La status bar mostra in modo discreto "Pose X/N", "Video frame Y/totale", FPS, Exposure e la durata video stimata. - Export PNG Sequence…: dalla toolbar, chiede una cartella di
destinazione e scrive
frame_000001.png,frame_000002.png, ... (un file per ogni frame video esposizione-espanso:len(project.frames) * project.exposureimmagini totali). Se la cartella contiene già fileframe_*.pngchiede conferma prima di sovrascriverli. Annullabile: i file già scritti restano su disco, non vengono cancellati. Non richiede FFmpeg. - Export MP4…: dalla toolbar, verifica FFmpeg (
PATH, poi/opt/homebrew/bin/ffmpegsu Apple Silicon); se assente mostra un messaggio chiaro (mai un traceback) e suggeriscebrew install ffmpeglasciando comunque disponibile l'export PNG. Se presente, chiede il file.mp4di destinazione, renderizza la sequenza PNG esposizione-espansa in una cartella temporanea (sfondo forzato opaco, dato che yuv420p/H.264 non supporta l'alpha), poi invocaffmpeg -y -framerate {fps} -i frame_%06d.png -c:v libx264 -pix_fmt yuv420p -movflags +faststart -crf 18 {output}viasubprocess.run(nessuna shell). Verifica che il file di output esista e abbia dimensione > 0 prima di dichiarare successo; pulisce sempre la cartella temporanea, anche in caso di errore o annullamento. - Progresso ed annullamento: un
QProgressDialog(fase, barra di avanzamento, pulsante Cancel) segue sia il rendering PNG sia l'encoding MP4; l'interfaccia resta reattiva perchéQProgressDialog.setValue()processa gli eventi Qt internamente tra un frame e l'altro — nessun blocco prolungato del thread principale, nessuna necessità di unQThread(che avrebbe rischiato di creareQPixmapfuori dal thread GUI, non supportato da Qt). - Progetto: New Project, Open, Save, Save As. Formato
.pivotcut.jsonversionato (format_version, invariato a 1: evoluzione retrocompatibile), conscene_settings,fps,exposure,assets,frames(ognuno con i propririgs),current_frame_index. Gli asset mantengonosource_pathassoluto sempre valido erelative_pathricalcolato ad ogni salvataggio rispetto alla cartella del progetto. - Layer 2.5D: layer di background/midground/foreground riutilizzando lo
stesso
AssetManager. Pannello Assets: bottone "Add Layer From Selected Asset" + selettore tipo (Background/Midground/Foreground), che imposta solo valori di default (z_index/z_depth), non un modello distinto — il layer resta liberamente modificabile dopo la creazione. - Camera virtuale e parallasse:
Camera(x, y, zoom) come snapshot per frame, esattamente come i rig. I layer più lontani (z_depth alto) si spostano meno quando la camera si sposta; il rig si muove sempre come il "piano personaggi" (z_depth = 0). Vedi formula sotto. - Inspector: pannello a destra con i campi del layer selezionato, o della camera del frame corrente se non c'è selezione. Ogni modifica scrive direttamente nel frame corrente e risincronizza il canvas. Include il pulsante "Reset Camera" (azzera x/y/zoom del solo frame corrente).
- Toolbar: New, Open, Save, Undo, Redo, Play/Stop, Loop, New Frame, Delete Frame, Export PNG Sequence…, Export MP4… — tutti funzionanti.
Character Rig Builder (Milestone 6A)
Terminologia
- Asset: un PNG registrato nel progetto (
Project.assets), riutilizzabile da qualunque numero di rig/layer. - Rig Template: una struttura di personaggio riutilizzabile (gerarchia di
parti, pivot, attach point, z-index) senza alcuna posa di timeline.
Vive in
Project.rig_templates(la "Rig Library") e non viene mai renderizzato/posato direttamente. - Rig Instance: un rig effettivamente presente in un
Frame(Frame.rigs), con una posa modificabile per-frame. Creare un personaggio dalla libreria (instantiate_rig_template) produce una Rig Instance del tutto indipendente dal template di origine (id di rig/bone rigenerati). - Bone/Part: una singola parte PNG del rig. Il dominio usa "Bone" (coerenza con Milestone 2-5A); l'interfaccia del Rig Builder la chiama sempre "Part" per restare vicina al vocabolario di Pivot Animator.
- Root Part: l'unico bone senza
parent_idin un rig/template. - Pivot: il punto, in coordinate locali dell'immagine PNG, attorno a cui
la parte ruota/scala (vedi
local_matrix). - Attach Point: il punto, in coordinate locali del genitore, dove il pivot di questa parte deve trovarsi — cioè "dove la parte si aggancia al genitore".
- z_index: solo ordine visivo di disegno tra le parti di uno stesso rig;
non ha alcuna relazione con la profondità di parallasse (
z_depth, che resta un concetto diLayer/Camera).
Pivot vs Attach Point
Sono due concetti distinti che in questa milestone risultano sempre coincidenti nello spazio mondo per costruzione: il pivot del figlio "aggancia" esattamente l'attach point del genitore. Concretamente:
- Pivot risponde a "da dove ruota/scala questa parte?" — spostarlo ricentra la rotazione mantenendo l'attach point fisso (la parte si sposta visivamente per compensare).
- Attach Point risponde a "dove si collega questa parte al genitore?" — spostarlo muove l'intera parte (pivot compreso) mantenendo la geometria di rotazione/scala invariata.
Nel Rig Builder i due handle sono disegnati come cerchi concentrici nello stesso punto (anello esterno ciano = attach point, punto pieno interno giallo = pivot), con una legenda esplicita nel pannello di preview; sono draggabili indipendentemente e i valori numerici esatti restano sempre visibili/editabili nell'Inspector per precisione. Questi handle e le linee di "scheletro" tra attach/pivot sono visibili solo nel Builder, mai nel render finale/canvas principale/export.
Internamente Bone.attach_x/attach_y non entrano mai nella matematica di
local_matrix/world_transforms (invariata da Milestone 2): vengono
riconciliati in x/y tramite set_attach_point/
set_pivot_preserving_attach_point, così il posizionamento in scena normale
(drag di una posa sul canvas principale) resta l'esatto codice già validato
dalle milestone precedenti.
Flusso di lavoro
- Characters → New Character Rig… apre il Rig Builder: quattro colonne (asset importabili, gerarchia del rig, preview con handle pivot/attach, inspector della parte selezionata).
- Importa uno o più PNG (singolo file, selezione multipla, o intera
cartella) — riusa
AssetManager, quindi un PNG già registrato non viene mai duplicato; il builder propone un nome leggibile dal filename (es.upper_arm_L.png-> "Upper arm L") senza imporre alcuna convenzione. - "Add Part": la prima parte aggiunta diventa automaticamente la root; le successive si agganciano alla parte attualmente selezionata (o alla root se nessuna è selezionata).
- Ogni parte è modificabile nell'Inspector: nome, genitore (con Reparent protetto da validazione anti-ciclo), posizione locale, rotazione, scala, pivot, attach point, z-index, opacità, visibilità.
- Strumenti non distruttivi (mai sovrascrivono il PNG sorgente): Fit to Canvas, Reset Scale, Set Pivot Center, Crop Preview to Alpha Bounds (ricentra il pivot sul bounding box alpha-visibile, calcolato con Pillow — salvato come metadato/offset del rig, non come nuovo file), dimensione di riferimento della scena.
- "Save Rig" valida l'intera struttura (
validate_rig_template: un solo root, nessun ciclo, asset esistenti, opacità/scale validi) prima di chiudere il dialogo; "Cancel" scarta tutto senza toccareProject, history o cache miniature. - Characters → Save Selected Rig as Template… cattura la Rig Instance
selezionata come voce libreria (
Project.rig_templates), indipendente dalle pose sui frame. - Characters → Add Character From Library… istanzia un template scelto come nuova Rig Instance nel frame corrente (id di rig/bone rigenerati, posizionata al centro scena).
- Characters → Export/Import Rig Template… scrive/legge un file
.pivotcut-rig.jsonstandalone (mai binari PNG — solo riferimenti informativi id/nome/dimensioni/percorso indicativo). All'import, se mancano asset richiesti si apre una UI di mapping (associa a un asset esistente, oppure importa un PNG sostitutivo); un template con riferimenti irrisolti non viene mai inserito. - Characters → Quick Create Single-Part Rig preserva esattamente il comportamento a singolo PNG delle milestone precedenti — non è più l'unico modo di creare un personaggio, ma resta disponibile invariato.
"Edit Selected Character Rig": strategia scelta
Modificare la struttura di un rig (Add/Remove/Reparent/Duplicate Part) tocca solo la Rig Instance del frame corrente — mai propagato automaticamente alle altre pose/frame che condividono lo stesso personaggio, e mai al Rig Template di libreria. Aggiornare il template è un'azione separata ed esplicita ("Save Selected Rig as Template…"). Questa scelta (raccomandata esplicitamente per l'MVP) evita che una modifica strutturale rompa silenziosamente pose già animate su altri frame; il prezzo è che sincronizzare manualmente più istanze dello stesso personaggio resta a carico dell'utente in questa milestone (vedi M6B più sotto).
Undo/Redo, dirty state, cache miniature
L'intera sessione del Builder (molti edit locali su una copia temporanea di
bone) produce un solo comando alla chiusura con Save
(EditRigStructureCommand per un rig esistente, CreateRigCommand per un
rig nuovo) — mai una entry per singolo Add/Remove/Reparent. Salvare un
template (SaveRigTemplateCommand)/cancellarlo (DeleteRigTemplateCommand)
sono comandi undo/redo-abili a parte, che segnano il progetto come "dirty";
l'export di un template esterno non lo segna (nessun contenuto di Project
cambia). Dopo ogni comando la miniatura del frame interessato viene
invalidata (ThumbnailCache.bump) e canvas/timeline/inspector sono
risincronizzati, esattamente come ogni altra mutazione dalla Milestone 5A.
Tutte le azioni del menu Characters sono disabilitate durante
playback/export, come ogni altra azione mutante.
Nessun limite "un rig per frame"
Da questa milestone Frame.rigs può contenere più di un rig: l'architettura
lo supportava già dalla Milestone 2 (RigRenderer/export_renderer
iteravano sempre tutti i rig di un frame), il limite era solo un vincolo
artificiale nell'interfaccia (AssetPanel), ora rimosso. Cliccare una parte
sul canvas seleziona anche il rig a cui appartiene
(CanvasView.selected_rig_id), usato per capire a quale rig si riferiscono
le azioni "Save as Template"/"Edit Selected Character Rig" quando un frame ne
contiene più di uno.
Undo/Redo: architettura
Project/Frame restano l'unica fonte di verità: domain/commands.py
(senza Qt) definisce un Command Protocol (description, apply(project),
revert(project)) e UndoRedoStack (execute/undo/redo/can_undo/
can_redo/clear, storico limitato a DEFAULT_MAX_HISTORY = 100 via
collections.deque(maxlen=...), eseguire un nuovo comando dopo un Undo
elimina il ramo Redo). Ogni azione della UI che deve essere annullabile
passa da MainWindow._execute_command(), mai da una mutazione diretta:
- Comandi strutturali (
NewFrameCommand,DeleteFrameCommand) avvolgono le funzioni pure esistenti didomain/timeline.pye ne catturano pigramente l'effetto (id fresco, suffisso "(copy)", ...) alla primaapply(), così un Redo successivo rigioca esattamente lo stesso effetto invece di generare un nuovo id casuale. MoveFrameCommandavvolgedomain.timeline.move_frame(project, from_index, to_index)— la stessa funzione generalizzata che ora serve sia Move Left/Right sia il riordino via drag-and-drop, garantendo comportamento identico ovunque.CreateRigCommand/AddLayerCommandaggiungono/rimuovono per id unRig/Layergià costruito dalle factory pure di dominio.- Comandi a snapshot mirato (
TransformBoneCommand,EditLayerCommand,EditCameraCommand) salvano una deep copy del solo oggetto modificato (unBone/Layer/Camera, mai l'interoProject) immediatamente prima e immediatamente dopo un gesto di editing completo — non per ogni pixel di drag o ogni tick di uno spinner:CanvasViewcattura lo snapshot "before" quando il drag di un bone inizia e ne emette uno solo (bone_transform_committed) al rilascio del mouse, se lo stato è realmente cambiato.InspectorPanelcattura la baseline ad ogni cambio di frame/selezione e ricommitta (layer_edit_committed/camera_edit_committed) sueditingFinished(spinbox/campo nome) o subito dopo un toggle checkbox/click su Reset Camera — mai su ognivalueChangedintermedio, che continua a scrivere live nel modello solo per l'anteprima canvas. Se il valore finale coincide con quello iniziale, nessun comando viene creato.
- Durante playback o export,
MainWindow._can_mutate_model()blocca_execute_command/_undo/_redo(oltre alla disabilitazione visiva di timeline/inspector/toolbar), come difesa aggiuntiva contro la ri-entranza generata daQProgressDialog.setValue()che processa eventi Qt durante l'export.
Miniature timeline: rendering e cache
services/export_renderer.render_thumbnail(project, frame_index, max_width, max_height) riusa render_frame_to_qimage() (nessuna pipeline duplicata),
poi scala preservando l'aspect ratio della scena con letterbox/pillarbox su
una tela trasparente max_width×max_height — 160×90 di default. Nessun
overlay, bordo di output o griglia: stessa garanzia del rendering di export.
services/thumbnail_cache.py::ThumbnailCache mantiene una cache con chiave
(frame_id, revision, max_width, max_height, background_mode). Frame non
ha alcun concetto di "revision" — è deliberatamente tenuto fuori dal
dominio Qt-free, come bookkeeping puramente derivato per l'invalidazione
della cache UI:
bump(frame_id): invalida solo le miniature di quel frame. Chiamato daMainWindow._bump_thumbnail_for_command()dopo ogni comando che toccaframe_id(bone/layer/camera/rig/layer creation).bump_all(): invalida tutta la cache. Usato per New/Delete Frame (id che cambiano) e per il caricamento di un intero progetto; costo trascurabile data la bassa frequenza di questi eventi.- Il riordino (
MoveFrameCommand) non invalida nulla: cambia solo l'ordine, mai il contenuto renderizzato di un frame.
Non esiste un timer di debounce dedicato: dato che un comando viene creato
solo al termine di un gesto di editing (drag-release, editingFinished),
l'invalidazione della cache è già naturalmente "debounced" dallo stesso
meccanismo che serve Undo/Redo — non è stata aggiunta un'infrastruttura
separata.
Sistema di coordinate e parallasse
Documentato in domain/models.py (convenzioni scena/rig) e domain/camera.py
(formula di parallasse, con esempi):
-
Origine scena: alto-sinistra. X positivo a destra, Y positivo in basso.
-
Unità: pixel scena. Rotazioni in gradi, positive in senso orario.
-
I valori di un bone (
x/y/rotation/scale_x/scale_y) sono in local space rispetto alparent_id, tranne il bone root di un rig, che è in world/scene space. UnLayer(senza gerarchia) usa la stessa identica composizione del bone root: traslazione a(x, y)→ rotazione attorno al pivot → scala attorno allo stesso pivot. -
Formula di parallasse (
domain/camera.py):parallax_factor = 1.0 / (1.0 + z_depth) screen_position = world_position - camera_position * parallax_factorz_depth = 0→factor = 1.0: si muove come il piano personaggi (i bone del rig usano sempre questo caso).z_depth = 1→factor = 0.5: si muove la metà (es. un layer midground).z_depth = 3→factor = 0.25: si muove un quarto (es. un layer background lontano).
-
camera.zoom(limitato a[0.1, 5.0]) scala l'intera scena renderizzata (rig e layer) attorno al centro del frame di output — non tocca lo zoom di navigazione dell'editor (vedi sotto). -
Nessuna prospettiva 3D, shader, rotazione camera o
z_depthnegativo nell'MVP.
Camera di progetto vs zoom/pan dell'editor
Sono due concetti volutamente separati:
- Zoom/pan di navigazione (rotellina/trackpad, Space+drag, tasto centrale
del mouse sul
CanvasView): serve solo per lavorare comodamente sul canvas. Non è mai salvato nel progetto e non tocca il modello di dominio — chiama soloQGraphicsView.scale()/scrollbar. - Camera di progetto (
Frame.camera, editabile dall'Inspector): un valore di dominio, salvato per frame, che sposta/scala effettivamente la scena renderizzata (rig + layer) tramite la formula di parallasse sopra. È quello che determinerà cosa finisce nell'export finale (Milestone 4).
Una cornice tratteggiata bianca nel canvas segna sempre l'area di output 1920x1080 (o le dimensioni scena configurate): non si muove con la camera — è rig e layer che si spostano "sotto" di essa, simulando il movimento camera.
Come creare un layer
- Importa un PNG dal pannello Assets ("Import PNG…").
- Selezionalo nella lista.
- Scegli il tipo (Background / Midground / Foreground) dal menu a tendina:
imposta solo i valori iniziali di
z_index/z_depth(-100/3.0, -50/1.0, 100/0.0 rispettivamente) — modificabili liberamente dopo, dall'Inspector. - Clicca "Add Layer From Selected Asset": il layer viene centrato in scena, con pivot al centro dell'immagine, e aggiunto solo al frame corrente.
- Clicca sul layer nel canvas per selezionarlo e modificarne le proprietà dall'Inspector (posizione, rotazione, scala, pivot, z-index, z-depth, opacità, visibilità).
Playback ed export: pose della timeline vs frame video
Distinzione fondamentale, documentata in domain/playback.py:
- Una posa della timeline (
Project.frames[i]) NON equivale a un frame video (un PNG della sequenza esportata, o un frame dell'MP4 codificato). Ogni posa viene mantenuta perProject.exposureframe video consecutivi, aProject.fpsframe video al secondo — nessun tweening/ interpolazione tra pose in questa milestone: un frame video mostra sempre esattamente una posa, invariata, per tutta la sua finestra di esposizione. total_output_frames = len(project.frames) * project.exposure.- Esempio guida (verificato sia nei test automatici sia manualmente): 4 pose,
fps=24, exposure=3 → 12 frame video → durata 12/24 = 0.5s. La posa
i(0-indicizzata) occupa i frame video 1-indicizzati dai * exposure + 1a(i + 1) * exposure. domain/playback.pyespone tre funzioni pure testate:expanded_timeline_indices(frame_count, exposure) -> list[int],output_frame_count(frame_count, exposure) -> int,output_duration_seconds(frame_count, exposure, fps) -> float, piùvalidate_playback_settings(fps, exposure, scene_width, scene_height)che richiede tutti e quattro i valori interi positivi (usata sia al caricamento progetto inproject_io.py, sia prima di ogni export).- Il rendering di export (
services/export_renderer.py) legge soloProject/Frame/asset e produceQImage: non dipende mai daMainWindow,CanvasView, selezione o zoom dell'editor. Riusa la stessa matematica di posizionamento diRigRenderer/LayerRenderer(world_transforms+character_plane_matrixper i bone,layer_screen_matrixper i layer) e gli stessi item Qt "senza stato" (BoneItem/MissingAssetItem/LayerItem/MissingLayerItem), ma tramite una pipeline dedicata che non aggiunge mai overlay di selezione, cornice di output o griglia — l'export contiene esclusivamente il contenuto finale della scena. - Limitazioni esplicite di questa milestone: nessun tweening/interpolazione
fra pose, nessun audio, nessun canale alpha nell'MP4 (yuv420p/H.264 non lo
supporta: lo sfondo viene sempre reso opaco prima dell'encoding). La
sequenza PNG invece preserva la trasparenza se
scene_settings.background_colorè configurato con alpha ridotto.
Limiti espliciti della Milestone 6A
- Nessun tweening/interpolazione automatica tra pose (invariato — non è obiettivo di questa milestone).
- Nessuna inverse kinematics: solo forward kinematics, come dalla Milestone 2. Ruotare un braccio muove correttamente avambraccio/mano perché sono figli nella gerarchia, ma non esiste alcun solver IK (trascinare una mano non ricalcola automaticamente la rotazione del braccio).
- Nessun audio, nessuna modifica a packaging/firma/notarizzazione (invariati dalla Milestone 5B).
- "Crop Preview to Alpha Bounds" è semplificato a un ricentraggio del pivot sul bounding box alpha-visibile del PNG (metadato/offset del rig): non produce un vero e proprio ritaglio/rettangolo di crop persistente, né un nuovo file PNG.
- L'import di asset PNG (anche dall'interno del Rig Builder) non è tracciato da Undo/Redo, in continuità con il comportamento del pannello Assets dalla Milestone 1: annullare l'intera sessione del Builder con "Cancel" non rimuove eventuali asset importati durante la sessione (restano registrati nel progetto, semplicemente non referenziati da alcun rig se non salvati).
- "Edit Selected Character Rig" modifica solo la Rig Instance del frame corrente, mai propagata automaticamente ad altre pose dello stesso personaggio né al Rig Template di libreria (vedi sopra "Edit Selected Character Rig: strategia scelta"); sincronizzare più istanze richiede un'azione manuale esplicita ("Save as Template" + "Add Character From Library" sui frame da aggiornare).
- "Set Root" (re-root della gerarchia) è disponibile solo dentro il Builder, non come azione rapida sul canvas principale.
- Nessuna libreria di categorie/tag ricercabile per i Rig Template oltre al
singolo campo testuale
category; nessuna anteprima miniaturizzata dei template nella UI di selezione (solo elenco per nome). - Il formato
.pivotcut-rig.jsonnon incorpora mai i PNG: riaprire un template esportato in un altro progetto richiede sempre di risolvere gli asset mancanti (mapping manuale o reimport), anche se i file PNG originali sono fisicamente disponibili in una cartella nota.
Limiti espliciti della Milestone 5B
Un solo rig per frame nell'interfaccia— limite rimosso in Milestone 6A (vedi "Character Rig Builder" sopra); nessun limite sul numero di layer per frame.- I layer sono selezionabili (per l'Inspector) ma non trascinabili nel canvas in questa milestone: l'editing avviene tramite l'Inspector.
- Nessun tweening/interpolazione automatica tra pose: ogni frame video mostra esattamente una posa, invariata, per tutta la sua esposizione.
- Nessun audio nell'export MP4.
- Nessun canale alpha nell'MP4 (limite di yuv420p/H.264): lo sfondo viene sempre reso opaco prima dell'encoding. La sequenza PNG preserva invece la trasparenza configurata.
- L'export gira sul thread principale con un
QProgressDialogche processa gli eventi Qt tra un frame e l'altro (nessunQThread, per evitare di creareQPixmap/QGraphicsScene/QPainterfuori dal thread GUI, non supportato da Qt); l'annullamento è garantito prima e durante il rendering PNG e prima della fase di encoding FFmpeg, non a metà di una chiamataffmpeggià avviata (blocco tipicamente breve per animazioni di questa scala). - Trascinare una miniatura durante playback/export non è impedito
visivamente (il gesto di drag può iniziare), ma il drop viene sempre
ignorato in modo sicuro — nessuna mutazione del modello può avvenire
mentre
is_playing/is_exportingè vero; è solo un piccolo limite di UX, non di correttezza/sicurezza. - Nessuna selezione multipla, nessun handle grafico di trasformazione, nessuna inverse kinematics, nessuno snapping.
- Nessuna prospettiva 3D, blur, shader o rotazione della camera.
- Gli asset PNG non vengono copiati fisicamente nella cartella del progetto:
solo
source_path/relative_pathsono tracciati. - Copia/incolla pose tra frame non è ancora implementato (previsto ma non MVP, come da requisiti).
Framenon ha un campo "revision": la cache miniature lo tiene deliberatamente fuori dal dominio (vedi "Miniature timeline" sopra), quindi una mutazione del modello che bypassa il sistema di comandi (possibile solo da codice, non dall'interfaccia) non invaliderebbe automaticamente la cache — ogni percorso UI reale passa sempre da un comando.- Il bundle
.appprodotto in questa milestone è locale e non firmato: nessuna firma Apple Developer, nessuna notarizzazione, nessun DMG — al primo avvio Gatekeeper richiede click destro -> Apri (vedi "Build macOS" sopra). Non è pensato per la distribuzione ad altri utenti. - Target di build: solo arm64 nativo (Apple Silicon). Questo NON è un binario universal2: non gira nativamente su Mac Intel (né lo dichiara).
- FFmpeg non è mai incluso nel bundle e non è richiesto per l'export PNG; l'export MP4 senza FFmpeg installato mostra lo stesso messaggio "non trovato" già presente da Milestone 4.
Changelog
Milestone 6A — Character Rig Builder stile Pivot
domain/rig.py:Boneesteso conattach_x/attach_y(coincidono per costruzione col pivot del figlio, senza toccarelocal_matrix/world_transforms, invariati da Milestone 2); nuovoRigTemplate(id/name/root_bone_id/bones +canvas_width/canvas_height/categoryopzionali); nuove funzioni pureset_attach_point/set_pivot_preserving_attach_point,create_child_bone,reparent_bone(con rifiuto di self-parent/cicli),remove_bone_subtree/remove_bone_reparent_children,duplicate_bone,set_root_bone(re-root per inversione del percorso),rig_template_from_rig/instantiate_rig_template(rigenerazione id rig/bone, remap genitore-figlio, riposizionamento della sola root);validate_rig/validate_rig_templatecondividono ora la stessa validazione strutturale.domain/models.py:Project.rig_templates: list[RigTemplate](la Rig Library), persistito in.pivotcut.jsoncon default retrocompatibile[]per progetti pre-6A.services/project_io.py: round-trip dirig_templates; bone di progetti legacy senzaattach_x/attach_yli derivano in modo semanticamente corretto dax/y/pivot_x/pivot_yesistenti, non da zero.- Nuovo
services/rig_template_io.py: formato esterno standalone.pivotcut-rig.json(format_version+ unRigTemplate+ riferimenti asset informativi id/nome/dimensioni/percorso — mai binari PNG);missing_asset_ids/remap_template_asset_idsper il flusso di mapping asset all'import. domain/commands.py: nuoviEditRigStructureCommand(un solo comando atomico per l'intera sessione del Builder, opera sulla singola Rig Instance del frame, mai sull'interoProject),SaveRigTemplateCommand(create-or-update nella libreria),DeleteRigTemplateCommand.- Nuovo
ui/rig_builder_dialog.py: dialogo a quattro colonne (asset importabili, gerarchia, preview con handle pivot/attach draggabili, inspector) che lavora su una copia locale di bone — Cancel non tocca maiProject/history/cache miniature, Save produce esattamente un comando. app/main_window.py: nuovo menu Characters (New Character Rig…, Quick Create Single-Part Rig, Add Character From Library…, Save Selected Rig as Template…, Import/Export Rig Template…, Edit Selected Character Rig…); rimosso il limite artificiale "un rig per frame" nell'interfaccia (l'architettura lo supportava già dalla Milestone 2).- Test: 27 nuovi test di dominio puro (
test_rig_template.py: coincidenza pivot/attach su almeno tre livelli di gerarchia, reparent/cicli, rimozione sottoalbero, reparent-to-grandparent,set_root_bone, conversione Rig<->RigTemplate, validazione); 13 nuovi test per l'I/O del template esterno (test_rig_template_io.py); nuovi test intest_history.pyperEditRigStructureCommand/SaveRigTemplateCommand/DeleteRigTemplateCommand(execute/undo/redo, una sola history entry per sessione Builder) e tre test di integrazione MainWindow (Cancel Builder non cambiaProject, Save Builder invalida la miniatura del frame, modificare la posa di un frame non muta il template salvato). Suite completa: 270/270 test passati. - Nessuna regressione sul comportamento Milestone 1-5B.
Milestone 5B — Packaging macOS locale (Apple Silicon)
- Nuovo
pivotcut.spec: configurazione PyInstaller esplicita per un bundle.appdirectory-based (mai--onefilesu macOS GUI),windowed/console=False,target_arch=None(usa l'architettura dell'interprete che esegue la build, mai dichiarata universal2),codesign_identity=None/nessunentitlements_file(build non firmata, la firma è Milestone 5C). Includesrc/pivotcut/resources/come data files con lo stesso layout relativo sia in sorgente sia nel bundle. Nessun hidden import extra necessario: l'hook PySide6 dipyinstaller-hooks-contribraccoglie già i plugin Qt richiesti (QtWidgets/QtGui/QtCoresoltanto), e l'unico uso di Pillow in questo progetto (Image.open/Image.savesu PNG) è coperto dal supporto PNG nativo diPIL.Image. - Nuovo
src/pivotcut/runtime_paths.py: unico punto del codice che ispezionasys.frozen/sys._MEIPASS, conpackage_root()/resource_path()che risolvono allo stesso identico layout relativo sia da sorgente sia da bundle frozen. Non ancora usato da nessun modulo (nessuna risorsa reale inresources/finora), preparato per quando arriverà un'icona o un template. - Nuovo
scripts/build_macos.sh: verifica macOS, verifica interprete arm64 nativo, verifica PyInstaller installato, pulizia sicura dibuild//dist/(conferma interattiva o--clean), esegue l'intera suitepyteste interrompe la build se un test fallisce, invoca PyInstaller, verifica chedist/PivotCut.appesista davvero. Nuovoscripts/clean_build.shper un cleanup indipendente. pyproject.toml/requirements.txt: aggiuntopyinstaller(+ le sue dipendenze direttealtgraph/macholib/setuptools/pyinstaller-hooks-contrib) come dipendenza di sviluppo..gitignore: ignora artefatti di packaging (*.app,*.dmg,*.spec.backup) senza toccarepivotcut.spec, che resta versionato.- Nessuna modifica al comportamento di Milestone 1-5A:
pytestcontinua a riportare 217/217 test passati. - Verifica manuale: build eseguita con
./scripts/build_macos.sh,dist/PivotCut.appverificato esistente con eseguibile interno all'interno diContents/MacOS/, avvio verificato sia conopensia eseguendo direttamente il binario interno (vedi report finale per i dettagli esatti e l'esito).
Milestone 5A — Timeline con miniature, drag-and-drop e Undo/Redo
Milestone 5A — Timeline con miniature, drag-and-drop e Undo/Redo
- Nuovo
domain/commands.py(senza Qt):CommandProtocol,UndoRedoStack(execute/undo/redo/can_undo/can_redo/clear,DEFAULT_MAX_HISTORY = 100), e tutti i comandi richiesti —NewFrameCommand/DeleteFrameCommand(avvolgonodomain.timelinecon cattura pigra al primoapply()per un redo fedele),MoveFrameCommand(Move Left/Right e drag-and-drop condividono la stessa funzione genericadomain.timeline.move_frame),CreateRigCommand/AddLayerCommand,TransformBoneCommand/EditLayerCommand/EditCameraCommand(snapshot mirato del solo oggetto modificato, mai dell'interoProject). domain/timeline.py:move_current_frame_left/rightora delegano al nuovomove_frame(project, from_index, to_index)generalizzato — stessi identici risultati per lo spostamento di una posizione (verificato), più supporto per spostamenti arbitrari (drag-and-drop).- Nuovo
services/export_renderer.render_thumbnail(): riusarender_frame_to_qimage(), scala preservando l'aspect ratio con letterbox/ pillarbox su tela trasparente. Nuovoservices/thumbnail_cache.py:ThumbnailCachecon chiave(frame_id, revision, size, background_mode),bump()/bump_all()per invalidazione mirata o totale — nessun timer di debounce dedicato: il sistema di comandi (un comando per gesto di editing completato) è già il meccanismo di debounce. ui/timeline_widget.pyriscritto:FrameThumbnailora mostra un'immagine renderizzata reale (non più placeholder testuale) e gestisce drag-and-drop di reorder viaQDrag/QMimeDatacon l'id stabile del frame come payload (mai l'indice), indicatore visivo del lato di inserimento, auto-scroll verso il frame corrente. New/Delete/Move/Reorder ora costruisconoCommanded eseguono tramite uncommand_executoriniettato daMainWindowinvece di mutareProjectdirettamente.ui/canvas_view.py: il drag di un bone continua a mutare live per il feedback visivo, ma emettebone_transform_committeduna sola volta al rilascio del mouse (se il valore è davvero cambiato), non per ogni pixel di movimento.ui/inspector_panel.py: nuova baseline "before" catturata ad ogni cambio di selezione/frame, commit (layer_edit_committed/camera_edit_committed) sueditingFinished/toggle/Reset Camera, con confronto per struct-equality per evitare comandi vuoti se il valore torna quello di partenza.app/main_window.py:UndoRedoStack+ThumbnailCachecondivisi,_execute_command/_undo/_redocentralizzano invalidazione cache, dirty flag e resync UI; menu Edit + toolbar con Undo/Redo (Cmd+Z/Cmd+Shift+Z, convenzioni Qt standard) con testo dinamico ("Undo Move Bone"); titolo finestra con*se_is_dirty, azzerato da Save/Save As, ripristinato pulito da New/Open; prompt Save/Discard/Cancel su New/Open/chiusura finestra con modifiche non salvate (closeEventoverride); guardia_can_mutate_model()(not is_exporting and not is_playing) condivisa da playback ed export, con_set_mutation_ui_enabled()che disabilita timeline/inspector/asset panel/toolbar in entrambi i casi.- 217 test automatici (
pytest): 166 Milestone 1-4 invariati, 32 nuovi test su comandi/UndoRedoStackincluso il caso non-frame-selezionato durante un reorder (test_history.py), 8 nuovi test su rendering miniature/cache/invalidazione (test_thumbnail_cache.py), 11 nuovi test sumove_framegeneralizzato incluso il round-trip di salvataggio dopo un riordino (test_timeline.py/test_project_io.py). - Verifica manuale completa (script Python con
QApplicationoffscreen, vedi report finale): 3 pose con rig/layer/camera distinti → miniature verificate visivamente diverse (confronto byte-per-byte dei pixel renderizzati) → drag-and-drop reorder + Undo/Redo dell'ordine (incluso il caso in cui il frame trascinato non era quello selezionato) → modifica layer + bone drag, poi Undo/Redo di entrambi → duplica/cancella frame, poi Undo/Redo → Save azzera*, modifica successiva lo ripristina → New/Open/Close con modifiche non salvate mostrano il prompt Save/Discard/Cancel → export PNG conis_exporting=Trueverificato bloccare New Frame/Undo sia a livello di modello sia di widget disabilitati.
Milestone 4 — Playback, export PNG e MP4
- Nuovo
domain/playback.py:expanded_timeline_indices,output_frame_count,output_duration_seconds,validate_playback_settings— matematica pura, senza Qt, con l'esempio 4 pose/exposure 3/fps 24 → 12 frame/0.5s testato.services/project_io.pyora validafps > 0/exposure > 0/scene_settings.width > 0/height > 0al caricamento. - Nuovo
services/export_renderer.py: rendering headlessrender_frame_to_qimage/save_png_sequence, con una pipeline dedicata (_build_offscreen_scene) che riusa la matematica e gli item Qt "senza stato" diRigRenderer/LayerRenderersenza mai dipendere daCanvasView/MainWindow/selezione, e senza mai includere overlay di selezione, cornice di output o griglia nell'immagine esportata. Asset mancanti producono lo stesso placeholder tratteggiato dell'editor, mai un crash. - Nuovo
services/ffmpeg_export.py: rilevamento FFmpeg (PATH, poi/opt/homebrew/bin/ffmpeg), invocazione viasubprocess.run(nessuna shell) con il comandoffmpeg -y -framerate {fps} -i frame_%06d.png -c:v libx264 -pix_fmt yuv420p -movflags +faststart -crf 18 {output}, rendering in unatempfile.TemporaryDirectorysempre ripulita (anche in caso di errore/annullamento), verifica che il file finale esista e non sia vuoto prima di dichiarare successo. - Nuovo
services/playback_controller.py:PlaybackControllerbasato suQTimer, stato esplicitois_playing, intervallo1000/fpsms, tiene ogni posa perexposuretick prima di avanzare, si ferma all'ultimo frame (MVP) oppure ricomincia dal primo se Loop è attivo. Non muta mai rig/layer/camera: scrive solocurrent_frame_index, tramite lo stessodomain.timeline.select_frameusato dalla navigazione manuale. ui/canvas_view.py: distingue Space+drag (pan, non attiva/disattiva il playback) da Space press/release senza drag (emetteplayback_requested); nuovoset_playback_active()blocca selezione/drag di bone e layer durante la riproduzione senza nascondere il canvas.ui/timeline_widget.py: nuovoset_playback_active()blocca navigazione/New/Delete/Move/fps/exposure durante la riproduzione (guardia anche a livello di singolo metodo, non solo sui pulsanti, così gli shortcut da tastiera restano coerenti).app/main_window.py:PlaybackControllercollegato a toolbar Play/Stop- Loop; un
eventFiltera livello applicazione rendeSpaceun toggle Play/Stop globale, escludendoQLineEdit/QSpinBox/QDoubleSpinBox/QComboBox(che continuano a ricevere lo spazio come testo/per aprire il menu) e il canvas (che gestisce da sé la distinzione pan/toggle). Nuove azioni "Export PNG Sequence…"/"Export MP4…" conQFileDialog, conferma prima di sovrascrivere file/cartelle esistenti,QProgressDialogcon fase/percentuale/Cancel per entrambi i flussi di export (rendering sul thread principale con eventi processati daQProgressDialog.setValue(), mai unQThread— vedi limitazioni). Status bar estesa con "Pose X/N", "Video frame Y/totale", FPS, Exposure, durata stimata.
- Loop; un
- 166 test automatici (
pytest): 105 Milestone 1/2/3 invariati, 22 nuovi test sudomain/playback.py(test_playback.py), 26 nuovi test sul rendering offscreen incluso il caso di asset mancante e l'esclusione degli overlay editor (test_export_renderer.py), 13 nuovi test suffmpeg_export.pycon FFmpeg completamente mockato viaunittest.mock.patchsusubprocess.run/shutil.which(test_ffmpeg_export.py) — nessuno richiede FFmpeg realmente installato. - Verifica manuale completa (script Python con
QApplicationoffscreen, vedi report finale): rig + 2 layer az_depthdiversi → 3 pose → fps=24/exposure=3 → playback che mantiene ogni posa esattamente 3 tick ([0,0,0,1,1,1,2,2,2]) e si ferma automaticamente all'ultima posa → Loop verificato separatamente ([0,0,1,1,2,2,0,0,1,1]con exposure=2) → export PNG sequence: 3 pose × exposure 3 = 9 PNG scritti e non vuoti → contenuto della scena canvas invariato (stesso numero di item) prima e dopo l'export → toggle Space globale verificato per 4 casi (widget generico,QSpinBoxescluso, canvas senza drag, canvas con drag) → FFmpeg non disponibile su questa macchina di sviluppo, quindi l'export MP4 reale non è stato eseguito qui (il percorso "FFmpeg non trovato" con messaggio chiaro e suggerimentobrew install ffmpegè invece verificato, sia manualmente sia nei test automatici mockati).
Milestone 3 — Layer, camera e 2.5D
- Nuovo
domain/layer.py(Layer,validate_layer/validate_layers,create_layer_from_asset) edomain/camera.py(Camera,parallax_factor,screen_position,zoom_matrix,layer_screen_matrix,character_plane_matrix) — matematica pura, senza Qt, con esempi z_depth=0/1/3 documentati e testati. Frameesteso concamera: Cameraelayers: list[Layer]; "New Frame" fa deep copy completa anche di camera e layer (id layer invariati tra frame, come rig/bone).services/project_io.py: serializzazione/deserializzazione di camera e layer con compatibilità totale con i file Milestone 1 e 2, validazione al caricamento (validate_camera,validate_layers).- Nuova UI:
ui/inspector_panel.py(campi layer/camera, Reset Camera),ui/graphics_items.pyesteso conLayerItem/MissingLayerItem/LayerRenderer,ui/canvas_view.pyesteso con selezione layer (mutua esclusione con la selezione bone), cornice output 1920x1080, e drag dei bone corretto per tenere conto dicamera.zoom,ui/asset_panel.pycon "Add Layer From Selected Asset" + selettore tipo. - Il rig resta sul "piano personaggi" (z_depth implicito 0): riceve lo stesso
offset camera dei layer con z_depth=0, calcolato in
domain.camerae composto sopra la trasformazione FK pura (invariata da Milestone 2). - 105 test automatici (
pytest): 95 Milestone 1/2 invariati, 38 nuovi test su parallasse/camera/layer (test_parallax.py), più nuovi test su deep copy layer/camera nella timeline e round-trip/compatibilità/validazione della persistenza. - Verifica manuale completa: import 2+ PNG → 2 layer con z_depth diversi → modifica camera x/y dall'Inspector → conferma numerica che il layer più lontano si sposta meno → modifica z-index → modifica layer dall'Inspector → Save/Open con rig+layer+camera → New Frame con isolamento del frame precedente; più verifica dei meccanismi UI (selezione mutuamente esclusiva bone/layer via evento mouse reale, correttezza del drag con camera.zoom attivo, placeholder per asset mancante senza crash).
Milestone 2 — PNG e rig
- Nuovo
domain/assets.py(dataclassAssetpura) edomain/rig.py(Bone,Rig,Matrix2D,validate_rig,world_transforms,create_rig_from_asset) — matematica delle trasformazioni 2D in Python puro, senza Qt, con cycle detection e validazione strutturale/semantica. Frameesteso conrigs: list[Rig](deep copy su "New Frame", id invariati);Projectesteso conassets: list[Asset].services/asset_manager.py: validazione PNG con Pillow, deduplica per path risolto, derivazione/refresh direlative_path, pickerQFileDialog.services/project_io.py: serializzazione/deserializzazione di asset e rig con compatibilità totale con i file Milestone 1 (assets/rigsassenti → liste vuote), validazione rig al caricamento.- Nuova UI:
ui/asset_panel.py(pannello Assets + "Create Rig From Selected Asset"),ui/graphics_items.py(RigRenderer, rendering "flat" daworld_transforms, placeholder per asset mancanti),ui/canvas_view.pyesteso con selezione/drag/rotazione di un bone. - 57 test automatici (
pytest): 25 Milestone 1 invariati, 25 nuovi test su composizione delle trasformazioni/validazione rig, 7 nuovi test su deep copy dei rig nella timeline e round-trip/compatibilità della persistenza. - Verifica manuale completa: import PNG → create rig → save → reopen → render + selezione → new frame → drag/rotate → frame precedente invariato.
Milestone 1 — Base funzionante
- Setup progetto (
pyproject.toml,requirements.txt, strutturasrc/). - Modello di dominio (
SceneSettings,Frame,Project) come dataclass pure, indipendenti da Qt. - Operazioni timeline pure e testate: nuovo frame duplicato (con suffisso label incrementale "(copy)"/"(copy 2)"), cancellazione con guardia sull'ultimo frame, riordino, navigazione con selezione che segue le regole richieste.
- Persistenza JSON versionata con eccezioni tipizzate (
InvalidProjectFileError,UnsupportedProjectFormatError,IncompleteProjectDataError). - MainWindow PySide6 con canvas, timeline, toolbar/menu New/Open/Save funzionanti, Play/Export disabilitati.
- 25 test automatici (
pytest) su dominio e persistenza; verifica manuale della GUI (creazione/cancellazione/navigazione frame, round-trip salvataggio/apertura, zoom/pan canvas).