diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67ac0a6..eba56f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install --index-url https://download.pytorch.org/whl/cpu torch - python -m pip install "jsonschema>=4.26.0" + python -m pip install "jsonschema>=4.26.0" "numpy>=1.26.0" "Pillow>=10.0.0" "safetensors>=0.7.0" - name: Run unit tests run: python -m unittest discover -s tests -p "test_*.py" -v diff --git a/CHANGELOG.md b/CHANGELOG.md index c722783..b7a2e29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,15 @@ All notable changes to DiffusionGemma Director are recorded here. The format fol ### Added -- Added a documented real-world 30-second beverage-ad field test covering GPT Image 2 campaign references, two-lane MiniMax H3 execution, instrumental upload routing, identity retention, relay comparison, delivered masters, and a prioritized commercial-production hardening backlog. -- Added 17 exported audio, timing, and production-planning nodes, bringing the registered ComfyUI surface to 33 nodes. +- Added a documented real-world 30-second beverage-ad field test covering GPT Image 2 campaign references, two-lane MiniMax H3 execution, instrumental upload routing, identity retention, relay comparison, rendered review masters, and a prioritized commercial-production hardening backlog. +- Added a separate twenty-two-node Advertisement contract, shared-control, audio, Director-transport repair, planning, relay, runtime-memory, finishing, and QA surface, bringing the registered ComfyUI total to 55 nodes while leaving the established 33-node surface intact. +- Added versioned Advertisement schemas for campaign fields and claims, ordered performer/product references, soundtrack policy, locked audio, H3 planning, retained-tail relay, final mix, delivery status, adaptations, and media QA. +- Added a native MiniMax Music 3 Advertisement route with structured commercial timing, five-second candidate headroom, the locally verified pruned INT8/ConvRot text-encoder path, and lazy MiniMax Music 3 / Upload song / Legacy ACE-Step source selection. +- Added `examples/16_minimax_h3_ref2va_advertisement_music3_v1.json` as a separate user-editable Advertisement workflow with a fresh graph identity, flattened independent nodes, and immutable V6 lineage guards, plus a governed API-format companion fixture. +- Added deterministic exact-copy end-card rendering, exact 30-second master finishing, real 15-second and 6-second frame/audio cutdowns, and explicit status-only reporting for unrendered 1:1 and 16:9 adaptations. +- Added an Advertisement-owned model-memory barrier so Music 3 resources can be released before Director and H3 without inheriting unrelated cleanup-node packs. +- Added end-to-end runtime evidence for the exact release API graph on isolated ComfyUI `0.33.1`: two 15-second H3 lanes, persistent Picture 4 relay, every diagnostic root, a 720-frame/30-second master, and real 360-frame/15-second and 144-frame/6-second cutdowns with exact matching audio clocks. Visual spot review found stable performer/product continuity; formal QA remains blocked pending evidence-backed identity, copy, and audio-sync approval. +- Added 17 exported audio, timing, and production-planning nodes, bringing the established non-Advertisement ComfyUI surface to 33 nodes. - Added decoded-song audition and locking with measured tempo, onset, tonal, clipping, silence, artifact, vocal-activity, recovery-window, excerpt, and whole-song diagnostics. Candidate and waveform-hash locks now fail closed instead of silently selecting different music. - Added a custom-song path with a lazy upload node and independent **Generate with ACE-Step / Upload song** router. Uploaded songs reuse the established selector, excerpt, hash-lock, audio-guide, planner, and pristine final-mux path without waking dormant ACE candidate lanes. - Added a single **Natural / audio-led sync**, **Dance / music sync**, or **Lyrics + lip sync** performance control shared by Director guidance, lyric selection, timed analysis, and H3 planning. @@ -23,6 +30,12 @@ All notable changes to DiffusionGemma Director are recorded here. The format fol ### Changed +- Centralized Advertisement v1's 30-second duration, 9:16 aspect, native-shot count, Natural/Dance performance, Ref2VA mode, H3 audio/dialogue policies, excerpt start, generation model, 15-second lane ceiling, splitter aspect, and governed 30/15/6 deliverables in one shared control. Unsupported reference layouts, seam modes, Lyrics-without-evidence, and VO-without-input choices are hidden from the shipped UI. +- Labeled automatically saved Advertisement media as review drafts. QA approval remains separate, requires evidence for every pass, and never treats queue completion as delivery approval. +- Assigned Advertisement references by role: Picture 1 performer hero, Picture 2 same-performer contact sheet, Picture 3 independent product/package sheet, and Picture 4 persistent retained-tail relay for continuity only. Product and performer authority remain separate across every H3 lane. +- Split Advertisement audio into a soundtrack-only H3 motion guide and an exact final delivery mix. Optional non-diegetic VO, ducking, mix hashes, and clipping diagnostics affect only the final mix, never H3 conditioning, and cannot become an accidental lip-sync instruction. +- Made Advertisement soundtrack QC content-aware: instrumental generations do not require a vocal proxy, vocal generations do, and uploaded or legacy sources retain their meaningful technical-integrity and locking policies. +- Added explicit `pass`, `fail`, and `not_measured` Advertisement media-QA states plus an unremovable host baseline for product identity, performer identity, copy legibility, audio sync, and technical integrity. Queue completion, widget edits, and missing evidence can no longer be presented as identity, typography, product, or sync approval. - Separated MiniMax-H3 native `[Shot N]` blocks from duration-bounded generation lanes. A single H3 invocation may now retain several native shots, and up to four planned lanes can cover a 60-second project when the configured 15-second lane ceiling and capacity constraints permit it. - Reworked H3 lane boundaries to prefer native cuts with measured recovery, then native or mixed evidence, with deterministic balanced seams as a safe fallback. Non-native seams carry the active source shot and rebase timestamped cues rather than changing the master timeline. - Made Project Master duration, aspect, soundtrack hash, excerpt, lane ceiling, and deliverables authoritative while leaving native shot choreography to the Director and H3 target profile. @@ -35,6 +48,16 @@ All notable changes to DiffusionGemma Director are recorded here. The format fol ### Fixed +- Preserved the host Director packet's grounding, runtime, cache, and reference-authority evidence during bounded Advertisement transport recovery. Raw output is considered only after an explicit host JSON-parse failure with successful plain salvage and no true template fallback; duplicate keys, non-finite values, malformed Unicode, metadata disagreement, excess repairs, and authority drift remain blocked. +- Removed invalid `model` inputs from all four native `MiniMaxH3ReferenceToVideo` conditioning nodes while retaining model links on the scheduler and guiders. The UI builder now rejects every connected API input that is absent from live ComfyUI node metadata, preventing the same unexpected-keyword failure class from reaching execution. +- Corrected the saved Grounding Guard title to state its intentional `off` baseline instead of implying audit mode; the independent Advertisement reference and generation contracts remain active. +- Corrected the four H3 lane labels to distinguish persistent Picture 3 product authority from Picture 4 relay continuity, corrected the scheduler label to its actual seven-step setting, made the Director checkpoint path application-relative, and made the API manifest enumerate only its real source branch, all required Director/H3/Music 3 assets, and real reference hashes. +- Kept a runtime-observed Music 3 tempo miss honest: the technically passing candidate's `87.890625` BPM signal estimate versus requested `122` remains a visible advisory, while H3 continues to follow the selected waveform and exact-tempo campaigns can use a locked upload. +- Fixed the MiniMax Music 3 selection boundary where time-to-sample rounding could leave an otherwise valid excerpt one sample short. The mixer records and right-pads only deficits up to one millisecond; material shortages still fail. +- Fixed Advertisement control-link combo and ACE integer-BPM typing, selected-source BPM provenance and mismatch evidence, exact brand-case rendering, canonical report/tensor hash verification, frame-clock and pixel-aspect alignment, retained-tail cutdown selection, and false LoadImage missing-input warnings in the generated UI workflow. +- Canonicalized only unambiguous `Picture N` citations inside governed Advertisement subject-definition rows before strict validation, eliminating meaningless Director retries without relaxing free-form prompt validation. +- Prevented Advertisement product references from being parsed as a second performer identity, and prevented the retained-tail Picture 4 relay from becoming a new source identity or product authority. +- Prevented added voice-over from entering the H3 motion guide, generated instrumental candidates from failing for lack of vocal evidence, and incomplete visual/audio QA from becoming a false pass. - Repaired MiniMax-H3 Ref2VA packets deterministically when model output contains malformed subject definitions, reference usage or retention rows, misplaced or short detailed descriptions, invalid cut timestamps, or omitted per-shot camera direction. - Removed the one-shot Project Master mismatch that incorrectly compared H3 native shot blocks with the number of generation invocations. - Prevented generic camera-safety text from making H3 output creatively anemic while retaining fail-closed validation for genuinely malformed camera contracts. @@ -47,7 +70,7 @@ All notable changes to DiffusionGemma Director are recorded here. The format fol ### Repository hygiene - Excluded generated runtime videos and identity/reference frames from Git and ComfyUI package archives. These files remain local and are not required by the source or tests. -- Kept migration tools and offline benchmark manifests in Git while excluding them from the install archive, and updated package metadata and the release checklist for the 33-node surface. +- Kept migration tools and offline benchmark manifests in Git while excluding them from the install archive, and updated package metadata and the release checklist for the 55-node surface. - Made the legacy external V5 Desktop/ComfyUI byte-equality audit explicitly opt-in; the default release suite now relies on the checked-in V6 workflow's hermetic hash and contract validation. ## [0.2.0] - 2026-08-07 diff --git a/GITHUB_RELEASE_CHECKLIST.md b/GITHUB_RELEASE_CHECKLIST.md index 5d10f03..54418cc 100644 --- a/GITHUB_RELEASE_CHECKLIST.md +++ b/GITHUB_RELEASE_CHECKLIST.md @@ -4,15 +4,20 @@ Use this checklist from the standalone `ComfyUI-DiffusionGemmaPromptBuilder` rep ## Product Boundary -- Keep the exported surface at thirty-three node ids: +- Keep the exported surface at fifty-five node ids: the established thirty-three-node surface remains unchanged, and the separate Advertisement surface adds exactly twenty-two ids: - eleven stable Director nodes: Model Loader, Context Hub, H3 Reference Context, Grounding Guard Settings, CoT Generator, JSON Splitter, Generation Gate, Branch Generation Gate, and the LTX-2.5, MiniMax-H3, and Ideogram 4 target profiles - one deprecated universal Target Profile retained for saved-workflow compatibility - two optional reference-preparation helpers: Reference Prep and H3 Reference Pair Prep - two experimental SplatStage planners - eleven audio/music-video controls: ten decoded-audio, source-routing, ACE, performance, and production-concept nodes plus Timed Lyrics Analyzer - six production-planning controls: Project Master, Audio-Aware H3 Multi-Shot Planner, H3 Relay Reference Gate, Multi-Format Delivery Planner, H3 Shot Seed Fanout, and H3 Shot Assembler + - six Advertisement contract/settings nodes: Workflow Controls, Campaign Contract, Reference Contract, Reference Asset Prep, Soundtrack Contract, and Music3 Prompt Adapter + - three Advertisement audio nodes: Soundtrack Source, Audio Audition & Lock, and Motion Guide / Final Mix + - six Advertisement planning/relay nodes: Director Packet Repair, Master Contract, Planning Defaults, Multi-Shot Planner, Relay Artifact, and Relay Gate + - six Advertisement finishing/QA nodes: Master Assembler, End Card Renderer, Master Finisher, Adaptation Status, Cutdown Renderer, and Media QA Gate + - one Advertisement runtime node: Memory Barrier - Keep the two SplatStage planners visibly experimental. Their mapping ids stay compatible, their display names end in `(Experimental)`, and their category is `prompt/diffusiongemma/experimental-motion-planning`. -- Confirm `NODE_CLASS_MAPPINGS` and `NODE_DISPLAY_NAME_MAPPINGS` contain the same thirty-three ids. +- Confirm `NODE_CLASS_MAPPINGS` and `NODE_DISPLAY_NAME_MAPPINGS` contain the same fifty-five ids, with no collision between the established thirty-three and the separate twenty-two Advertisement ids. - Treat `ltx_reframe.py` and the reframe workflows as unexported prototypes. Do not describe them as installed nodes unless the package entry point and frontend are deliberately restored together. - Do not bundle model weights or generated tensor dumps. The NVFP4 bridge remains source code in `nodes.py`. @@ -38,7 +43,7 @@ node --check web/js/model_loader.js node --test tests/*.mjs ``` -- The unit suite must validate the exact thirty-three-node surface, the dedicated target-node boundaries, the Director/optional/experimental/audio/production-planning category split, both bundled SplatStage schemas, and retained reframe helpers. +- The unit suite must validate the exact fifty-five-node surface, preserve the established thirty-three-node boundary, validate the separate twenty-two-node Advertisement contract/audio/planning/finishing/runtime split, the dedicated target-node boundaries, both bundled SplatStage schemas, and retained reframe helpers. - The readiness/schema smoke must finish with `Prompt Builder readiness/schema smoke passed`. - The JavaScript suite must exercise the shipped `model_loader.js`. No test may import the retired `ltx_reframe_layout.js`. - The default release suite validates checked-in artifacts only. Set `DG_VERIFY_DEPLOYED_V5_ARTIFACTS=1` solely when intentionally auditing the superseded, mutable V5 Desktop and ComfyUI copies; divergence there is deployment evidence to reconcile, not a hermetic release gate. @@ -52,15 +57,18 @@ node --test tests/*.mjs python proof_gates.py --model-path C:\ComfyUI\models\LLM\diffusiongemma-26B-A4B-it-NVFP4 ``` -2. Fresh-install the repository in a clean ComfyUI `0.30.1` custom-nodes folder and confirm all thirty-three nodes appear: eleven stable Director nodes, one legacy compatibility target, two optional reference-preparation nodes, two SplatStage planners only under the experimental motion-planning category, eleven audio/music-video controls, and six production-planning controls. +2. Fresh-install the repository in a clean ComfyUI `0.30.1` custom-nodes folder and confirm the established thirty-three-node surface still imports without regression. On ComfyUI `>=0.33.1`, confirm all fifty-five nodes appear: the established thirty-three plus the separate twenty-two Advertisement nodes under `prompt/diffusiongemma/advertising`. 3. Copy `examples/assets/character_motion_transfer` to `ComfyUI/input/character_motion_transfer` and load `examples/07_ltx23_character_motion_transfer.json`. 4. Load the MiniMax H3 T2VA and Ref2VA examples and confirm the Generation Gate blocks invalid or empty prompt packets. 5. Load `examples/15_minimax_h3_ref2va_music_video_v6.json`, replace both unbundled identity-image choices, and verify the default ACE source remains lazy with a blank upload. Exercise Upload song, Natural, Dance, Lyrics, and optional relay as separate branch tests; do not infer a successful media render from graph validation alone. -6. Treat longer experimental SplatStage and reframe workflows as separate opt-in validation, not as evidence for the stable Director compatibility promise. +6. On ComfyUI `>=0.33.1`, load `examples/16_minimax_h3_ref2va_advertisement_music3_v1.json`, select Picture 1 performer hero, Picture 2 same-performer sheet, and Picture 3 independent product sheet, and confirm the portable Director checkpoint path resolves locally. Verify MiniMax Music 3 remains the default and Upload song / Legacy ACE-Step stay lazy UI fallbacks. +7. Run the separate API fixture through the full two-lane H3 path. Require actual 30-second, 15-second, and 6-second review-draft videos, an exact 30-second soundtrack, all Director/QC/QA diagnostic roots, exact frame/audio clocks, and a persisted Picture 4 retained-tail relay. Queue completion alone is not proof. +8. Keep delivery blocked while performer identity, product identity, copy legibility, or audio sync remains `not_measured`. Review the media and record evidence before changing any QA state to `pass`; never treat deterministic end-card copy as proof that generated in-scene package text is exact. +9. Treat longer experimental SplatStage and reframe workflows as separate opt-in validation, not as evidence for the stable Director compatibility promise. ## Files Tracked on GitHub -- Package entry point and source: `__init__.py`, `nodes.py`, `audio_production_nodes.py`, `production_planning_nodes.py`, `timed_lyrics_nodes.py`, `ltx25_contract.py`, `ltx_reframe.py`, `proof_gates.py` +- Package entry point and source: `__init__.py`, `nodes.py`, `audio_production_nodes.py`, `production_planning_nodes.py`, `timed_lyrics_nodes.py`, `advertising_contract_nodes.py`, `advertising_audio_nodes.py`, `advertising_planning_nodes.py`, `advertising_finishing_nodes.py`, `advertising_runtime_nodes.py`, `ltx25_contract.py`, `ltx_reframe.py`, `proof_gates.py` - Metadata and policy: `README.md`, `LICENSE`, `requirements.txt`, `pyproject.toml`, `.gitignore`, `.comfyignore`, this checklist - Runtime contracts: `schemas/`, `web/`, `scripts/` - Verification: `tests/`, `.github/workflows/ci.yml` diff --git a/README.md b/README.md index 85a9170..b5894c2 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,13 @@ # ComfyUI DiffusionGemma Prompt Builder -DiffusionGemma Director provides a stable five-node prompt-authoring core for ComfyUI: choose one model-specific Target Profile node between Context Hub and the shared Director/validation path. Four stable Director companion nodes, two optional reference-preparation nodes, eleven audio/music-video production controls, and six production-planning controls extend that core. The package also exports two experimental SplatStage motion-planning nodes. DiffusionGemma writes and validates prompts or blueprints; it is not the downstream image or video sampler. +DiffusionGemma Director provides a stable five-node prompt-authoring core for ComfyUI: choose one model-specific Target Profile node between Context Hub and the shared Director/validation path. Four stable Director companion nodes, two optional reference-preparation nodes, eleven audio/music-video production controls, and six production-planning controls extend that core. The package also exports two experimental SplatStage motion-planning nodes. A separate twenty-two-node Advertisement surface adds typed campaign, reference, soundtrack, shared settings, transport repair, planning, relay, finishing, and QA contracts without changing the established workflows. DiffusionGemma writes and validates prompts or blueprints; it is not the downstream image or video sampler. -The primary stable workflow is the working LTX 2.3 character motion-transfer graph in `examples/07_ltx23_character_motion_transfer.json`. Additional Director examples cover native MiniMax H3 T2VA and Ref2VA paths. The accumulated synchronized music-video production graph is preserved in `examples/15_minimax_h3_ref2va_music_video_v6.json`; replace its two saved `LoadImage` choices with your own primary identity image and same-person contact sheet before running it. +The primary stable workflow is the working LTX 2.3 character motion-transfer graph in `examples/07_ltx23_character_motion_transfer.json`. Additional Director examples cover native MiniMax H3 T2VA and Ref2VA paths. The accumulated synchronized music-video production graph is preserved in `examples/15_minimax_h3_ref2va_music_video_v6.json`; replace its two saved `LoadImage` choices with your own primary identity image and same-person contact sheet before running it. Advertisement work is additive and isolated: it does not migrate, overwrite, or silently change that known-good V6 graph. ## Exported Nodes -Thirty-three nodes are registered in ComfyUI. Their stable, compatibility, optional, and experimental surfaces are intentionally separate. +Fifty-five nodes are registered in ComfyUI: the established thirty-three-node Director/music-video surface and the separate twenty-two-node Advertisement surface. Stable, compatibility, optional, experimental, and Advertisement responsibilities remain intentionally distinct. ### Stable Director Assistant @@ -86,7 +86,21 @@ These controls appear under `prompt/diffusiongemma/production-planning` and prov - `DiffusionGemmaH3ShotAssembler` lazily requests only the planned H3 lanes, trims every decoded segment at its retained cut, verifies the master clock and soundtrack duration, concatenates exact frames, and passes the pristine audio object through unchanged. - `DiffusionGemmaMultiFormatDeliveryPlanner` records requested reframes and cutdowns as plan-only deliverables; it never claims alternate media was rendered. -The Project Contract is authoritative for aspect, maximum H3 generation-segment length, and resulting lane count. Director and the MiniMax-H3 Target Profile remain authoritative for the unique native `[Shot N]` schedule inside those lanes. Missing ideal cut evidence is advisory rather than fatal; the plan exposes boundary-policy revision 2, selected strategy, evidence booleans, carry-in fragments, realized lane durations, and whether the non-binding preferred duration was actually realizable. Structural errors such as invalid clocks, duplicate/gapped/empty native shot blocks, out-of-range timestamps, stale audio locks, malformed identity manifests, impossible minimum/maximum limits, or more than four required lanes still fail closed. Missing, stale, weak, or malformed lyric timing also remains non-fatal: the planner records a warning and uses Natural/audio-led articulation. Written lyrics are never treated as a timing schedule. H3-generated audio is intentionally discarded; only the locked ACE excerpt is muxed into the assembled video. +### Advertisement Production System + +The Advertisement nodes appear under `prompt/diffusiongemma/advertising`. They form a separate, versioned contract stack so commercial fields and delivery rules do not leak into the generic Director or the known-good music-video V6 workflow. + +- Campaign and reference contracts type the brand, product, audience, objective, exact copy, substantiated claims, duration, aspect, end card, and deliverables. Reference preparation normalizes and hashes three user-selected image inputs; users do not paste hashes. Picture 1 is the performer hero, Picture 2 is a same-performer contact sheet, Picture 3 is an independent product/package contact sheet, and Picture 4 is a hash-verified, downscaled copy of the exact retained final frame from the previous H3 lane. Picture 4 is continuity evidence only and never becomes performer or product authority. Performer and product references are not merged into one identity sheet. +- `DiffusionGemmaAdvertisementWorkflowControls` is the saved workflow's one shared settings authority. Advertisement v1 intentionally locks the governed 30-second, 9:16 master and 30/15/6 deliverable contract; Ref2VA mode; H3 audio/dialogue policies; zero-second excerpt start; MiniMax H3 generation model; and 15-second lane ceiling. It shares one native-shot count with Director and the Advertisement planner, drives the splitter aspect, derives tail cutdown starts, and exposes only Natural or Dance performance. Unsupported reference layouts, non-relay seam modes, Lyrics performance without timed evidence, and separate VO without an input path are hidden from this v1 UI rather than offered as broken choices. +- The soundtrack contract supports `Instrumental`, `Vocal`, and `Auto` content policy and emits a structured MiniMax Music 3 caption plus separately tagged lyrics. The lazy source router exposes three origins: **MiniMax Music 3** by default, **Upload song**, and **Legacy ACE-Step**. An instrumental generated candidate does not need a vocal proxy; a vocal candidate does. All selected sources still pass technical integrity, excerpt, provenance, and waveform-hash locking. +- A 30-second campaign requests a 35-second MiniMax Music 3 candidate so the selector has five seconds of headroom; generated duration is capped at 60 seconds. The verified local route uses `minimax_music3_text_encoder_pruned_int8_convrot.safetensors` with the native MiniMax Music 3 text encoder, `minimax_music3_dit_fp16.safetensors`, and `minimax_music3_dav.safetensors`. +- The Advertisement mixer sends a soundtrack-only motion guide to H3. Its reusable node contract supports later non-diegetic voice-over with ducking, hashes, and clipping diagnostics, but the shipped v1 workflow locks VO to `None` until an upload/loader path is connected. A sub-millisecond source-window rounding deficit may be right-padded by at most one millisecond and is recorded; materially short audio still fails closed. +- The Advertisement planner preserves the Picture 1/2 performer authorities and Picture 3 product authority on every lane, while Picture 4 carries only the retained previous-lane tail. The finishing path rejects raster dimensions outside the model-compatible contracted-aspect tolerance, assembles an exact 30-second 9:16 master, replaces its governed tail with a deterministic exact-copy end card, and renders actual 15-second and 6-second frame/audio cutdowns. Requested 1:1 and 16:9 adaptations remain explicitly unrendered until a semantic reframe renderer is connected. +- Media QA reports `pass`, `fail`, or `not_measured` per check. Product identity, performer identity, copy legibility, audio sync, and technical integrity are a host-owned baseline that an edited widget cannot remove. A bare `pass` without evidence is downgraded to `not_measured`. The three automatic video saves are explicitly labeled and stored as review drafts; a completed ComfyUI queue is not delivery approval. + +The Advertisement-owned Director transport repair uses the host-final packet by default and preserves its grounding, runtime, and cache evidence. It may recover the raw model-authored H3 object only when the host explicitly reports JSON parsing failed, plain salvage succeeded, and no real template fallback occurred. Recovery is bounded to invalid in-string backslashes plus exact governed Subject-row delimiters completed from the SHA-locked reference contract; duplicate keys, non-finite values, malformed Unicode, excess repairs, extra prose, metadata disagreement, and reference-authority changes fail closed. The existing H3 validator remains authoritative for prompt structure. The Advertisement-owned memory barrier releases Music 3 resources before Director and H3 without requiring unrelated cleanup-node packs. Schemas under `schemas/advertisement_*.schema.json` version the campaign, references, soundtrack, planning, relay, mix, delivery, adaptation, and QA payloads. Assembly, end-card, and finish reports are hash- and tensor-verified by their host nodes but do not currently have standalone JSON Schema files. + +The Project Contract is authoritative for aspect, maximum H3 generation-segment length, and resulting lane count. Director and the MiniMax-H3 Target Profile remain authoritative for the unique native `[Shot N]` schedule inside those lanes. Missing ideal cut evidence is advisory rather than fatal; the plan exposes boundary-policy revision 2, selected strategy, evidence booleans, carry-in fragments, realized lane durations, and whether the non-binding preferred duration was actually realizable. Structural errors such as invalid clocks, duplicate/gapped/empty native shot blocks, out-of-range timestamps, stale audio locks, malformed identity manifests, impossible minimum/maximum limits, or more than four required lanes still fail closed. Missing, stale, weak, or malformed lyric timing also remains non-fatal: the planner records a warning and uses Natural/audio-led articulation. Written lyrics are never treated as a timing schedule. H3-generated audio is intentionally discarded; only the selected, hash-locked soundtrack excerpt is muxed into the assembled video. The former universal `DiffusionGemmaTargetProfile` remains registered and marked deprecated so existing saved workflows continue to load with their exact widget order. New workflows should use one of the three model-specific targets. Other compatibility and utility classes remain in source where exported nodes reuse their behavior internally. The `ltx_reframe.py` module and LTX reframe workflows are retained as unexported prototypes; the retired `ltx_reframe_layout.js` frontend is not part of this release. @@ -126,6 +140,39 @@ examples/assets/character_motion_transfer/diffusiongemma_ltx_character_demo.mp4 examples/assets/character_motion_transfer/diffusiongemma_ltx_character_demo.gif ``` +## Separate Advertisement Workflow + +The governed GlowBloom advertisement path is a separate MiniMax-H3 Ref2VA system rather than an edit to the music-video V6 graph. Its user-editable ComfyUI workflow is: + +```text +examples/16_minimax_h3_ref2va_advertisement_music3_v1.json +``` + +The corresponding API-format production fixture is a deliberately Music3-only validation graph: + +```text +examples/api/17_minimax_h3_ref2va_advertisement_music3_glowbloom_v1_api.json +``` + +The workflow has a fresh revision-1 graph identity and shares no mutable subgraph UUID with V6. It uses MiniMax Music 3 as its saved default, with Upload song and Legacy ACE-Step available through the lazy source router; the smaller API fixture contains only the default MiniMax Music 3 source and does not claim those UI-only fallbacks. It expects three user assets in the Picture 1 performer / Picture 2 same-performer sheet / Picture 3 product sheet order; Picture 4 is supplied only by the retained-tail relay between H3 lanes. Reference Asset Prep derives all three SHA-256 values from the selected images and keeps the product budget independent. `ADVERTISEMENT CONTROLS` is the one shared 30-second duration, native-shot, performance, and 30/15/6 delivery authority; duplicated consumer widgets are linked rather than independently editable. The saved Grounding Guard is explicitly labeled as an **OFF baseline**; the separate reference contract, governed packet repair, JSON splitter, and branch gate remain active and fail closed. + +The Director loader uses the portable path `models/LLM/diffusiongemma-26B-A4B-it-NVFP4`, relative to the ComfyUI application directory. If ComfyUI is launched from a different working directory or the checkpoint is stored elsewhere, edit that one **Load DiffusionGemma Director** field to the full local repo-folder path before queueing. + +For the 30-second preset, Music 3 generates a 35-second candidate and the selector locks one exact 30-second excerpt. H3 receives the music-only motion guide. The reusable mixer supports separate final-mix VO, but this v1 workflow keeps VO disabled until it has an actual input path. The finishing nodes render a deterministic three-second end card containing normalized, case-preserved governed copy and produce real tail-selected 15-second and 6-second cutdowns from the finished master. + +The exact release graph is runtime-proven end to end on an isolated ComfyUI `0.33.1` installation with the pruned INT8/ConvRot Music 3 text encoder and the listed H3 assets. The 99-node API graph completed both 15-second H3 lanes, persistent Picture 4 relay, deterministic finishing, every diagnostic root, and real 30-second/15-second/6-second review drafts. The master was exactly 720 frames at 480x864 and 24 fps with a 30.000-second stereo soundtrack; the cutdowns were exactly 360 and 144 frames with matching 15.000-second and 6.000-second audio. A sampled visual contact-sheet review found stable performer/wardrobe and recognizable product continuity plus a legible deterministic end card. These facts establish technical execution, not delivery approval: the saved QA status still blocks delivery until evidence-backed performer, product, copy, and audio-sync review is recorded. + +Known limitations: + +- MiniMax-H3 generation remains stochastic. Multiple references and retained-tail continuity reduce drift but cannot guarantee performer likeness, product geometry, packaging text, or demographic presentation. +- Exact brand, CTA, and legal copy is guaranteed only in the deterministic end card. Do not treat generated in-scene labels as reliable typography or a substantiated claim. +- The 15-second and 6-second outputs are real temporal cutdowns. Requested 1:1 and 16:9 adaptations are status-only until a semantic reframe renderer is connected. +- Visual identity, package consistency, copy legibility, and audio sync require measured or human evidence. The QA gate intentionally reports `not_measured` and keeps delivery unapproved when that evidence is absent. +- MiniMax Music 3 tempo remains generative. The runtime-proven candidate passed technical QC but the signal analyzer estimated `87.890625` BPM against the requested `122`, and recorded that mismatch as an advisory. H3 follows the selected waveform itself; use a locked upload or stricter candidate policy when an exact tempo is mandatory. +- Automatic saves are review drafts, not QA-gated delivery exports. A pass requires evidence for every required check; a bare pass is downgraded to `not_measured`. +- The combined editable workflow includes a real Legacy ACE fallback. ComfyUI may validate linked lazy loader/model choices before branch selection, so `acestep_v1.5_turbo.safetensors`, `qwen_0.6b_ace15.safetensors`, `qwen_4b_ace15.safetensors`, and `ace_1.5_vae.safetensors` must remain visible even when MiniMax Music 3 is selected. The Music3-only API fixture does not require that fallback branch. +- The fictional GlowBloom references, soundtrack, and rendered campaign media are not bundled as distributable production assets. Replace the fixture's local input selections and review every claim before commercial use. + ## Recommended Wiring For custom workflows, the core DiffusionGemma path is: @@ -322,7 +369,9 @@ Official references: ## Compatibility -The verified baseline is ComfyUI `0.30.1` with Python 3.11. Registry metadata declares `requires-comfyui = ">=0.30.1"`: this is the Registry's minimum-compatible-version constraint, while `0.30.1` remains the version exercised by the compatibility and release checks. Raise that floor when an exported node begins to require a newer ComfyUI API. +The established Director surface retains its ComfyUI `0.30.1` / Python 3.11 compatibility baseline. Registry metadata declares `requires-comfyui = ">=0.30.1"`: this remains the package-wide minimum-compatible-version constraint unless an exported node makes a newer core mandatory. + +The separate Advertisement + native MiniMax Music 3 workflow requires ComfyUI `>=0.33.1`. Its complete two-lane H3 campaign path was runtime-proven on `0.33.1`, including the pruned INT8/ConvRot Music 3 text encoder, portable Director checkpoint path, governed two-call Director refinement, persistent relay, and exact 30/15/6 finishing. This narrower Advertisement requirement does not change the preserved V6 workflow, and technical completion does not override the Advertisement QA gate's evidence requirements. ## Runtime Dependencies @@ -332,6 +381,8 @@ This pack assumes a working ComfyUI install with CUDA-capable PyTorch. The Pytho - `accelerate>=1.14.0` - `comfy-kitchen>=0.2.10` - `jsonschema>=4.26.0` +- `numpy>=1.26.0` +- `Pillow>=10.0.0` - `safetensors>=0.7.0` - `tqdm>=4.67.0` @@ -351,6 +402,30 @@ Place the whole repo folder at: ComfyUI/models/LLM/diffusiongemma-26B-A4B-it-NVFP4 ``` +MiniMax Music 3 models used by the separate Advertisement fixture: + +```text +ComfyUI/models/text_encoders/minimax_music3_text_encoder_pruned_int8_convrot.safetensors +ComfyUI/models/diffusion_models/minimax_music3_dit_fp16.safetensors +ComfyUI/models/vae/minimax_music3_dav.safetensors +``` + +The INT8/ConvRot text-encoder selection is the locally runtime-proven ComfyUI `0.33.1` path. Model files are not bundled with this repository. + +MiniMax H3 models used by both Advertisement artifacts: + +```text +ComfyUI/models/diffusion_models/minimax_h3_ref2va_pruned_int8_convrot.safetensors +ComfyUI/models/text_encoders/qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors +ComfyUI/models/vae/minimax_h3_video_vae_fp16.safetensors +ComfyUI/models/vae/minimax_h3_audio_vae_fp32.safetensors +ComfyUI/models/loras/minimax_h3_ref2v_turbo_4step_v0.1_comfyui_bf16.safetensors +``` + +The saved baseline uses seven scheduler steps with the Turbo LoRA at strength `0.75`, video sigma shift `12`, and audio sigma shift `3`. These H3 files are also local dependencies and are not bundled. + +The combined user-editable workflow also retains the Legacy ACE-Step fallback. Because ComfyUI can validate linked lazy model selections before executing the chosen branch, `acestep_v1.5_turbo.safetensors`, `qwen_0.6b_ace15.safetensors`, `qwen_4b_ace15.safetensors`, and `ace_1.5_vae.safetensors` must be discoverable even when the saved source mode remains MiniMax Music 3. The ACE fallback has no LoRA dependency. Use the Music3-only API fixture when that fallback is intentionally unavailable. + LTX 2.3 diffusion model: - [Kijai/LTX2.3_comfy - ltx-2.3-22b-distilled_transformer_only_fp8_input_scaled_v3.safetensors](https://huggingface.co/Kijai/LTX2.3_comfy/blob/main/diffusion_models/ltx-2.3-22b-distilled_transformer_only_fp8_input_scaled_v3.safetensors) diff --git a/__init__.py b/__init__.py index bd9c949..d9e3617 100644 --- a/__init__.py +++ b/__init__.py @@ -17,18 +17,48 @@ from .timed_lyrics_nodes import ( NODE_CLASS_MAPPINGS as _TIMED_LYRICS_NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as _TIMED_LYRICS_NODE_DISPLAY_NAME_MAPPINGS, ) +from .advertising_contract_nodes import ( + NODE_CLASS_MAPPINGS as _ADVERTISING_CONTRACT_NODE_CLASS_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS as _ADVERTISING_CONTRACT_NODE_DISPLAY_NAME_MAPPINGS, +) +from .advertising_audio_nodes import ( + NODE_CLASS_MAPPINGS as _ADVERTISING_AUDIO_NODE_CLASS_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS as _ADVERTISING_AUDIO_NODE_DISPLAY_NAME_MAPPINGS, +) +from .advertising_planning_nodes import ( + NODE_CLASS_MAPPINGS as _ADVERTISING_PLANNING_NODE_CLASS_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS as _ADVERTISING_PLANNING_NODE_DISPLAY_NAME_MAPPINGS, +) +from .advertising_finishing_nodes import ( + NODE_CLASS_MAPPINGS as _ADVERTISING_FINISHING_NODE_CLASS_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS as _ADVERTISING_FINISHING_NODE_DISPLAY_NAME_MAPPINGS, +) +from .advertising_runtime_nodes import ( + NODE_CLASS_MAPPINGS as _ADVERTISING_RUNTIME_NODE_CLASS_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS as _ADVERTISING_RUNTIME_NODE_DISPLAY_NAME_MAPPINGS, +) NODE_CLASS_MAPPINGS = { **_DIRECTOR_NODE_CLASS_MAPPINGS, **_AUDIO_NODE_CLASS_MAPPINGS, **_PRODUCTION_PLANNING_NODE_CLASS_MAPPINGS, **_TIMED_LYRICS_NODE_CLASS_MAPPINGS, + **_ADVERTISING_CONTRACT_NODE_CLASS_MAPPINGS, + **_ADVERTISING_AUDIO_NODE_CLASS_MAPPINGS, + **_ADVERTISING_PLANNING_NODE_CLASS_MAPPINGS, + **_ADVERTISING_FINISHING_NODE_CLASS_MAPPINGS, + **_ADVERTISING_RUNTIME_NODE_CLASS_MAPPINGS, } NODE_DISPLAY_NAME_MAPPINGS = { **_DIRECTOR_NODE_DISPLAY_NAME_MAPPINGS, **_AUDIO_NODE_DISPLAY_NAME_MAPPINGS, **_PRODUCTION_PLANNING_NODE_DISPLAY_NAME_MAPPINGS, **_TIMED_LYRICS_NODE_DISPLAY_NAME_MAPPINGS, + **_ADVERTISING_CONTRACT_NODE_DISPLAY_NAME_MAPPINGS, + **_ADVERTISING_AUDIO_NODE_DISPLAY_NAME_MAPPINGS, + **_ADVERTISING_PLANNING_NODE_DISPLAY_NAME_MAPPINGS, + **_ADVERTISING_FINISHING_NODE_DISPLAY_NAME_MAPPINGS, + **_ADVERTISING_RUNTIME_NODE_DISPLAY_NAME_MAPPINGS, } WEB_DIRECTORY = "./web" diff --git a/advertising_audio_nodes.py b/advertising_audio_nodes.py new file mode 100644 index 0000000..f27b196 --- /dev/null +++ b/advertising_audio_nodes.py @@ -0,0 +1,1014 @@ +# Copyright (c) 2026 exportAnything. All rights reserved. +# SPDX-License-Identifier: MIT + +"""Advertisement-specific decoded-audio selection and deterministic mixing. + +The existing music-video selector intentionally remains untouched. This +module reuses its CPU waveform analysis and all ordinary quality gates, while +making the one previously implicit vocal-presence gate explicit in a typed +advertisement soundtrack contract. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import re +from typing import Any, Mapping + +import torch +import torch.nn.functional as torch_functional + +try: # Loaded as a ComfyUI package. + from . import audio_production_nodes as legacy_audio + from .advertising_contract_nodes import ( + SOUNDTRACK_SCHEMA, + VERSION, + _strict_json_object, + _validate_schema, + ) +except ImportError: # Standalone repository tests. + import audio_production_nodes as legacy_audio + from advertising_contract_nodes import ( + SOUNDTRACK_SCHEMA, + VERSION, + _strict_json_object, + _validate_schema, + ) + + +CATEGORY = "prompt/diffusiongemma/advertising" +SELECTION_SCHEMA = "diffusiongemma.advertisement_audio_selection" +MIX_SCHEMA = "diffusiongemma.advertisement_audio_mix" +SELECTION_VERSION = 1 +MIX_VERSION = 1 +CLIPPING_POLICIES = ("Attenuate mix to ceiling", "Fail on clipping") +SOUNDTRACK_SOURCE_MODES = ("MiniMax Music 3", "Upload song", "Legacy ACE-Step") +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") + + +def _json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def _sha_text(value: Any) -> str: + return hashlib.sha256(str(value).encode("utf-8")).hexdigest() + + +def _parse_soundtrack_contract(value: Any) -> dict[str, Any]: + payload = _strict_json_object(value, "soundtrack_contract_json") + _validate_schema(payload, "advertisement_soundtrack.schema.json", "soundtrack contract") + if ( + payload.get("schema") != SOUNDTRACK_SCHEMA + or payload.get("version") != VERSION + or not payload.get("ready") + ): + raise ValueError("soundtrack_contract_json is not a ready supported advertisement soundtrack contract.") + return payload + + +def _content_mode(contract: Mapping[str, Any]) -> str: + mode = str(contract.get("effective_content_mode", "")) + if mode not in {"instrumental", "vocal"}: + raise ValueError("soundtrack contract effective content mode is invalid.") + return mode + + +def _normalize_ad_source_policy(value: Any) -> str: + normalized = re.sub(r"[^a-z0-9]+", "_", str(value or "").strip().casefold()).strip("_") + aliases = { + "": "minimax_music3", + "minimax_music3": "minimax_music3", + "music3": "minimax_music3", + "minimax_3": "minimax_music3", + "ace": "ace_step", + "ace_step": "ace_step", + "legacy_ace": "ace_step", + "upload": "uploaded_song", + "upload_song": "uploaded_song", + "uploaded_song": "uploaded_song", + } + if normalized not in aliases: + raise ValueError("source_policy must be minimax_music3, uploaded_song, or ace_step.") + return aliases[normalized] + + +def _legacy_qc_policy(source_policy: str) -> str: + return "uploaded_song" if source_policy == "uploaded_song" else "ace_step" + + +def _safe_nonvocal_double_time(analysis: Mapping[str, Any]) -> bool: + if str(analysis.get("tempo_relation", "")) != "double_time_detected": + return False + relative_error = analysis.get("canonical_tempo_relative_error") + excerpt = analysis.get("suggested_excerpt") + if not isinstance(relative_error, (int, float)) or not isinstance(excerpt, Mapping): + return False + if not math.isfinite(float(relative_error)): + return False + supporting = ( + float(excerpt.get("low_density_visual_recovery_coverage", 0.0)) + >= legacy_audio.MIN_SAFE_DOUBLE_TIME_VISUAL_RECOVERY_COVERAGE, + float(excerpt.get("score", 0.0)) + >= legacy_audio.MIN_SAFE_DOUBLE_TIME_EXCERPT_SCORE, + float(excerpt.get("tonal_family_consistency", 0.0)) + >= legacy_audio.MIN_SAFE_DOUBLE_TIME_TONAL_FAMILY_CONSISTENCY, + float(excerpt.get("tonal_transition_stability", 0.0)) + >= legacy_audio.MIN_SAFE_DOUBLE_TIME_TONAL_TRANSITION_STABILITY, + ) + return bool( + float(relative_error) <= legacy_audio.MAX_CANONICAL_DOUBLE_TIME_RELATIVE_ERROR + and float(excerpt.get("onset_density_per_second", math.inf)) + <= legacy_audio.MAX_SAFE_DOUBLE_TIME_EXCERPT_ONSETS_PER_SECOND + and sum(bool(value) for value in supporting) + >= legacy_audio.MIN_SAFE_DOUBLE_TIME_SUPPORTING_SIGNAL_COUNT + ) + + +def _requested_tempo_mismatch(analysis: Mapping[str, Any]) -> bool: + expected = analysis.get("expected_bpm") + relative_error = analysis.get("canonical_tempo_relative_error") + return bool( + isinstance(expected, (int, float)) + and math.isfinite(float(expected)) + and float(expected) > 0.0 + and isinstance(relative_error, (int, float)) + and math.isfinite(float(relative_error)) + and float(relative_error) > 0.10 + ) + + +def _content_aware_failures( + analysis: Mapping[str, Any], + *, + excerpt_duration_seconds: float, + minimum_score: float, + source_policy: str, + content_mode: str, +) -> list[str]: + failures = list( + legacy_audio._candidate_failures( + analysis, + excerpt_duration_seconds=excerpt_duration_seconds, + minimum_score=minimum_score, + source_policy=_legacy_qc_policy(source_policy), + ) + ) + excerpt = analysis.get("suggested_excerpt") + vocal_proxy = bool( + isinstance(excerpt, Mapping) and excerpt.get("likely_vocal_active_proxy") + ) + if content_mode == "instrumental": + failures = [ + code + for code in failures + if code != "no_non_silent_vocal_active_proxy_window" + ] + if ( + "double_time_outside_low_pressure_contract" in failures + and _safe_nonvocal_double_time(analysis) + ): + failures.remove("double_time_outside_low_pressure_contract") + elif content_mode == "vocal" and not vocal_proxy: + if "no_non_silent_vocal_active_proxy_window" not in failures: + failures.append("no_non_silent_vocal_active_proxy_window") + else: # pragma: no cover - contract validation constrains this. + raise ValueError("Unsupported advertisement soundtrack content mode.") + return failures + + +def _content_advisories( + analysis: Mapping[str, Any], + *, + source_policy: str, + minimum_score: float, + content_mode: str, +) -> list[str]: + advisories = list( + legacy_audio._candidate_advisories( + analysis, + source_policy=_legacy_qc_policy(source_policy), + minimum_score=minimum_score, + ) + ) + excerpt = analysis.get("suggested_excerpt") + vocal_proxy = bool( + isinstance(excerpt, Mapping) and excerpt.get("likely_vocal_active_proxy") + ) + if content_mode == "instrumental": + advisories.append("instrumental_contract_vocal_proxy_not_required") + if vocal_proxy: + advisories.append("instrumental_contract_possible_midband_vocal_proxy_not_proof") + if _safe_nonvocal_double_time(analysis): + advisories.append("instrumental_low_pressure_double_time_without_vocal_requirement") + else: + advisories.append("vocal_contract_vocal_proxy_required") + if source_policy == "minimax_music3": + advisories.append("generated_source_minimax_music3") + elif source_policy == "ace_step": + advisories.append("generated_source_legacy_ace_step") + if _requested_tempo_mismatch(analysis): + advisories.append("requested_tempo_mismatch_signal_estimate") + return list(dict.fromkeys(advisories)) + + +def _selection_failure(report: dict[str, Any], message: str): + report["ready"] = False + report["status"] = message + blocker = legacy_audio.ExecutionBlocker(message) + encoded = _json(report) + return (blocker, 0.0, blocker, blocker, encoded, message, False) + + +def _candidate_summary(entry: Mapping[str, Any]) -> dict[str, Any]: + analysis = entry.get("analysis") + summary: dict[str, Any] = { + "index": int(entry.get("index", 0)), + "present": bool(entry.get("present")), + "passed": bool(entry.get("passed")), + "failures": list(entry.get("failures", [])), + "advisories": list(entry.get("advisories", [])), + } + if isinstance(analysis, Mapping): + excerpt = analysis.get("suggested_excerpt") + summary["analysis"] = { + "waveform_sha256": str(analysis.get("waveform_sha256", "")), + "duration_seconds": float(analysis.get("duration_seconds", 0.0)), + "production_score": float(analysis.get("production_score", 0.0)), + "tempo_relation": str(analysis.get("tempo_relation", "")), + "expected_bpm": float(analysis.get("expected_bpm", 0.0)), + "detected_bpm": float(analysis.get("detected_bpm", 0.0)), + "canonical_tempo_relative_error": ( + float(analysis["canonical_tempo_relative_error"]) + if isinstance(analysis.get("canonical_tempo_relative_error"), (int, float)) + and math.isfinite(float(analysis["canonical_tempo_relative_error"])) + else None + ), + "requested_tempo_match_within_10_percent": ( + not _requested_tempo_mismatch(analysis) + if float(analysis.get("expected_bpm", 0.0)) > 0.0 + and isinstance(analysis.get("canonical_tempo_relative_error"), (int, float)) + else None + ), + "likely_vocal_active_proxy": bool( + isinstance(excerpt, Mapping) + and excerpt.get("likely_vocal_active_proxy") + ), + "suggested_excerpt_start_seconds": float( + excerpt.get("start_seconds", 0.0) + if isinstance(excerpt, Mapping) + else 0.0 + ), + "suggested_excerpt_score": float( + excerpt.get("score", 0.0) + if isinstance(excerpt, Mapping) + else 0.0 + ), + } + if entry.get("error"): + summary["error"] = str(entry["error"])[:500] + return summary + + +class DiffusionGemmaAdvertisementSoundtrackSourceRouter: + """Lazily select MiniMax Music 3, upload, or legacy ACE audio.""" + + @classmethod + def INPUT_TYPES(cls): + lazy = {"lazy": True, "forceInput": True} + return { + "required": { + "source_mode": ( + list(SOUNDTRACK_SOURCE_MODES), + { + "default": "MiniMax Music 3", + "tooltip": "Only the selected source branch is requested. All routes converge before advertisement content-aware QC.", + }, + ) + }, + "optional": { + "music3_candidate_count": ("INT", dict(lazy)), + "music3_expected_bpm": ("FLOAT", dict(lazy)), + "music3_lyrics": ("STRING", {**lazy, "multiline": True}), + "music3_duration_seconds": ("FLOAT", dict(lazy)), + "music3_candidate_1": ("AUDIO", {"lazy": True}), + "music3_candidate_2": ("AUDIO", {"lazy": True}), + "music3_candidate_3": ("AUDIO", {"lazy": True}), + "music3_candidate_4": ("AUDIO", {"lazy": True}), + "uploaded_audio": ("AUDIO", {"lazy": True}), + "uploaded_duration_seconds": ("FLOAT", dict(lazy)), + "uploaded_expected_bpm": ("FLOAT", dict(lazy)), + "uploaded_lyrics": ("STRING", {**lazy, "multiline": True}), + "uploaded_waveform_sha256": ("STRING", dict(lazy)), + "uploaded_status": ("STRING", dict(lazy)), + "uploaded_ready": ("BOOLEAN", dict(lazy)), + "ace_candidate_count": ("INT", dict(lazy)), + "ace_expected_bpm": ("FLOAT", dict(lazy)), + "ace_lyrics": ("STRING", {**lazy, "multiline": True}), + "ace_duration_seconds": ("FLOAT", dict(lazy)), + "ace_candidate_1": ("AUDIO", {"lazy": True}), + "ace_candidate_2": ("AUDIO", {"lazy": True}), + "ace_candidate_3": ("AUDIO", {"lazy": True}), + "ace_candidate_4": ("AUDIO", {"lazy": True}), + }, + } + + RETURN_TYPES = ( + "AUDIO", "AUDIO", "AUDIO", "AUDIO", "INT", "FLOAT", "STRING", + "FLOAT", "STRING", "STRING", "BOOLEAN", + ) + RETURN_NAMES = ( + "candidate_1", + "candidate_2", + "candidate_3", + "candidate_4", + "effective_candidate_count", + "effective_expected_bpm", + "selected_lyrics", + "selected_duration_seconds", + "source_policy", + "status", + "ready", + ) + FUNCTION = "route" + CATEGORY = CATEGORY + DESCRIPTION = "A lazy three-way ad soundtrack router. MiniMax Music 3 is the default generated source, upload is source-locked, and legacy ACE remains available without waking dormant branches." + + @classmethod + def check_lazy_status(cls, **kwargs): + mode = str(kwargs.get("source_mode", "MiniMax Music 3")) + if mode == "Upload song": + names = ( + "uploaded_audio", + "uploaded_duration_seconds", + "uploaded_expected_bpm", + "uploaded_lyrics", + "uploaded_waveform_sha256", + "uploaded_status", + "uploaded_ready", + ) + return [name for name in names if name in kwargs and kwargs[name] is None] + prefix = "ace" if mode == "Legacy ACE-Step" else "music3" + metadata = ( + f"{prefix}_candidate_count", + f"{prefix}_expected_bpm", + f"{prefix}_lyrics", + f"{prefix}_duration_seconds", + ) + pending = [name for name in metadata if name in kwargs and kwargs[name] is None] + if pending: + return pending + try: + count = int(kwargs.get(f"{prefix}_candidate_count")) + except (TypeError, ValueError): + return [] + if not 1 <= count <= 4: + return [] + names = [f"{prefix}_candidate_{index}" for index in range(1, count + 1)] + return [name for name in names if name in kwargs and kwargs[name] is None] + + @staticmethod + def _generated(prefix: str, policy: str, kwargs: Mapping[str, Any]): + count_value = kwargs.get(f"{prefix}_candidate_count") + bpm_value = kwargs.get(f"{prefix}_expected_bpm") + duration_value = kwargs.get(f"{prefix}_duration_seconds") + if count_value is None or bpm_value is None or duration_value is None: + raise ValueError(f"The selected {prefix} source is missing candidate count, BPM, or duration metadata.") + count = int(count_value) + if not 1 <= count <= 4: + raise ValueError(f"{prefix}_candidate_count must be from 1 to 4.") + bpm = float(bpm_value) + duration = float(duration_value) + if not math.isfinite(bpm) or not 0.0 <= bpm <= 300.0: + raise ValueError(f"{prefix}_expected_bpm must be finite from 0 to 300.") + if not math.isfinite(duration) or duration <= 0.0: + raise ValueError(f"{prefix}_duration_seconds must be positive and finite.") + generated = [kwargs.get(f"{prefix}_candidate_{index}") for index in range(1, 5)] + missing = [index for index in range(1, count + 1) if generated[index - 1] is None] + if missing: + raise ValueError( + f"The selected {prefix} source is missing candidate lane(s): {', '.join(map(str, missing))}." + ) + first = generated[0] + candidates = tuple(value if value is not None else first for value in generated) + lyrics = str(kwargs.get(f"{prefix}_lyrics") or "").strip() + label = "MiniMax Music 3" if policy == "minimax_music3" else "legacy ACE-Step" + status = ( + f"{label} selected with {count} candidate{'s' if count != 1 else ''}; " + "upload and the other generated soundtrack branch remain dormant." + ) + return (*candidates, count, bpm, lyrics, duration, policy, status, True) + + def route(self, source_mode, **kwargs): + mode = str(source_mode) + if mode not in SOUNDTRACK_SOURCE_MODES: + raise ValueError("source_mode is unsupported.") + if mode == "MiniMax Music 3": + return self._generated("music3", "minimax_music3", kwargs) + if mode == "Legacy ACE-Step": + return self._generated("ace", "ace_step", kwargs) + + audio = kwargs.get("uploaded_audio") + if audio is None: + raise ValueError("Upload song is selected, but uploaded_audio is not connected.") + waveform, sample_rate = _audio_parts(audio, "uploaded_audio") + duration = int(waveform.shape[-1]) / float(sample_rate) + claimed_duration = kwargs.get("uploaded_duration_seconds") + if claimed_duration is not None and abs(float(claimed_duration) - duration) > 0.05: + raise ValueError("uploaded_duration_seconds does not match the decoded waveform.") + bpm = float(kwargs.get("uploaded_expected_bpm") or 0.0) + if not math.isfinite(bpm) or not 0.0 <= bpm <= 300.0: + raise ValueError("uploaded_expected_bpm must be finite from 0 to 300.") + supplied_hash = str(kwargs.get("uploaded_waveform_sha256") or "").strip().casefold() + actual_hash = legacy_audio.waveform_sha256(audio) + if supplied_hash and (not _SHA256_RE.fullmatch(supplied_hash) or supplied_hash != actual_hash): + raise ValueError("uploaded_waveform_sha256 does not match the decoded waveform.") + if kwargs.get("uploaded_ready") is False: + raise ValueError(str(kwargs.get("uploaded_status") or "The uploaded soundtrack is not ready.")) + lyrics = str(kwargs.get("uploaded_lyrics") or "").strip() + status = ( + f"Uploaded soundtrack selected at {duration:.3f}s; all generated soundtrack branches remain dormant, " + f"and waveform {actual_hash[:12]}… enters content-aware QC." + ) + return (audio, audio, audio, audio, 1, bpm, lyrics, duration, "uploaded_song", status, True) + + +class DiffusionGemmaAdvertisementAudioCandidateSelector: + """Select a locked soundtrack with content-aware vocal QC.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "soundtrack_contract_json": ( + "STRING", + {"default": "", "multiline": True, "forceInput": True}, + ), + "candidate_count": ("INT", {"default": 1, "min": 1, "max": 4, "step": 1}), + "selection_mode": (list(legacy_audio.SELECTION_MODES), {"default": "auto_select"}), + "expected_bpm": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 300.0, "step": 0.1}), + "excerpt_duration_seconds": ("FLOAT", {"default": 30.0, "min": 0.5, "max": 600.0, "step": 0.1}), + "minimum_score": ("FLOAT", {"default": 0.52, "min": 0.0, "max": 1.0, "step": 0.01}), + }, + "optional": { + "locked_waveform_sha256": ("STRING", {"default": "", "multiline": False}), + "locked_start_seconds": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 2000.0, "step": 0.1}), + "candidate_1": ("AUDIO", {"lazy": True}), + "candidate_2": ("AUDIO", {"lazy": True}), + "candidate_3": ("AUDIO", {"lazy": True}), + "candidate_4": ("AUDIO", {"lazy": True}), + "source_policy": ( + "STRING", + { + "default": "minimax_music3", + "forceInput": True, + "tooltip": "ace_step retains generated-candidate musical QC; uploaded_song retains technical integrity gates plus the explicit vocal-content contract.", + }, + ), + }, + } + + RETURN_TYPES = ("AUDIO", "FLOAT", "STRING", "STRING", "STRING", "STRING", "BOOLEAN") + RETURN_NAMES = ( + "selected_audio", + "suggested_start_seconds", + "waveform_sha256", + "director_report_json", + "audit_report_json", + "status", + "ready", + ) + FUNCTION = "select" + CATEGORY = CATEGORY + DESCRIPTION = "Run existing decoded-audio QC with an explicit advertisement content contract: instrumental music does not require a vocal proxy, while vocal music does." + + @classmethod + def check_lazy_status(cls, **kwargs): + try: + count = max(1, min(4, int(kwargs.get("candidate_count", 1)))) + mode = legacy_audio._normalize_selection_mode(kwargs.get("selection_mode", "auto_select")) + except (TypeError, ValueError): + return [] + lock_match = re.fullmatch(r"lock_candidate_([1-4])", mode) + names = ( + [f"candidate_{int(lock_match.group(1))}"] + if lock_match is not None and int(lock_match.group(1)) <= count + else [f"candidate_{index}" for index in range(1, count + 1)] + ) + return [name for name in names if name in kwargs and kwargs[name] is None] + + def select( + self, + soundtrack_contract_json, + candidate_count, + selection_mode, + expected_bpm, + excerpt_duration_seconds, + minimum_score, + locked_waveform_sha256="", + locked_start_seconds=-1.0, + candidate_1=None, + candidate_2=None, + candidate_3=None, + candidate_4=None, + source_policy="minimax_music3", + ): + contract = _parse_soundtrack_contract(soundtrack_contract_json) + content_mode = _content_mode(contract) + count = int(candidate_count) + if not 1 <= count <= 4: + raise ValueError("candidate_count must be from 1 to 4.") + mode = legacy_audio._normalize_selection_mode(selection_mode) + policy = _normalize_ad_source_policy(source_policy) + expected = float(expected_bpm) + duration = float(excerpt_duration_seconds) + threshold = float(minimum_score) + locked_start = float(locked_start_seconds) + if not math.isfinite(expected) or not 0.0 <= expected <= 300.0: + raise ValueError("expected_bpm must be finite from 0 to 300.") + if not math.isfinite(duration) or duration <= 0.0: + raise ValueError("excerpt_duration_seconds must be positive and finite.") + if not math.isfinite(threshold) or not 0.0 <= threshold <= 1.0: + raise ValueError("minimum_score must be finite from 0 to 1.") + if not math.isfinite(locked_start) or locked_start < -1.0: + raise ValueError("locked_start_seconds must be -1 or non-negative.") + lock_hash = str(locked_waveform_sha256 or "").strip().casefold() + lock_match = re.fullmatch(r"lock_candidate_([1-4])", mode) + indices = [int(lock_match.group(1))] if lock_match is not None else list(range(1, count + 1)) + report: dict[str, Any] = { + "schema": SELECTION_SCHEMA, + "version": SELECTION_VERSION, + "settings": { + "candidate_count": count, + "selection_mode": mode, + "expected_bpm": expected, + "excerpt_duration_seconds": duration, + "minimum_score": threshold, + "source_policy": policy, + "soundtrack_content_mode": content_mode, + "content_evidence_policy": contract["content_evidence_policy"], + }, + "lock": { + "active": mode != "auto_select", + "requested_waveform_sha256": lock_hash, + "requested_start_seconds": locked_start, + "satisfied": False, + "failure_reason": "", + }, + "candidates": [], + "selected_candidate_index": None, + "selected_waveform_sha256": "", + "ready": False, + } + if lock_match is not None and indices[0] > count: + report["lock"]["failure_reason"] = "locked_candidate_outside_candidate_count" + return _selection_failure(report, f"Audio selection is locked to candidate {indices[0]}, outside candidate_count={count}.") + if mode == "lock_by_hash" and not _SHA256_RE.fullmatch(lock_hash): + report["lock"]["failure_reason"] = "invalid_locked_waveform_sha256" + return _selection_failure(report, "Audio selection is locked by hash, but no valid SHA-256 was supplied.") + + candidates = [candidate_1, candidate_2, candidate_3, candidate_4] + measured: dict[int, dict[str, Any]] = {} + missing: list[int] = [] + for index in indices: + audio = candidates[index - 1] + if audio is None: + entry = { + "index": index, + "present": False, + "passed": False, + "failures": ["candidate_missing"], + "advisories": [], + } + missing.append(index) + else: + try: + analysis = legacy_audio.analyze_decoded_audio( + audio, + expected_bpm=expected, + excerpt_duration_seconds=duration, + locked_start_seconds=( + locked_start + if mode != "auto_select" and locked_start >= 0.0 + else None + ), + ) + failures = _content_aware_failures( + analysis, + excerpt_duration_seconds=duration, + minimum_score=threshold, + source_policy=policy, + content_mode=content_mode, + ) + entry = { + "index": index, + "present": True, + "passed": not failures, + "failures": failures, + "advisories": _content_advisories( + analysis, + source_policy=policy, + minimum_score=threshold, + content_mode=content_mode, + ), + "analysis": analysis, + } + except (RuntimeError, TypeError, ValueError) as exc: + entry = { + "index": index, + "present": True, + "passed": False, + "failures": ["analysis_error"], + "advisories": [], + "error": str(exc), + } + measured[index] = entry + report["candidates"].append(_candidate_summary(entry)) + + if mode in {"auto_select", "lock_by_hash"} and missing: + report["lock"]["failure_reason"] = "candidate_lane_missing" if mode == "lock_by_hash" else "" + return _selection_failure(report, f"Advertisement audio audition is missing candidate lane(s): {', '.join(map(str, missing))}.") + + selected_index: int | None = None + if lock_match is not None: + selected_index = indices[0] + entry = measured[selected_index] + if not entry.get("passed"): + report["lock"]["failure_reason"] = "locked_candidate_failed_qc" + return _selection_failure( + report, + f"Locked candidate {selected_index} failed advertisement audio QC: " + + ", ".join(entry.get("failures", [])) + + ".", + ) + elif mode == "lock_by_hash": + matches = [ + index + for index, entry in measured.items() + if entry.get("analysis", {}).get("waveform_sha256") == lock_hash + ] + if not matches: + report["lock"]["failure_reason"] = "locked_hash_not_present" + return _selection_failure(report, "The locked waveform hash is not present among advertisement audio candidates.") + selected_index = min(matches) + if not measured[selected_index].get("passed"): + report["lock"]["failure_reason"] = "locked_hash_failed_qc" + return _selection_failure(report, "The locked waveform is present but failed advertisement audio QC.") + else: + passing = [entry for entry in measured.values() if entry.get("passed")] + if passing: + selected = max( + passing, + key=lambda entry: ( + round(float(entry["analysis"]["suggested_excerpt"].get("score", 0.0)), 9), + round(float(entry["analysis"].get("production_score", 0.0)), 9), + -int(entry["index"]), + ), + ) + selected_index = int(selected["index"]) + + if selected_index is None: + details = "; ".join( + f"candidate {entry['index']}: {', '.join(entry.get('failures', []))}" + for entry in measured.values() + ) + return _selection_failure( + report, + "No advertisement soundtrack candidate passed content-aware decoded-audio QC. " + details, + ) + + entry = measured[selected_index] + analysis = entry["analysis"] + selected_hash = str(analysis["waveform_sha256"]) + start = float(analysis["suggested_excerpt"]["start_seconds"]) + lock_token = _sha_text( + f"diffusiongemma-ad-audio-lock@1|{selected_hash}|{start:.9f}|{duration:.9f}|{content_mode}" + ) + report["selected_candidate_index"] = selected_index + report["selected_waveform_sha256"] = selected_hash + report["selection_lock_token"] = lock_token + report["ready"] = True + report["lock"].update( + { + "satisfied": mode != "auto_select", + "resolved_candidate_index": selected_index, + "resolved_waveform_sha256": selected_hash, + "resolved_start_seconds": start, + } + ) + status = ( + f"Advertisement audio selected candidate {selected_index} under the explicit {content_mode} contract; " + f"waveform lock {selected_hash[:12]}…. Vocal presence remains a signal proxy, not transcription proof." + ) + report["status"] = status + _validate_schema(report, "advertisement_audio_selection.schema.json", "advertisement audio selection") + audit_json = _json(report) + director_report = { + "schema": "diffusiongemma.advertisement_audio_selection_compact", + "version": SELECTION_VERSION, + "ready": True, + "soundtrack_content_mode": content_mode, + "selected_candidate_index": selected_index, + "selected_audio_sha256": selected_hash, + "selection_lock_token": lock_token, + "qc": _candidate_summary(entry), + } + return ( + candidates[selected_index - 1], + start, + selected_hash, + _json(director_report), + audit_json, + status, + True, + ) + + +def _audio_parts(audio: Any, label: str) -> tuple[torch.Tensor, int]: + waveform, sample_rate = legacy_audio._audio_parts(audio, label) + value = waveform.detach().to(device="cpu", dtype=torch.float32).contiguous() + if not bool(torch.isfinite(value).all()): + raise ValueError(f"{label} contains NaN or infinite samples.") + return value, int(sample_rate) + + +def _dbfs(value: float) -> float: + if value <= 0.0 or not math.isfinite(value): + return -120.0 + return max(-120.0, 20.0 * math.log10(value)) + + +def _fit_voice_over( + voice_over: Any, + *, + sample_rate: int, + batch_count: int, + channel_count: int, + target_samples: int, + start_samples: int, +) -> torch.Tensor: + value, source_rate = _audio_parts(voice_over, "voice_over") + if source_rate != sample_rate: + resampled_samples = max(1, int(round(value.shape[-1] * sample_rate / float(source_rate)))) + value = torch_functional.interpolate( + value, + size=resampled_samples, + mode="linear", + align_corners=False, + ) + if value.shape[0] == 1 and batch_count > 1: + value = value.repeat(batch_count, 1, 1) + if int(value.shape[0]) != batch_count: + raise ValueError("voice_over batch count must equal the music batch count or be one.") + if value.shape[1] == 1 and channel_count > 1: + value = value.repeat(1, channel_count, 1) + elif channel_count == 1 and value.shape[1] > 1: + value = value.mean(dim=1, keepdim=True) + elif int(value.shape[1]) != channel_count: + raise ValueError("voice_over channels cannot be aligned with the music channels.") + output = torch.zeros((batch_count, channel_count, target_samples), dtype=torch.float32) + usable = max(0, min(int(value.shape[-1]), target_samples - start_samples)) + if usable <= 0: + raise ValueError("voice_over_start_seconds leaves no voice-over samples inside the master duration.") + output[..., start_samples : start_samples + usable] = value[..., :usable] + return output + + +def _same_length_pool(value: torch.Tensor, kernel: int, *, maximum: bool = False) -> torch.Tensor: + kernel = max(1, int(kernel)) + if kernel % 2 == 0: + kernel += 1 + if kernel == 1: + return value + padding = kernel // 2 + padded = torch_functional.pad(value, (padding, padding), mode="replicate") + return ( + torch_functional.max_pool1d(padded, kernel_size=kernel, stride=1) + if maximum + else torch_functional.avg_pool1d(padded, kernel_size=kernel, stride=1) + ) + + +class DiffusionGemmaAdvertisementAudioMixer: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "music_audio": ("AUDIO",), + "soundtrack_contract_json": ("STRING", {"default": "", "multiline": True, "forceInput": True}), + "target_duration_seconds": ("FLOAT", {"default": 30.0, "min": 0.1, "max": 60.0, "step": 0.01}), + "music_gain_db": ("FLOAT", {"default": 0.0, "min": -30.0, "max": 12.0, "step": 0.5}), + "voice_over_gain_db": ("FLOAT", {"default": 0.0, "min": -30.0, "max": 12.0, "step": 0.5}), + "ducking_db": ("FLOAT", {"default": -9.0, "min": -30.0, "max": 0.0, "step": 0.5}), + "duck_attack_ms": ("FLOAT", {"default": 80.0, "min": 0.0, "max": 1000.0, "step": 5.0}), + "duck_release_ms": ("FLOAT", {"default": 250.0, "min": 0.0, "max": 3000.0, "step": 5.0}), + "peak_ceiling_dbfs": ("FLOAT", {"default": -1.0, "min": -12.0, "max": 0.0, "step": 0.1}), + "clipping_policy": (list(CLIPPING_POLICIES), {"default": CLIPPING_POLICIES[0]}), + }, + "optional": { + "voice_over": ("AUDIO", {"lazy": True}), + "voice_over_start_seconds": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 60.0, "step": 0.01}), + }, + } + + RETURN_TYPES = ("AUDIO", "AUDIO", "STRING", "STRING", "STRING", "STRING", "BOOLEAN") + RETURN_NAMES = ( + "motion_guide_audio", + "final_mix_audio", + "motion_guide_sha256", + "final_mix_sha256", + "mix_report_json", + "status", + "ready", + ) + FUNCTION = "mix" + CATEGORY = CATEGORY + DESCRIPTION = "Keep advertisement motion conditioning music-only while creating a separate exact-duration final mix with optional non-diegetic VO and deterministic ducking." + + def mix( + self, + music_audio, + soundtrack_contract_json, + target_duration_seconds, + music_gain_db, + voice_over_gain_db, + ducking_db, + duck_attack_ms, + duck_release_ms, + peak_ceiling_dbfs, + clipping_policy, + voice_over=None, + voice_over_start_seconds=0.0, + ): + contract = _parse_soundtrack_contract(soundtrack_contract_json) + vo_policy = str(contract["voice_over"]["policy"]) + if vo_policy == "separate_non_diegetic" and voice_over is None: + raise ValueError("The soundtrack contract requires separate non-diegetic VO, but voice_over is not connected.") + if vo_policy == "none" and voice_over is not None: + raise ValueError("voice_over is connected, but the soundtrack contract explicitly sets VO to None.") + if str(clipping_policy) not in CLIPPING_POLICIES: + raise ValueError("clipping_policy is unsupported.") + duration = float(target_duration_seconds) + if not math.isfinite(duration) or not 0.0 < duration <= 60.0: + raise ValueError("target_duration_seconds must be finite from greater than 0 to 60.") + start_seconds = float(voice_over_start_seconds) + if not math.isfinite(start_seconds) or start_seconds < 0.0 or start_seconds >= duration: + raise ValueError("voice_over_start_seconds must fall inside the master duration.") + numeric_values = { + "music_gain_db": (music_gain_db, -30.0, 12.0), + "voice_over_gain_db": (voice_over_gain_db, -30.0, 12.0), + "ducking_db": (ducking_db, -30.0, 0.0), + "duck_attack_ms": (duck_attack_ms, 0.0, 1000.0), + "duck_release_ms": (duck_release_ms, 0.0, 3000.0), + "peak_ceiling_dbfs": (peak_ceiling_dbfs, -12.0, 0.0), + } + resolved: dict[str, float] = {} + for name, (value, minimum, maximum) in numeric_values.items(): + number = float(value) + if not math.isfinite(number) or not minimum <= number <= maximum: + raise ValueError(f"{name} must be finite from {minimum:g} to {maximum:g}.") + resolved[name] = number + + music, sample_rate = _audio_parts(music_audio, "music_audio") + target_samples = int(round(duration * sample_rate)) + source_samples = int(music.shape[-1]) + deficit = max(0, target_samples - source_samples) + rounding_tolerance_samples = max(1, int(round(sample_rate * 0.001))) + if deficit > rounding_tolerance_samples: + raise ValueError( + f"music_audio is {music.shape[-1] / float(sample_rate):.3f}s, shorter than the {duration:.3f}s master." + ) + if deficit: + music = torch_functional.pad(music, (0, deficit)) + music = music[..., :target_samples] + music_gain = 10.0 ** (resolved["music_gain_db"] / 20.0) + motion_waveform = (music * music_gain).contiguous() + motion_guide = {"waveform": motion_waveform, "sample_rate": sample_rate} + + voice_canvas = torch.zeros_like(motion_waveform) + voice_present = voice_over is not None + if voice_present: + voice_canvas = _fit_voice_over( + voice_over, + sample_rate=sample_rate, + batch_count=int(motion_waveform.shape[0]), + channel_count=int(motion_waveform.shape[1]), + target_samples=target_samples, + start_samples=int(round(start_seconds * sample_rate)), + ) + voice_canvas = voice_canvas * (10.0 ** (resolved["voice_over_gain_db"] / 20.0)) + + if voice_present: + mono_abs = voice_canvas.abs().mean(dim=(0, 1), keepdim=True) + detector_kernel = max(1, int(round(sample_rate * 0.020))) + detected = _same_length_pool(mono_abs, detector_kernel) + threshold = 10.0 ** (-45.0 / 20.0) + activity = (detected >= threshold).to(dtype=torch.float32) + attack_kernel = max(1, int(round(sample_rate * resolved["duck_attack_ms"] / 1000.0))) + release_kernel = max(1, int(round(sample_rate * resolved["duck_release_ms"] / 1000.0))) + activity = _same_length_pool(activity, attack_kernel, maximum=True) + activity = _same_length_pool(activity, release_kernel) + activity = activity.clamp(0.0, 1.0) + duck_linear = 10.0 ** (resolved["ducking_db"] / 20.0) + duck_gain = 1.0 - activity * (1.0 - duck_linear) + ducked_music = motion_waveform * duck_gain + else: + activity = torch.zeros((1, 1, target_samples), dtype=torch.float32) + duck_gain = torch.ones_like(activity) + ducked_music = motion_waveform + pre_ceiling_mix = ducked_music + voice_canvas + pre_peak = float(pre_ceiling_mix.abs().max().item()) + ceiling_linear = 10.0 ** (resolved["peak_ceiling_dbfs"] / 20.0) + attenuation = 1.0 + if pre_peak > ceiling_linear + 1.0e-12: + if str(clipping_policy) == "Fail on clipping": + raise ValueError( + f"Final advertisement mix sample peak {_dbfs(pre_peak):.2f} dBFS exceeds the configured {resolved['peak_ceiling_dbfs']:.2f} dBFS ceiling." + ) + attenuation = ceiling_linear / pre_peak + final_waveform = (pre_ceiling_mix * attenuation).contiguous() + final_mix = {"waveform": final_waveform, "sample_rate": sample_rate} + motion_hash = legacy_audio.waveform_sha256(motion_guide) + final_hash = legacy_audio.waveform_sha256(final_mix) + final_peak = float(final_waveform.abs().max().item()) + report = { + "schema": MIX_SCHEMA, + "version": MIX_VERSION, + "soundtrack_contract_sha256": _sha_text(_json(contract)), + "duration_seconds": round(duration, 9), + "sample_rate": sample_rate, + "sample_count": target_samples, + "duration_alignment": { + "source_sample_count": source_samples, + "target_sample_count": target_samples, + "right_padding_samples": deficit, + "rounding_tolerance_samples": rounding_tolerance_samples, + "policy": "right_pad_at_most_one_millisecond_then_exact_crop", + }, + "channels": int(final_waveform.shape[1]), + "batch_count": int(final_waveform.shape[0]), + "voice_over": { + "policy": vo_policy, + "connected": voice_present, + "start_seconds": round(start_seconds, 9) if voice_present else 0.0, + "included_in_motion_guide": False, + "included_in_final_mix": voice_present, + "activity_fraction_signal_proxy": float(activity.mean().item()), + }, + "ducking": { + "ducking_db": resolved["ducking_db"], + "attack_ms": resolved["duck_attack_ms"], + "release_ms": resolved["duck_release_ms"], + "minimum_applied_gain": float(duck_gain.min().item()), + "algorithm": "20ms_absolute_level_proxy_with_bounded_attack_release_smoothing", + }, + "levels": { + "motion_guide_sample_peak_dbfs": _dbfs(float(motion_waveform.abs().max().item())), + "pre_ceiling_final_mix_sample_peak_dbfs": _dbfs(pre_peak), + "final_mix_sample_peak_dbfs": _dbfs(final_peak), + "peak_ceiling_dbfs": resolved["peak_ceiling_dbfs"], + "global_attenuation_db": _dbfs(attenuation), + "final_clipped_sample_fraction": float( + (final_waveform.abs() >= 1.0).to(torch.float32).mean().item() + ), + "lufs": "not_measured", + "true_peak": "not_measured", + }, + "motion_guide_sha256": motion_hash, + "final_mix_sha256": final_hash, + "motion_guide_policy": "music_only_never_non_diegetic_voice_over", + "final_mix_policy": "music_plus_optional_separate_non_diegetic_voice_over", + "ready": True, + } + _validate_schema(report, "advertisement_audio_mix.schema.json", "advertisement audio mix") + status = ( + f"Advertisement audio mixed to exactly {duration:.3f}s at {sample_rate} Hz. " + f"Motion guide {motion_hash[:12]}… remains music-only; final mix {final_hash[:12]}… " + + ("contains separately mixed VO." if voice_present else "contains no VO.") + ) + return (motion_guide, final_mix, motion_hash, final_hash, _json(report), status, True) + + +NODE_CLASS_MAPPINGS = { + "DiffusionGemmaAdvertisementSoundtrackSourceRouter": DiffusionGemmaAdvertisementSoundtrackSourceRouter, + "DiffusionGemmaAdvertisementAudioCandidateSelector": DiffusionGemmaAdvertisementAudioCandidateSelector, + "DiffusionGemmaAdvertisementAudioMixer": DiffusionGemmaAdvertisementAudioMixer, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "DiffusionGemmaAdvertisementSoundtrackSourceRouter": "DiffusionGemma Advertisement Soundtrack Source", + "DiffusionGemmaAdvertisementAudioCandidateSelector": "DiffusionGemma Advertisement Audio Audition & Lock", + "DiffusionGemmaAdvertisementAudioMixer": "DiffusionGemma Advertisement Motion Guide / Final Mix", +} + + +__all__ = [ + "SELECTION_SCHEMA", + "MIX_SCHEMA", + "DiffusionGemmaAdvertisementAudioCandidateSelector", + "DiffusionGemmaAdvertisementSoundtrackSourceRouter", + "DiffusionGemmaAdvertisementAudioMixer", + "NODE_CLASS_MAPPINGS", + "NODE_DISPLAY_NAME_MAPPINGS", +] diff --git a/advertising_contract_nodes.py b/advertising_contract_nodes.py new file mode 100644 index 0000000..e46fac7 --- /dev/null +++ b/advertising_contract_nodes.py @@ -0,0 +1,1347 @@ +# Copyright (c) 2026 exportAnything. All rights reserved. +# SPDX-License-Identifier: MIT + +"""Typed, additive contracts for advertisement-specific workflows. + +This module deliberately does not alter the generic music-video contracts. It +turns commercial intent into small versioned JSON envelopes that an +advertisement workflow can validate before an expensive Director or H3 lane is +queued. Natural-language descriptions are never used as machine-readable +reference roles; the generated manifest carries explicit ``[ad:...]`` role +annotations instead. +""" + +from __future__ import annotations + +import hashlib +import json +import math +from pathlib import Path +import re +from typing import Any, Mapping + +import torch +import torch.nn.functional as torch_functional + + +CATEGORY = "prompt/diffusiongemma/advertising" +CAMPAIGN_SCHEMA = "diffusiongemma.advertisement_campaign_contract" +REFERENCE_SCHEMA = "diffusiongemma.advertisement_reference_contract" +SOUNDTRACK_SCHEMA = "diffusiongemma.advertisement_soundtrack_contract" +VERSION = 1 +ASPECT_RATIOS = ("9:16", "16:9", "1:1", "4:3", "3:4", "3:2", "2:3", "21:9") +REFERENCE_SLOT_POLICIES = ( + "2 performer + 1 product + relay", + "1 performer + 1 product + relay", + "2 performer + 1 product; relay off", +) +EXPORTED_REFERENCE_SLOT_POLICIES = (REFERENCE_SLOT_POLICIES[0],) +ADVERTISEMENT_PERFORMANCE_MODES = ( + "Natural / audio-led sync", + "Dance / music sync", +) +ADVERTISEMENT_PERFORMANCE_CARRIER_MODES = ( + "Dance / music sync", + "Lyrics + lip sync", + "Natural / audio-led sync", +) +H3_GENERATION_MODE_CARRIER = ("t2va", "ref2va") +H3_SHOT_COUNT_CARRIER = ("auto", *(str(value) for value in range(1, 13)), "custom") +H3_AUDIO_MODE_CARRIER = ("auto_scene_audio", "explicit_sound_design", "visual_only") +H3_DIALOGUE_MODE_CARRIER = ("auto", "required", "off") +ADVERTISEMENT_FPS = 24 +PRODUCT_REFERENCE_KINDS = ("single packshot", "product contact sheet") +SOUNDTRACK_CONTENT_MODES = ("Instrumental", "Vocal", "Auto") +VOICE_OVER_POLICIES = ("None", "Separate non-diegetic VO") +EXPORTED_VOICE_OVER_POLICIES = (VOICE_OVER_POLICIES[0],) +TIME_SIGNATURES = ("2", "3", "4", "6") +LANGUAGES = ( + "unknown", "en", "es", "fr", "de", "it", "pt", "ja", "ko", "zh", + "yue", "hi", "ar", "tr", "ru", "uk", "pl", "nl", "sv", "no", + "da", "fi", "id", "vi", "th", "tl", +) +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_REFERENCE_ROW_RE = re.compile( + r"^\s*<\s*(Picture|Video|Audio)\s+([1-9]\d*)\s*>\s*:\s*(\S.*)$", + flags=re.IGNORECASE, +) +_AD_ANNOTATION_RE = re.compile(r"\[\s*ad\s*:\s*([^\]]+)\]", flags=re.IGNORECASE) +_LYRIC_SECTION_RE = re.compile( + r"(?im)^\s*\[(Intro|Verse(?:\s+\d+)?|Pre-Chorus|Chorus|Post-Chorus|Bridge|Outro|Instrumental|Break|Interlude)\]\s*$" +) + + +def _image_tensor(value: Any, label: str) -> torch.Tensor: + if not isinstance(value, torch.Tensor) or value.ndim != 4: + raise ValueError(f"{label} must be a ComfyUI IMAGE tensor [batch,height,width,channels].") + if int(value.shape[0]) != 1: + raise ValueError(f"{label} must contain exactly one image.") + if int(value.shape[-1]) not in {3, 4}: + raise ValueError(f"{label} must have RGB or RGBA channels.") + result = value.detach().to(device="cpu", dtype=torch.float32) + if int(result.shape[-1]) == 4: + alpha = result[..., 3:4].clamp(0.0, 1.0) + result = result[..., :3] * alpha + 0.5 * (1.0 - alpha) + if not bool(torch.isfinite(result).all()): + raise ValueError(f"{label} contains NaN or infinite pixels.") + return result.clamp(0.0, 1.0).contiguous() + + +def _budgeted_image( + image: torch.Tensor, + *, + maximum_area: float, + multiple: int = 8, +) -> torch.Tensor: + source_height = int(image.shape[1]) + source_width = int(image.shape[2]) + source_area = source_width * source_height + if source_area <= maximum_area + 1.0e-6: + return image + scale = math.sqrt(maximum_area / float(source_area)) + target_width = max(multiple, int(math.floor(source_width * scale / multiple)) * multiple) + target_height = max(multiple, int(math.floor(source_height * scale / multiple)) * multiple) + channels_first = image.permute(0, 3, 1, 2) + resized = torch_functional.interpolate( + channels_first, + size=(target_height, target_width), + mode="bilinear", + align_corners=False, + antialias=True, + ) + return resized.permute(0, 2, 3, 1).contiguous() + + +def _image_sha256(image: torch.Tensor, role: str) -> str: + value = image.detach().to(device="cpu", dtype=torch.float32).contiguous() + digest = hashlib.sha256() + digest.update(b"diffusiongemma-ad-reference-image@1\0") + digest.update(str(role).encode("utf-8")) + digest.update(b"\0") + for size in value.shape: + digest.update(int(size).to_bytes(8, "little", signed=False)) + payload = memoryview(value.view(torch.uint8).numpy()).cast("B") + for offset in range(0, len(payload), 1024 * 1024): + digest.update(payload[offset : offset + 1024 * 1024]) + return digest.hexdigest() + + +def _image_content_sha256(image: torch.Tensor) -> str: + """Hash normalized source pixels without a role salt for distinctness.""" + + value = image.detach().to(device="cpu", dtype=torch.float32).contiguous() + digest = hashlib.sha256() + digest.update(b"diffusiongemma-ad-reference-source-image@1\0") + for size in value.shape: + digest.update(int(size).to_bytes(8, "little", signed=False)) + payload = memoryview(value.view(torch.uint8).numpy()).cast("B") + for offset in range(0, len(payload), 1024 * 1024): + digest.update(payload[offset : offset + 1024 * 1024]) + return digest.hexdigest() + + +def _director_canvas(image: torch.Tensor, width: int, height: int) -> torch.Tensor: + source_height = int(image.shape[1]) + source_width = int(image.shape[2]) + scale = min(width / float(source_width), height / float(source_height)) + target_width = max(1, min(width, int(round(source_width * scale)))) + target_height = max(1, min(height, int(round(source_height * scale)))) + resized = torch_functional.interpolate( + image.permute(0, 3, 1, 2), + size=(target_height, target_width), + mode="bilinear", + align_corners=False, + antialias=True, + ).permute(0, 2, 3, 1) + canvas = torch.full((1, height, width, 3), 0.5, dtype=torch.float32) + left = (width - target_width) // 2 + top = (height - target_height) // 2 + canvas[:, top : top + target_height, left : left + target_width, :] = resized + return canvas.contiguous() + + +def _json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def _sha_text(value: Any) -> str: + return hashlib.sha256(str(value).encode("utf-8")).hexdigest() + + +def _strict_json_object(value: Any, label: str) -> dict[str, Any]: + def reject_constant(token: str) -> None: + raise ValueError(f"{label} contains non-finite JSON number {token}.") + + try: + parsed = json.loads(str(value or ""), parse_constant=reject_constant) + except (TypeError, ValueError, json.JSONDecodeError) as exc: + raise ValueError(f"{label} must be valid strict JSON.") from exc + if not isinstance(parsed, dict): + raise ValueError(f"{label} must be a JSON object.") + return parsed + + +def _strict_json_array(value: Any, label: str) -> list[Any]: + def reject_constant(token: str) -> None: + raise ValueError(f"{label} contains non-finite JSON number {token}.") + + try: + parsed = json.loads(str(value or ""), parse_constant=reject_constant) + except (TypeError, ValueError, json.JSONDecodeError) as exc: + raise ValueError(f"{label} must be valid strict JSON.") from exc + if not isinstance(parsed, list): + raise ValueError(f"{label} must be a JSON array.") + return parsed + + +def _text(value: Any, label: str, *, maximum: int, required: bool = True) -> str: + result = str(value or "").strip() + if required and not result: + raise ValueError(f"{label} is required.") + if "\x00" in result: + raise ValueError(f"{label} cannot contain a NUL character.") + if len(result) > maximum: + raise ValueError(f"{label} exceeds {maximum} characters.") + return result + + +def _copy_text(value: Any, label: str, *, maximum: int, required: bool = True) -> str: + """Normalize exact copy once so the finishing renderer can preserve it verbatim.""" + + result = _text(value, label, maximum=maximum, required=required) + if not result: + return "" + normalized = "\n".join(" ".join(line.split()) for line in result.splitlines()) + if len(normalized) > maximum: + raise ValueError(f"{label} exceeds {maximum} characters after whitespace normalization.") + return normalized + + +def _finite(value: Any, label: str, *, minimum: float, maximum: float) -> float: + try: + number = float(value) + except (TypeError, ValueError, OverflowError) as exc: + raise ValueError(f"{label} must be a finite number.") from exc + if not math.isfinite(number) or not minimum <= number <= maximum: + raise ValueError(f"{label} must be from {minimum:g} to {maximum:g}.") + return number + + +def _frame_aligned_seconds(value: float, label: str) -> float: + frames = value * ADVERTISEMENT_FPS + if abs(frames - round(frames)) > 1.0e-6: + raise ValueError(f"{label} must align exactly to the {ADVERTISEMENT_FPS} fps advertisement clock.") + return value + + +def _schema_path(filename: str) -> Path: + return Path(__file__).resolve().parent / "schemas" / filename + + +def _validate_schema(payload: Mapping[str, Any], filename: str, label: str) -> None: + try: + from jsonschema import Draft202012Validator + except Exception as exc: # pragma: no cover - dependency is declared by the package. + raise RuntimeError("Advertisement contracts require jsonschema.") from exc + schema = json.loads(_schema_path(filename).read_text(encoding="utf-8")) + errors = sorted( + Draft202012Validator(schema).iter_errors(payload), + key=lambda item: tuple(str(part) for part in item.absolute_path), + ) + if not errors: + return + error = errors[0] + path = "$" + "".join( + f"[{part}]" if isinstance(part, int) else f".{part}" + for part in error.absolute_path + ) + raise ValueError(f"{label} failed schema validation at {path}: {error.message}") + + +def _canonical_aspect(value: Any) -> str: + match = re.fullmatch(r"\s*(\d+)\s*:\s*(\d+)\s*", str(value or "")) + if not match: + raise ValueError("aspect_ratio must be a supported W:H ratio.") + aspect = f"{int(match.group(1))}:{int(match.group(2))}" + if aspect not in ASPECT_RATIOS: + raise ValueError("aspect_ratio must be one of " + ", ".join(ASPECT_RATIOS) + ".") + return aspect + + +def _normalize_claims(claims_json: Any) -> tuple[list[dict[str, Any]], list[str]]: + raw = _strict_json_array(claims_json, "claims_json") + normalized: list[dict[str, Any]] = [] + approved: list[str] = [] + for index, item in enumerate(raw): + if not isinstance(item, Mapping): + raise ValueError(f"claims_json[{index}] must be an object.") + unknown = set(item) - {"text", "status", "evidence"} + if unknown: + raise ValueError( + f"claims_json[{index}] has unknown field(s): {', '.join(sorted(unknown))}." + ) + claim = _text(item.get("text"), f"claims_json[{index}].text", maximum=300) + status = str(item.get("status", "")).strip().casefold() + if status not in {"approved", "draft", "prohibited"}: + raise ValueError( + f"claims_json[{index}].status must be approved, draft, or prohibited." + ) + evidence = _text( + item.get("evidence"), + f"claims_json[{index}].evidence", + maximum=1000, + required=False, + ) + if status == "approved" and not evidence: + raise ValueError( + f"claims_json[{index}] is approved but has no substantiation/evidence reference." + ) + row = {"text": claim, "status": status, "evidence": evidence} + normalized.append(row) + if status == "approved": + approved.append(claim) + return normalized, approved + + +def _normalize_deliverables(deliverables_json: Any, master_aspect: str) -> dict[str, Any]: + payload = _strict_json_object(deliverables_json, "deliverables_json") + unknown = set(payload) - {"primary", "adaptations", "cutdowns_seconds"} + if unknown: + raise ValueError( + "deliverables_json has unknown field(s): " + ", ".join(sorted(unknown)) + "." + ) + primary = str(payload.get("primary", master_aspect)).strip() + primary_match = re.search(r"\b(?:21:9|16:9|9:16|4:3|3:4|3:2|2:3|1:1)\b", primary) + primary_aspect = primary_match.group(0) if primary_match else _canonical_aspect(primary) + if primary_aspect != master_aspect: + raise ValueError("deliverables_json.primary must match aspect_ratio.") + adaptations: list[str] = [] + for index, item in enumerate(payload.get("adaptations", [])): + aspect = _canonical_aspect(item) + if aspect != master_aspect and aspect not in adaptations: + adaptations.append(aspect) + cutdowns: list[float] = [] + for index, item in enumerate(payload.get("cutdowns_seconds", [])): + seconds = _finite(item, f"deliverables_json.cutdowns_seconds[{index}]", minimum=0.1, maximum=60.0) + if seconds not in cutdowns: + cutdowns.append(seconds) + return { + "primary": master_aspect, + "adaptations": adaptations, + "cutdowns_seconds": sorted(cutdowns, reverse=True), + "render_status": "requested_not_rendered", + } + + +def _annotation_fields(description: str) -> dict[str, str]: + matches = _AD_ANNOTATION_RE.findall(str(description or "")) + if len(matches) != 1: + raise ValueError("Each advertisement reference row must contain exactly one [ad:...] annotation.") + fields: dict[str, str] = {} + for part in matches[0].split(";"): + if "=" not in part: + raise ValueError("Advertisement reference annotations use key=value fields separated by semicolons.") + key, value = (token.strip().casefold() for token in part.split("=", 1)) + if not key or not value or key in fields: + raise ValueError("Advertisement reference annotations contain an empty or duplicate field.") + fields[key] = value + required = {"role", "subject", "retention"} + if set(fields) != required: + raise ValueError("Advertisement reference annotations require exactly role, subject, and retention fields.") + if not re.fullmatch(r"subject_[1-9]\d*", fields["subject"]): + raise ValueError("Advertisement reference annotation subject must be subject_N.") + return fields + + +def parse_advertisement_reference_manifest(manifest: Any) -> list[dict[str, Any]]: + """Parse explicit reference semantics without inspecting prose keywords.""" + + rows: list[dict[str, Any]] = [] + for line_index, raw in enumerate(str(manifest or "").splitlines(), start=1): + if not raw.strip(): + continue + match = _REFERENCE_ROW_RE.fullmatch(raw) + if match is None: + raise ValueError(f"reference_manifest line {line_index} is not a complete reference row.") + tag = f"<{match.group(1).title()} {int(match.group(2))}>" + fields = _annotation_fields(match.group(3)) + rows.append( + { + "tag": tag, + "kind": match.group(1).casefold(), + "ordinal": int(match.group(2)), + **fields, + } + ) + if not rows: + raise ValueError("reference_manifest has no advertisement reference rows.") + tags = [row["tag"] for row in rows] + if len(tags) != len(set(tags)): + raise ValueError("reference_manifest contains duplicate reference tags.") + return rows + + +class DiffusionGemmaAdvertisementWorkflowControls: + """One authoritative control surface for settings shared across the saved graph.""" + + # Advertisement v1 is a governed 30/15/6 deliverable system. Other master + # lengths need duration-aware campaign copy and arrangement presets before + # they can be exposed without reintroducing split-setting failures. + DURATION_PRESETS = ("30 seconds",) + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "master_duration": (list(cls.DURATION_PRESETS), {"default": "30 seconds"}), + "native_shot_count": ("INT", {"default": 8, "min": 1, "max": 99}), + "performance_mode": ( + list(ADVERTISEMENT_PERFORMANCE_MODES), + {"default": ADVERTISEMENT_PERFORMANCE_MODES[0]}, + ), + } + } + + RETURN_TYPES = ( + "FLOAT", + "INT", + list(ADVERTISEMENT_PERFORMANCE_CARRIER_MODES), + "FLOAT", + "FLOAT", + "STRING", + list(ASPECT_RATIOS), + list(H3_GENERATION_MODE_CARRIER), + list(H3_SHOT_COUNT_CARRIER), + "INT", + list(H3_AUDIO_MODE_CARRIER), + list(H3_DIALOGUE_MODE_CARRIER), + "FLOAT", + "STRING", + "FLOAT", + "STRING", + "STRING", + "BOOLEAN", + ) + RETURN_NAMES = ( + "master_duration_seconds", + "native_shot_count", + "performance_mode", + "cutdown_15_start_seconds", + "cutdown_6_start_seconds", + "deliverables_json", + "master_aspect_ratio", + "h3_generation_mode", + "h3_shot_count_mode", + "h3_shot_count_override", + "h3_audio_mode", + "h3_dialogue_mode", + "excerpt_start_seconds", + "generation_model", + "max_h3_lane_seconds", + "master_aspect_ratio_override", + "status", + "ready", + ) + FUNCTION = "resolve" + CATEGORY = CATEGORY + DESCRIPTION = ( + "Provides the single duration, native-shot, and performance authority used by every Advertisement " + "consumer. Cutdown windows are derived from the master tail so governed end-card copy is retained." + ) + + def resolve(self, master_duration, native_shot_count, performance_mode): + preset = str(master_duration) + if preset not in self.DURATION_PRESETS: + raise ValueError("master_duration is unsupported.") + duration = float(preset.split(" ", 1)[0]) + shots = int(native_shot_count) + if not 1 <= shots <= 99: + raise ValueError("native_shot_count must be from 1 to 99.") + mode = str(performance_mode) + if mode not in ADVERTISEMENT_PERFORMANCE_MODES: + raise ValueError("performance_mode is unsupported.") + cutdown_15_start = duration - 15.0 + cutdown_6_start = duration - 6.0 + deliverables_json = _json( + { + "primary": "9:16", + "adaptations": ["1:1", "16:9"], + "cutdowns_seconds": [15, 6], + } + ) + return ( + duration, + shots, + mode, + cutdown_15_start, + cutdown_6_start, + deliverables_json, + "9:16", + "ref2va", + "custom", + 0, + "auto_scene_audio", + "off", + 0.0, + "MiniMax H3 Ref2VA", + 15.0, + "9:16", + f"Advertisement controls locked: {duration:g}s, {shots} native shots, {mode}.", + True, + ) + + +class DiffusionGemmaAdvertisementCampaignContract: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "brand_name": ("STRING", {"default": "", "multiline": False}), + "product_name": ("STRING", {"default": "", "multiline": False}), + "variant_name": ("STRING", {"default": "", "multiline": False}), + "audience": ("STRING", {"default": "", "multiline": True}), + "campaign_objective": ("STRING", {"default": "", "multiline": True}), + "headline": ("STRING", {"default": "", "multiline": True}), + "call_to_action": ("STRING", {"default": "", "multiline": True}), + "claims_json": ( + "STRING", + { + "default": "[]", + "multiline": True, + "tooltip": "Each claim is {text,status,evidence}. Only substantiated approved claims enter production guidance.", + }, + ), + "production_duration_seconds": ( + "FLOAT", + {"default": 30.0, "min": 1.0 / ADVERTISEMENT_FPS, "max": 60.0, "step": 1.0 / ADVERTISEMENT_FPS}, + ), + "aspect_ratio": (list(ASPECT_RATIOS), {"default": "9:16"}), + "end_card_duration_seconds": ( + "FLOAT", + {"default": 3.0, "min": 1.0 / ADVERTISEMENT_FPS, "max": 10.0, "step": 1.0 / ADVERTISEMENT_FPS}, + ), + "deliverables_json": ( + "STRING", + { + "default": '{"primary":"9:16","adaptations":["1:1","16:9"],"cutdowns_seconds":[15,6]}', + "multiline": True, + }, + ), + }, + "optional": { + "campaign_name": ("STRING", {"default": "", "multiline": False}), + "legal_line": ("STRING", {"default": "", "multiline": True}), + "offer_text": ("STRING", {"default": "", "multiline": True}), + "price_text": ("STRING", {"default": "", "multiline": False}), + }, + } + + RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING", "BOOLEAN") + RETURN_NAMES = ( + "campaign_contract_json", + "director_brief", + "exact_copy_json", + "contract_sha256", + "status", + "ready", + ) + FUNCTION = "build" + CATEGORY = CATEGORY + DESCRIPTION = "Freeze typed ad identity, exact copy, governed claims, timing, end card, and requested deliverables without claiming that variants were rendered." + + def build( + self, + brand_name, + product_name, + variant_name, + audience, + campaign_objective, + headline, + call_to_action, + claims_json, + production_duration_seconds, + aspect_ratio, + end_card_duration_seconds, + deliverables_json, + campaign_name="", + legal_line="", + offer_text="", + price_text="", + ): + brand = _copy_text(brand_name, "brand_name", maximum=120) + product = _copy_text(product_name, "product_name", maximum=160) + variant = _copy_text(variant_name, "variant_name", maximum=120, required=False) + target_audience = _text(audience, "audience", maximum=1000) + objective = _text(campaign_objective, "campaign_objective", maximum=1500) + exact_headline = _copy_text(headline, "headline", maximum=300) + cta = _copy_text(call_to_action, "call_to_action", maximum=240) + legal = _copy_text(legal_line, "legal_line", maximum=1000, required=False) + offer = _copy_text(offer_text, "offer_text", maximum=300, required=False) + price = _copy_text(price_text, "price_text", maximum=120, required=False) + campaign = _text(campaign_name, "campaign_name", maximum=160, required=False) + duration = _frame_aligned_seconds(_finite( + production_duration_seconds, + "production_duration_seconds", + minimum=1.0 / ADVERTISEMENT_FPS, + maximum=60.0, + ), "production_duration_seconds") + end_duration = _frame_aligned_seconds(_finite( + end_card_duration_seconds, + "end_card_duration_seconds", + minimum=1.0 / ADVERTISEMENT_FPS, + maximum=10.0, + ), "end_card_duration_seconds") + if end_duration >= duration: + raise ValueError("end_card_duration_seconds must be shorter than the production duration.") + aspect = _canonical_aspect(aspect_ratio) + claims, approved_claims = _normalize_claims(claims_json) + deliverables = _normalize_deliverables(deliverables_json, aspect) + if any(seconds >= duration for seconds in deliverables["cutdowns_seconds"]): + raise ValueError("Every requested cutdown must be shorter than the primary duration.") + + exact_copy = { + "brand_name": brand, + "product_name": product, + "variant_name": variant, + "headline": exact_headline, + "call_to_action": cta, + "legal_line": legal, + "offer_text": offer, + "price_text": price, + "approved_claims": approved_claims, + "render_policy": "deterministic_finishing_overlay", + } + exact_copy_json = _json(exact_copy) + payload = { + "schema": CAMPAIGN_SCHEMA, + "version": VERSION, + "campaign_name": campaign or f"{brand} {product}", + "brand": { + "brand_name": brand, + "product_name": product, + "variant_name": variant, + }, + "audience": target_audience, + "objective": objective, + "copy": exact_copy, + "claims": claims, + "timeline": { + "duration_seconds": round(duration, 6), + "aspect_ratio": aspect, + "end_card": { + "start_seconds": round(duration - end_duration, 6), + "duration_seconds": round(end_duration, 6), + "render_status": "contracted_not_rendered", + }, + }, + "deliverables": deliverables, + "exact_copy_sha256": _sha_text(exact_copy_json), + "ready": True, + } + _validate_schema(payload, "advertisement_campaign.schema.json", "campaign contract") + encoded = _json(payload) + digest = _sha_text(encoded) + approved_text = ( + "\nApproved substantiated claims: " + " | ".join(approved_claims) + if approved_claims + else "\nNo product claim is authorized for generated scene text or narration." + ) + director_brief = ( + f"ADVERTISEMENT — {payload['campaign_name']}\n" + f"Brand/product: {brand} {product}{(' — ' + variant) if variant else ''}.\n" + f"Audience: {target_audience}\nObjective: {objective}\n" + f"Create a {duration:g}-second {aspect} visual story. Reserve the final " + f"{end_duration:g} seconds for a deterministic end card added after generation. " + "Do not invent readable packaging, headlines, prices, legal wording, offers, or claims inside generated video." + f"{approved_text}\n" + f"Finishing-only exact copy: headline={exact_headline!r}; CTA={cta!r}." + ) + excluded = sum(row["status"] != "approved" for row in claims) + status = ( + f"Advertisement campaign contract locked for {duration:g}s {aspect}; " + f"{len(approved_claims)} approved claim(s), {excluded} draft/prohibited claim(s) excluded, " + f"and {len(deliverables['adaptations']) + len(deliverables['cutdowns_seconds'])} requested variant(s) remain unrendered." + ) + return (encoded, director_brief, exact_copy_json, digest, status, True) + + +class DiffusionGemmaAdvertisementReferenceContract: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "slot_policy": ( + list(EXPORTED_REFERENCE_SLOT_POLICIES), + {"default": EXPORTED_REFERENCE_SLOT_POLICIES[0]}, + ), + "performer_description": ("STRING", {"default": "", "multiline": True}), + "product_description": ("STRING", {"default": "", "multiline": True}), + "product_retention_attributes": ("STRING", {"default": "", "multiline": True}), + "product_reference_kind": (list(PRODUCT_REFERENCE_KINDS), {"default": "product contact sheet"}), + }, + "optional": { + "performer_retention_attributes": ("STRING", {"default": "identity, face, hair, body, wardrobe", "multiline": True}), + "package_copy_json": ("STRING", {"default": "[]", "multiline": True}), + }, + } + + RETURN_TYPES = ("STRING", "STRING", "INT", "INT", "STRING", "STRING", "STRING", "BOOLEAN") + RETURN_NAMES = ( + "reference_contract_json", + "reference_manifest", + "expected_subject_count", + "performer_picture_count", + "product_picture_tag", + "continuity_picture_tag", + "status", + "ready", + ) + FUNCTION = "build" + CATEGORY = CATEGORY + DESCRIPTION = "Assign explicit performer, product/package, and optional relay roles to H3 Picture slots without natural-language wording passwords." + + def build( + self, + slot_policy, + performer_description, + product_description, + product_retention_attributes, + product_reference_kind, + performer_retention_attributes="identity, face, hair, body, wardrobe", + package_copy_json="[]", + ): + policy = str(slot_policy) + if policy not in REFERENCE_SLOT_POLICIES: + raise ValueError("slot_policy is unsupported.") + performer = _text(performer_description, "performer_description", maximum=2000) + product = _text(product_description, "product_description", maximum=2000) + performer_retention = _text( + performer_retention_attributes, + "performer_retention_attributes", + maximum=1000, + ) + product_retention = _text( + product_retention_attributes, + "product_retention_attributes", + maximum=1000, + ) + reference_kind = str(product_reference_kind) + if reference_kind not in PRODUCT_REFERENCE_KINDS: + raise ValueError("product_reference_kind is unsupported.") + raw_package_copy = _strict_json_array(package_copy_json, "package_copy_json") + package_copy: list[str] = [] + for index, item in enumerate(raw_package_copy): + value = _text(item, f"package_copy_json[{index}]", maximum=160) + if value not in package_copy: + package_copy.append(value) + + two_performer = policy != "1 performer + 1 product + relay" + relay_enabled = policy != "2 performer + 1 product; relay off" + performer_tags = ["", ""] if two_performer else [""] + product_tag = "" if two_performer else "" + relay_tag = ( + ("" if two_performer else "") + if relay_enabled + else "" + ) + slots: list[dict[str, Any]] = [] + manifest_rows: list[str] = [] + for index, tag in enumerate(performer_tags): + role = "performer_primary" if index == 0 else "performer_supplemental" + slots.append( + { + "picture_tag": tag, + "role": role, + "subject_tag": "", + "retention": "fully_preserved", + "description": performer, + "non_transfer": [] if index == 0 else ["layout", "background", "pose sequence"], + } + ) + categories = "identity,appearance,composition" if index == 0 else "identity,appearance" + manifest_rows.append( + f"{tag}: [dg:{categories}] [ad:role={role};subject=subject_1;retention=fully_preserved] " + + performer + ) + + product_non_transfer = ( + ["grid", "seams", "backgrounds", "panel multiplicity", "viewpoint sequence"] + if reference_kind == "product contact sheet" + else [] + ) + slots.append( + { + "picture_tag": product_tag, + "role": "product_package", + "subject_tag": "", + "retention": "fully_preserved", + "description": product, + "reference_kind": reference_kind.replace(" ", "_"), + "non_transfer": product_non_transfer, + } + ) + non_transfer_text = ( + " The contact-sheet grid, seams, backgrounds, repeated panels, and viewpoint sequence do not transfer." + if product_non_transfer + else "" + ) + manifest_rows.append( + f"{product_tag}: [dg:object,color,text,appearance] " + "[ad:role=product_package;subject=subject_2;retention=fully_preserved] " + f"{product}{non_transfer_text}" + ) + manifest = "\n".join(manifest_rows) + parsed_roles = parse_advertisement_reference_manifest(manifest) + if [row["role"] for row in parsed_roles] != [slot["role"] for slot in slots]: + raise ValueError("Internal advertisement reference-role serialization is inconsistent.") + + payload = { + "schema": REFERENCE_SCHEMA, + "version": VERSION, + "slot_policy": ( + "performer_2_product_1_relay_1" + if two_performer and relay_enabled + else ( + "performer_1_product_1_relay_1" + if relay_enabled + else "performer_2_product_1_relay_off" + ) + ), + "subjects": [ + { + "subject_tag": "", + "role": "performer", + "description": performer, + "retention_attributes": performer_retention, + }, + { + "subject_tag": "", + "role": "product_package", + "description": product, + "retention_attributes": product_retention, + "exact_package_copy": package_copy, + "copy_fidelity_policy": "qa_required_not_guaranteed_by_generation", + }, + ], + "picture_slots": slots, + "relay": { + "enabled": bool(relay_tag), + "picture_tag": relay_tag, + "role": "previous_lane_pose_spatial_lighting_motion_only_not_identity_or_product", + "manifest_reservation": ( + f"{relay_tag}: [dg:action,motion,camera,temporal,spatial,lighting] " + "[ad:role=continuity_relay;subject=subject_1;retention=continuity_only] " + "Reserved for the immediately previous lane tail; never performer identity or product/package authority." + if relay_tag + else "" + ), + }, + "reference_manifest": manifest, + "reference_manifest_sha256": _sha_text(manifest), + "expected_subject_count": 2, + "ready": True, + } + _validate_schema(payload, "advertisement_reference.schema.json", "reference contract") + encoded = _json(payload) + status = ( + f"Advertisement references reserve {len(performer_tags)} performer Picture(s), " + f"{product_tag} for the independent product package, and " + + (f"{relay_tag} for later-lane continuity." if relay_tag else "no relay slot.") + ) + return ( + encoded, + manifest, + 2, + len(performer_tags), + product_tag, + relay_tag, + status, + True, + ) + + +class DiffusionGemmaAdvertisementReferenceAssetPrep: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "performer_hero_image": ( + "IMAGE", + {"tooltip": "Picture 1: primary performer identity, body, wardrobe, and composition authority."}, + ), + "performer_sheet_image": ( + "IMAGE", + {"tooltip": "Picture 2: same-performer supplemental identity sheet. It never carries product authority."}, + ), + "product_sheet_image": ( + "IMAGE", + {"tooltip": "Picture 3: independent product/package reference or product-only contact sheet."}, + ), + "generation_width": ("INT", {"default": 480, "min": 256, "max": 8192, "step": 8}), + "generation_height": ("INT", {"default": 864, "min": 256, "max": 8192, "step": 8}), + "performer_area_ratio": ( + "FLOAT", + { + "default": 1.0, + "min": 0.25, + "max": 2.0, + "step": 0.05, + "tooltip": "Combined pixel-area budget for Pictures 1 and 2 relative to one generation frame.", + }, + ), + "performer_hero_share": ( + "FLOAT", + { + "default": 0.6, + "min": 0.2, + "max": 0.8, + "step": 0.05, + "tooltip": "Share of the performer-only budget assigned to Picture 1; Picture 2 receives the remainder.", + }, + ), + "product_area_ratio": ( + "FLOAT", + { + "default": 0.5, + "min": 0.1, + "max": 1.5, + "step": 0.05, + "tooltip": "Independent Picture 3 product budget relative to one generation frame; it is never borrowed by performer assets.", + }, + ), + } + } + + RETURN_TYPES = ( + "IMAGE", + "IMAGE", + "IMAGE", + "STRING", + "STRING", + "STRING", + "IMAGE", + "STRING", + "STRING", + "BOOLEAN", + ) + RETURN_NAMES = ( + "performer_hero_image", + "performer_sheet_image", + "product_sheet_image", + "performer_hero_sha256", + "performer_sheet_sha256", + "product_sheet_sha256", + "director_reference_batch", + "asset_manifest_json", + "status", + "ready", + ) + FUNCTION = "prepare" + CATEGORY = CATEGORY + DESCRIPTION = "Downscale-only prep and deterministic hashing for separate performer hero, same-performer sheet, and product sheet roles, plus a padded Director-analysis batch." + + def prepare( + self, + performer_hero_image, + performer_sheet_image, + product_sheet_image, + generation_width, + generation_height, + performer_area_ratio, + performer_hero_share, + product_area_ratio, + ): + width = int(generation_width) + height = int(generation_height) + if width < 256 or height < 256 or width > 8192 or height > 8192: + raise ValueError("generation_width and generation_height must be from 256 to 8192.") + performer_ratio = _finite( + performer_area_ratio, "performer_area_ratio", minimum=0.25, maximum=2.0 + ) + hero_share = _finite( + performer_hero_share, "performer_hero_share", minimum=0.2, maximum=0.8 + ) + product_ratio = _finite( + product_area_ratio, "product_area_ratio", minimum=0.1, maximum=1.5 + ) + source_images = ( + _image_tensor(performer_hero_image, "performer_hero_image"), + _image_tensor(performer_sheet_image, "performer_sheet_image"), + _image_tensor(product_sheet_image, "product_sheet_image"), + ) + source_hashes = tuple(_image_content_sha256(image) for image in source_images) + if len(set(source_hashes)) != 3: + raise ValueError( + "Performer hero, performer sheet, and product sheet must be three distinct source images." + ) + frame_area = float(width * height) + performer_area = frame_area * performer_ratio + budgets = ( + performer_area * hero_share, + performer_area * (1.0 - hero_share), + frame_area * product_ratio, + ) + prepared = tuple( + _budgeted_image(image, maximum_area=budget) + for image, budget in zip(source_images, budgets) + ) + roles = ("performer_hero", "performer_sheet", "product_sheet") + prepared_role_hashes = tuple( + _image_sha256(image, role) for image, role in zip(prepared, roles) + ) + director_batch = torch.cat( + [_director_canvas(image, width, height) for image in prepared], dim=0 + ) + assets = [] + for index, (role, image, source_digest, prepared_digest, budget) in enumerate( + zip(roles, prepared, source_hashes, prepared_role_hashes, budgets), start=1 + ): + assets.append( + { + "picture_tag": f"", + "role": role, + "sha256": source_digest, + "prepared_role_sha256": prepared_digest, + "width": int(image.shape[2]), + "height": int(image.shape[1]), + "pixel_area_budget": int(math.floor(budget)), + "upscaled": False, + } + ) + manifest = { + "schema": "diffusiongemma.advertisement_reference_assets", + "version": 1, + "generation_width": width, + "generation_height": height, + "performer_budget": { + "area_ratio": performer_ratio, + "hero_share": hero_share, + "roles": ["performer_hero", "performer_sheet"], + }, + "product_budget": { + "area_ratio": product_ratio, + "independent_from_performer_budget": True, + "role": "product_sheet", + }, + "assets": assets, + "director_batch": { + "count": 3, + "width": width, + "height": height, + "aspect_policy": "preserve_then_neutral_pad", + "role_order": list(roles), + }, + "identity_product_combined_sheet": False, + "ready": True, + } + _validate_schema( + manifest, + "advertisement_reference_assets.schema.json", + "advertisement reference assets", + ) + status = ( + "Prepared and hash-locked Picture 1 performer hero, Picture 2 same-performer sheet, " + "and Picture 3 independent product sheet; the product received a separate pixel budget." + ) + return ( + prepared[0], + prepared[1], + prepared[2], + source_hashes[0], + source_hashes[1], + source_hashes[2], + director_batch, + _json(manifest), + status, + True, + ) + + +def _normalize_tagged_lyrics(value: Any, effective_mode: str) -> str: + lyrics = str(value or "").strip() + if effective_mode == "instrumental": + if lyrics and lyrics.casefold() not in {"instrumental", "[instrumental]"}: + raise ValueError("Instrumental soundtrack mode cannot contain sung lyrics.") + # Music 3 may emit an early end token when given only a single + # instrumental sentinel, even when max_duration is substantially + # longer. A sparse, wordless section scaffold preserves the strict + # no-vocal contract while giving the duration model enough structure + # to realize a commercial-length intro, development, and outro. + return "[Intro]\n\n[Instrumental]\n\n[Bridge]\n\n[Instrumental]\n\n[Outro]" + if not lyrics or lyrics.casefold() in {"instrumental", "[instrumental]"}: + raise ValueError("Vocal soundtrack mode requires real lyrics.") + if not _LYRIC_SECTION_RE.search(lyrics): + lyrics = "[Verse]\n" + lyrics + if len(lyrics) > 4000: + raise ValueError("tagged lyrics exceed 4000 characters.") + return lyrics + + +class DiffusionGemmaAdvertisementSoundtrackContract: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "content_mode": (list(SOUNDTRACK_CONTENT_MODES), {"default": "Instrumental"}), + "genre_style": ("STRING", {"default": "", "multiline": True}), + "mood": ("STRING", {"default": "", "multiline": True}), + "instrumentation": ("STRING", {"default": "", "multiline": True}), + "target_duration_seconds": ( + "FLOAT", + { + "default": 30.0, + "min": 0.1, + "max": 60.0, + "step": 0.1, + "tooltip": "Selectable advertisement master duration. Music3 generation receives five seconds of selection headroom when the 60-second cap allows it.", + }, + ), + "bpm": ("FLOAT", {"default": 105.0, "min": 0.0, "max": 300.0, "step": 0.1}), + "time_signature": (list(TIME_SIGNATURES), {"default": "4"}), + "language": (list(LANGUAGES), {"default": "unknown"}), + "lyrics": ("STRING", {"default": "", "multiline": True}), + "voice_over_policy": ( + list(EXPORTED_VOICE_OVER_POLICIES), + {"default": EXPORTED_VOICE_OVER_POLICIES[0]}, + ), + }, + "optional": { + "arrangement_notes": ("STRING", {"default": "", "multiline": True}), + "do_not_sound_like": ("STRING", {"default": "", "multiline": True}), + }, + } + + RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "FLOAT", "FLOAT", "STRING", "BOOLEAN") + RETURN_NAMES = ( + "soundtrack_contract_json", + "music3_caption", + "tagged_lyrics", + "effective_content_mode", + "expected_bpm", + "generation_duration_seconds", + "status", + "ready", + ) + FUNCTION = "build" + CATEGORY = CATEGORY + DESCRIPTION = "Create a structured Music3/ACE-style caption and tagged lyrics while keeping soundtrack vocals distinct from non-diegetic advertisement VO." + + def build( + self, + content_mode, + genre_style, + mood, + instrumentation, + target_duration_seconds, + bpm, + time_signature, + language, + lyrics, + voice_over_policy, + arrangement_notes="", + do_not_sound_like="", + ): + requested = str(content_mode) + if requested not in SOUNDTRACK_CONTENT_MODES: + raise ValueError("content_mode is unsupported.") + style = _text(genre_style, "genre_style", maximum=1000) + mood_text = _text(mood, "mood", maximum=600) + instruments = _text(instrumentation, "instrumentation", maximum=1000) + arrangement = _text(arrangement_notes, "arrangement_notes", maximum=1500, required=False) + exclusions = _text(do_not_sound_like, "do_not_sound_like", maximum=600, required=False) + master_duration = _finite( + target_duration_seconds, + "target_duration_seconds", + minimum=0.1, + maximum=60.0, + ) + generation_duration = min(60.0, master_duration + 5.0) + selection_headroom = generation_duration - master_duration + expected_bpm = _finite(bpm, "bpm", minimum=0.0, maximum=300.0) + meter = str(time_signature) + if meter not in TIME_SIGNATURES: + raise ValueError("time_signature is unsupported.") + lang = str(language) + if lang not in LANGUAGES: + raise ValueError("language is unsupported.") + vo_policy = str(voice_over_policy) + if vo_policy not in VOICE_OVER_POLICIES: + raise ValueError("voice_over_policy is unsupported.") + raw_lyrics = str(lyrics or "").strip() + effective = ( + "vocal" + if requested == "Vocal" or (requested == "Auto" and raw_lyrics.casefold() not in {"", "instrumental", "[instrumental]"}) + else "instrumental" + ) + tagged = _normalize_tagged_lyrics(raw_lyrics, effective) + vocal_sentence = ( + f"Vocal content: sung lyrics in language {lang}." + if effective == "vocal" + else "Vocal content: instrumental, with no intelligible sung or spoken words." + ) + tempo_sentence = ( + f"Tempo: {expected_bpm:g} BPM in {meter}/4." + if expected_bpm > 0.0 + else f"Tempo: signal-selected in {meter}/4." + ) + caption_parts = [ + "Global Metadata:", + f"Generation duration: {generation_duration:g} seconds. Selectable advertisement master: {master_duration:g} seconds. Selection headroom: {selection_headroom:g} seconds.", + f"Style: {style}. Mood: {mood_text}. {tempo_sentence}", + "Vocal Details:", + vocal_sentence, + "Arrangement:", + f"Instrumentation: {instruments}.", + ( + arrangement + if arrangement + else "Build one complete ad-ready musical arc inside the selectable master and keep clean edit points for picture synchronization." + ), + ] + if exclusions: + caption_parts.append(f"Avoid: {exclusions}.") + caption = " ".join(caption_parts) + payload = { + "schema": SOUNDTRACK_SCHEMA, + "version": VERSION, + "requested_content_mode": requested.casefold(), + "effective_content_mode": effective, + "music3": { + "caption": caption, + "tagged_lyrics": tagged, + "bpm": expected_bpm, + "time_signature": meter, + "language": lang, + "target_duration_seconds": master_duration, + "generation_duration_seconds": generation_duration, + "selection_headroom_seconds": selection_headroom, + }, + "voice_over": { + "policy": "separate_non_diegetic" if vo_policy == VOICE_OVER_POLICIES[1] else "none", + "included_in_motion_guide": False, + "included_in_final_mix": vo_policy == VOICE_OVER_POLICIES[1], + }, + "content_evidence_policy": ( + "vocal_proxy_required" + if effective == "vocal" + else "vocal_proxy_not_required" + ), + "ready": True, + } + _validate_schema(payload, "advertisement_soundtrack.schema.json", "soundtrack contract") + encoded = _json(payload) + status = ( + f"Advertisement soundtrack locked as {effective}; Music3 will generate {generation_duration:g}s " + f"for a {master_duration:g}s selectable advertisement master. " + + ( + "Voice-over remains a separate final-mix input and is excluded from the motion guide." + if vo_policy == VOICE_OVER_POLICIES[1] + else "No voice-over is contracted." + ) + ) + return ( + encoded, + caption, + tagged, + effective, + expected_bpm, + generation_duration, + status, + True, + ) + + +class DiffusionGemmaAdvertisementMusic3PromptAdapter: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "soundtrack_contract_json": ( + "STRING", + {"default": "", "multiline": True, "forceInput": True}, + ) + } + } + + RETURN_TYPES = ("STRING", "STRING", "STRING", "FLOAT", "FLOAT", "INT", "STRING", "BOOLEAN") + RETURN_NAMES = ( + "caption", + "tagged_lyrics", + "content_mode", + "bpm", + "generation_duration_seconds", + "ace_bpm_integer", + "status", + "ready", + ) + FUNCTION = "adapt" + CATEGORY = CATEGORY + DESCRIPTION = "Validate and expose only the structured Music3 caption and tagged lyrics from an advertisement soundtrack contract." + + def adapt(self, soundtrack_contract_json): + payload = _strict_json_object(soundtrack_contract_json, "soundtrack_contract_json") + _validate_schema(payload, "advertisement_soundtrack.schema.json", "soundtrack contract") + if payload.get("schema") != SOUNDTRACK_SCHEMA or payload.get("version") != VERSION or not payload.get("ready"): + raise ValueError("soundtrack_contract_json is not a ready supported advertisement soundtrack contract.") + music = payload["music3"] + bpm = float(music["bpm"]) + ace_bpm = int(round(bpm)) + if ace_bpm < 10: + # Zero means signal-selected/unspecified to the shared soundtrack + # contract, but the optional ACE encoder requires an integer in + # [10, 300]. Keep the default Music3/upload paths independent and + # give the explicitly selected ACE fallback a neutral valid tempo. + ace_bpm = 120 + if ace_bpm > 300: + raise ValueError("Music3 BPM exceeds the ACE fallback integer carrier.") + status = ( + f"Music3 prompt adapter emitted the governed {payload['effective_content_mode']} caption without changing it; " + f"ACE fallback BPM carrier={ace_bpm}." + ) + return ( + str(music["caption"]), + str(music["tagged_lyrics"]), + str(payload["effective_content_mode"]), + bpm, + float(music["generation_duration_seconds"]), + ace_bpm, + status, + True, + ) + + +NODE_CLASS_MAPPINGS = { + "DiffusionGemmaAdvertisementWorkflowControls": DiffusionGemmaAdvertisementWorkflowControls, + "DiffusionGemmaAdvertisementCampaignContract": DiffusionGemmaAdvertisementCampaignContract, + "DiffusionGemmaAdvertisementReferenceContract": DiffusionGemmaAdvertisementReferenceContract, + "DiffusionGemmaAdvertisementReferenceAssetPrep": DiffusionGemmaAdvertisementReferenceAssetPrep, + "DiffusionGemmaAdvertisementSoundtrackContract": DiffusionGemmaAdvertisementSoundtrackContract, + "DiffusionGemmaAdvertisementMusic3PromptAdapter": DiffusionGemmaAdvertisementMusic3PromptAdapter, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "DiffusionGemmaAdvertisementWorkflowControls": "DiffusionGemma Advertisement Workflow Controls", + "DiffusionGemmaAdvertisementCampaignContract": "DiffusionGemma Advertisement Campaign Contract", + "DiffusionGemmaAdvertisementReferenceContract": "DiffusionGemma Advertisement Reference Contract", + "DiffusionGemmaAdvertisementReferenceAssetPrep": "DiffusionGemma Advertisement Reference Asset Prep & Hash", + "DiffusionGemmaAdvertisementSoundtrackContract": "DiffusionGemma Advertisement Soundtrack Contract", + "DiffusionGemmaAdvertisementMusic3PromptAdapter": "DiffusionGemma Advertisement Music3 Prompt Adapter", +} + + +__all__ = [ + "CAMPAIGN_SCHEMA", + "REFERENCE_SCHEMA", + "SOUNDTRACK_SCHEMA", + "ADVERTISEMENT_PERFORMANCE_MODES", + "ADVERTISEMENT_PERFORMANCE_CARRIER_MODES", + "DiffusionGemmaAdvertisementWorkflowControls", + "DiffusionGemmaAdvertisementCampaignContract", + "DiffusionGemmaAdvertisementReferenceContract", + "DiffusionGemmaAdvertisementReferenceAssetPrep", + "DiffusionGemmaAdvertisementSoundtrackContract", + "DiffusionGemmaAdvertisementMusic3PromptAdapter", + "parse_advertisement_reference_manifest", + "NODE_CLASS_MAPPINGS", + "NODE_DISPLAY_NAME_MAPPINGS", +] diff --git a/advertising_finishing_nodes.py b/advertising_finishing_nodes.py new file mode 100644 index 0000000..a5c10e2 --- /dev/null +++ b/advertising_finishing_nodes.py @@ -0,0 +1,1084 @@ +# Copyright (c) 2026 exportAnything. All rights reserved. +# SPDX-License-Identifier: MIT + +"""Deterministic assembly, delivery, and QA nodes for advertisement workflows. + +The implementation is deliberately separate from the established music-video +nodes. It accepts only the versioned advertisement planning contracts and +does not alter any legacy output or saved-workflow interface. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import re +from typing import Any, Mapping + +import numpy as np +import torch +from PIL import Image, ImageColor, ImageDraw, ImageFont + +try: + from .advertising_planning_nodes import ( + ADVERTISEMENT_MASTER_SCHEMA, + ADVERTISEMENT_PLAN_SCHEMA, + H3_FPS, + VERSION, + _contract, + _json, + _plan, + _tensor_sha256, + ) + from .audio_production_nodes import waveform_sha256 +except ImportError: # Standalone repository tests. + from advertising_planning_nodes import ( + ADVERTISEMENT_MASTER_SCHEMA, + ADVERTISEMENT_PLAN_SCHEMA, + H3_FPS, + VERSION, + _contract, + _json, + _plan, + _tensor_sha256, + ) + from audio_production_nodes import waveform_sha256 + + +CATEGORY = "prompt/diffusiongemma/advertising" +ADVERTISEMENT_ASSEMBLY_SCHEMA = "diffusiongemma.advertisement_master_assembly" +ADVERTISEMENT_END_CARD_SCHEMA = "diffusiongemma.advertisement_end_card" +ADVERTISEMENT_DELIVERY_SCHEMA = "diffusiongemma.advertisement_delivery_manifest" +ADVERTISEMENT_ADAPTATION_SCHEMA = "diffusiongemma.advertisement_adaptation_status" +ADVERTISEMENT_MEDIA_QA_SCHEMA = "diffusiongemma.advertisement_media_qa" +ASPECT_RE = re.compile(r"^(?:21:9|16:9|9:16|4:3|3:4|3:2|2:3|1:1)$") +QA_STATES = ("pass", "fail", "not_measured") +MAX_END_CARD_TENSOR_BYTES = 2 * 1024 * 1024 * 1024 +MANDATORY_MEDIA_QA_CHECKS = ( + "product_identity", + "performer_identity", + "copy_legibility", + "audio_sync", + "technical_integrity", +) + + +def _parse_object(value: Any, label: str) -> dict[str, Any]: + try: + parsed = json.loads(str(value or "")) + except json.JSONDecodeError as exc: + raise ValueError(f"{label} must be valid JSON.") from exc + if not isinstance(parsed, dict): + raise ValueError(f"{label} must be a JSON object.") + return parsed + + +def _rgb_images(value: Any, label: str) -> torch.Tensor: + tensor = value if isinstance(value, torch.Tensor) else torch.as_tensor(value) + if tensor.ndim != 4 or int(tensor.shape[0]) < 1 or int(tensor.shape[-1]) != 3: + raise ValueError(f"{label} must be a non-empty ComfyUI RGB IMAGE batch.") + if not tensor.is_floating_point(): + tensor = tensor.to(dtype=torch.float32) + if not torch.isfinite(tensor).all(): + raise ValueError(f"{label} contains non-finite pixels.") + return tensor + + +def _audio_parts(audio: Any, label: str = "audio") -> tuple[torch.Tensor, int]: + if not isinstance(audio, Mapping): + raise ValueError(f"{label} must be a ComfyUI AUDIO mapping.") + waveform = audio.get("waveform") + if not isinstance(waveform, torch.Tensor) or waveform.ndim != 3: + raise ValueError(f"{label} waveform must have shape [batch, channels, samples].") + try: + sample_rate = int(audio.get("sample_rate", 0)) + except (TypeError, ValueError) as exc: + raise ValueError(f"{label} sample_rate must be a positive integer.") from exc + if sample_rate <= 0 or int(waveform.shape[-1]) < 1: + raise ValueError(f"{label} must contain samples at a positive sample rate.") + if not torch.isfinite(waveform).all(): + raise ValueError(f"{label} contains non-finite samples.") + return waveform, sample_rate + + +def _validate_identity(contract: Mapping[str, Any], plan: Mapping[str, Any]) -> None: + if plan.get("schema") != ADVERTISEMENT_PLAN_SCHEMA or plan.get("version") != VERSION: + raise ValueError("advertisement_plan_json has an unsupported schema or version.") + if str(plan.get("campaign_id", "")) != str(contract.get("campaign_id", "")): + raise ValueError("Advertisement plan and master contract campaign identities disagree.") + if str(plan.get("advertisement_contract_sha256", "")) != str(contract.get("contract_sha256", "")): + raise ValueError("Advertisement plan was compiled from a different master contract revision.") + if abs(float(plan.get("production_duration_seconds", -1.0)) - float(contract["production_duration_seconds"])) > 1.0e-6: + raise ValueError("Advertisement plan and master contract durations disagree.") + if int(plan.get("fps", 0)) != int(contract.get("fps", 0)): + raise ValueError("Advertisement plan and master contract frame rates disagree.") + + +def _expected_master_frames(contract: Mapping[str, Any]) -> int: + fps = int(contract.get("fps", H3_FPS)) + if fps != H3_FPS: + raise ValueError(f"Advertisement H3 masters must use exactly {H3_FPS} fps.") + return int(round(float(contract["production_duration_seconds"]) * fps)) + + +def _validate_pixel_aspect( + contract: Mapping[str, Any], width: int, height: int +) -> dict[str, Any]: + expected = str(contract.get("master_aspect_ratio", "")) + if not ASPECT_RE.fullmatch(expected): + raise ValueError("Advertisement contract has an unsupported master aspect ratio.") + if width <= 0 or height <= 0: + raise ValueError("Advertisement master pixel dimensions must be positive.") + numerator, denominator = (int(value) for value in expected.split(":")) + expected_ratio = numerator / denominator + actual_ratio = width / height + relative_error = abs(actual_ratio - expected_ratio) / expected_ratio + # H3's native dimensions are rounded to model-compatible multiples. The + # 480x864 9:16 preset differs by ~1.2%, so permit that quantization while + # rejecting a genuinely different shape such as 1:1 or 16:9. + tolerance = 0.04 + if relative_error > tolerance: + raise ValueError( + f"Advertisement master pixels are {width}:{height}, which does not match " + f"the contracted {expected} aspect ratio within {tolerance:.0%}." + ) + return { + "contracted_aspect_ratio": expected, + "pixel_width": width, + "pixel_height": height, + "actual_ratio": round(actual_ratio, 9), + "relative_error": round(relative_error, 9), + "tolerance": tolerance, + "matched": True, + } + + +def _verify_audio(contract: Mapping[str, Any], audio: Any, *, exact_duration: bool) -> tuple[torch.Tensor, int]: + waveform, sample_rate = _audio_parts(audio, "master_audio") + expected_hash = str(contract.get("audio_lock", {}).get("waveform_sha256", "")) + actual_hash = waveform_sha256(audio) + if actual_hash != expected_hash: + raise ValueError("Master audio SHA-256 does not match the advertisement campaign lock.") + if exact_duration: + expected_samples = int(round(float(contract["production_duration_seconds"]) * sample_rate)) + if int(waveform.shape[-1]) != expected_samples: + raise ValueError( + f"Master audio must contain exactly {expected_samples} samples for this campaign; " + f"received {int(waveform.shape[-1])}." + ) + return waveform, sample_rate + + +def _manifest_sha(manifest: Mapping[str, Any]) -> str: + return hashlib.sha256(_json(manifest).encode("utf-8")).hexdigest() + + +def _verify_embedded_hash( + payload: Mapping[str, Any], field: str, label: str +) -> str: + supplied = str(payload.get(field, "")).strip().casefold() + if not re.fullmatch(r"[0-9a-f]{64}", supplied): + raise ValueError(f"{label} has no valid {field}.") + identity = dict(payload) + identity.pop(field, None) + if _manifest_sha(identity) != supplied: + raise ValueError(f"{label} {field} does not match its canonical content.") + return supplied + + +class AdvertisementMasterAssembler: + """Assemble retained H3 lane frames under exact campaign/audio identity.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "advertisement_contract_json": ("STRING", {"forceInput": True, "multiline": True}), + "advertisement_plan_json": ("STRING", {"forceInput": True, "multiline": True}), + "master_audio": ("AUDIO",), + }, + "optional": { + "lane_1_images": ("IMAGE", {"lazy": True}), + "lane_2_images": ("IMAGE", {"lazy": True}), + "lane_3_images": ("IMAGE", {"lazy": True}), + "lane_4_images": ("IMAGE", {"lazy": True}), + }, + } + + RETURN_TYPES = ("IMAGE", "AUDIO", "STRING", "STRING", "BOOLEAN") + RETURN_NAMES = ("assembled_master", "locked_master_audio", "assembly_report_json", "status", "ready") + FUNCTION = "assemble" + CATEGORY = CATEGORY + DESCRIPTION = ( + "Trims each H3 lane to its exact retained-frame contract and assembles one campaign- and " + "audio-locked master. Extra H3 padding frames are never allowed into the delivery master." + ) + + @staticmethod + def _required_inputs(advertisement_plan_json: str) -> list[str]: + plan = _plan(advertisement_plan_json) + return [f"lane_{index}_images" for index in range(1, len(plan["lanes"]) + 1)] + + def check_lazy_status( + self, + advertisement_contract_json: str, + advertisement_plan_json: str, + master_audio: Any, + lane_1_images: Any = None, + lane_2_images: Any = None, + lane_3_images: Any = None, + lane_4_images: Any = None, + ) -> list[str]: + supplied = (lane_1_images, lane_2_images, lane_3_images, lane_4_images) + required = self._required_inputs(advertisement_plan_json) + return [name for index, name in enumerate(required) if supplied[index] is None] + + def assemble( + self, + advertisement_contract_json: str, + advertisement_plan_json: str, + master_audio: Any, + lane_1_images: Any = None, + lane_2_images: Any = None, + lane_3_images: Any = None, + lane_4_images: Any = None, + ): + contract = _contract(advertisement_contract_json) + plan = _plan(advertisement_plan_json) + _validate_identity(contract, plan) + _verify_audio(contract, master_audio, exact_duration=True) + supplied = (lane_1_images, lane_2_images, lane_3_images, lane_4_images) + lane_count = len(plan["lanes"]) + if not 1 <= lane_count <= 4: + raise ValueError("Advertisement assembly requires from one to four generation lanes.") + retained: list[torch.Tensor] = [] + lane_reports: list[dict[str, Any]] = [] + dimensions: tuple[int, int] | None = None + running_frame = 0 + for index, lane in enumerate(plan["lanes"]): + images = supplied[index] + if images is None: + raise ValueError(f"Advertisement lane {index + 1} images are missing.") + tensor = _rgb_images(images, f"lane_{index + 1}_images") + current_dimensions = (int(tensor.shape[1]), int(tensor.shape[2])) + if dimensions is None: + dimensions = current_dimensions + elif current_dimensions != dimensions: + raise ValueError("All advertisement H3 lanes must have identical pixel dimensions.") + frame_count = int(lane.get("retained_master_frames", 0)) + if frame_count <= 0 or int(tensor.shape[0]) < frame_count: + raise ValueError( + f"Advertisement lane {index + 1} needs {frame_count} retained frames, " + f"but received {int(tensor.shape[0])}." + ) + expected_start = int(lane.get("master_frame_start", -1)) + expected_end = int(lane.get("master_frame_end_exclusive", -1)) + if expected_start != running_frame or expected_end - expected_start != frame_count: + raise ValueError(f"Advertisement lane {index + 1} has a non-contiguous master-frame contract.") + segment = tensor[:frame_count] + retained.append(segment) + lane_reports.append( + { + "lane_index": index + 1, + "received_frames": int(tensor.shape[0]), + "retained_frames": frame_count, + "discarded_padding_frames": int(tensor.shape[0]) - frame_count, + "master_frame_start": expected_start, + "master_frame_end_exclusive": expected_end, + } + ) + running_frame = expected_end + master = torch.cat(retained, dim=0) + expected_frames = _expected_master_frames(contract) + if running_frame != expected_frames or int(master.shape[0]) != expected_frames: + raise ValueError( + f"Advertisement assembly must contain exactly {expected_frames} frames; " + f"the lane plan produced {int(master.shape[0])}." + ) + pixel_aspect_evidence = _validate_pixel_aspect( + contract, int(master.shape[2]), int(master.shape[1]) + ) + report: dict[str, Any] = { + "schema": ADVERTISEMENT_ASSEMBLY_SCHEMA, + "version": VERSION, + "campaign_id": contract["campaign_id"], + "advertisement_contract_sha256": contract["contract_sha256"], + "advertisement_plan_sha256": plan["plan_sha256"], + "master_audio_sha256": waveform_sha256(master_audio), + "fps": H3_FPS, + "exact_master_frames": expected_frames, + "pixel_dimensions": [int(master.shape[2]), int(master.shape[1])], + "pixel_aspect_evidence": pixel_aspect_evidence, + "assembled_pixel_sha256": _tensor_sha256(master), + "lanes": lane_reports, + "ready": True, + } + report["report_sha256"] = _manifest_sha(report) + return ( + master, + master_audio, + _json(report), + f"Assembled exact {expected_frames}-frame advertisement master from {lane_count} H3 lanes.", + True, + ) + + +def _color(value: str, label: str) -> tuple[int, int, int]: + try: + rgb = ImageColor.getrgb(str(value or "")) + except ValueError as exc: + raise ValueError(f"{label} must be a valid CSS color or #RRGGBB value.") from exc + if len(rgb) != 3: + raise ValueError(f"{label} must be an opaque RGB color.") + return tuple(int(component) for component in rgb) + + +def _font(size: int, *, bold: bool) -> ImageFont.ImageFont: + names = ("DejaVuSans-Bold.ttf", "arialbd.ttf") if bold else ("DejaVuSans.ttf", "arial.ttf") + for name in names: + try: + return ImageFont.truetype(name, size=max(4, int(size))) + except OSError: + continue + return ImageFont.load_default() + + +def _text_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont) -> int: + box = draw.textbbox((0, 0), text or " ", font=font) + return int(box[2] - box[0]) + + +def _break_token( + draw: ImageDraw.ImageDraw, + token: str, + font: ImageFont.ImageFont, + maximum_width: int, +) -> list[str]: + """Break an unspaced token deterministically so no line can be clipped.""" + + pieces: list[str] = [] + current = "" + for character in token: + candidate = current + character + if current and _text_width(draw, candidate, font) > maximum_width: + pieces.append(current) + current = character + else: + current = candidate + if current: + pieces.append(current) + return pieces or [""] + + +def _wrap_copy( + draw: ImageDraw.ImageDraw, + value: str, + font: ImageFont.ImageFont, + maximum_width: int, +) -> list[str]: + """Pixel-wrap exact copy while preserving authored paragraph boundaries.""" + + lines: list[str] = [] + for paragraph in str(value).splitlines() or [""]: + words = paragraph.split() + if not words: + lines.append("") + continue + current = "" + for word in words: + fragments = ( + [word] + if _text_width(draw, word, font) <= maximum_width + else _break_token(draw, word, font, maximum_width) + ) + for fragment in fragments: + candidate = fragment if not current else f"{current} {fragment}" + if current and _text_width(draw, candidate, font) > maximum_width: + lines.append(current) + current = fragment + else: + current = candidate + if current: + lines.append(current) + return lines + + +def _copy_layout( + draw: ImageDraw.ImageDraw, + width: int, + height: int, + fields: list[dict[str, Any]], +) -> tuple[list[dict[str, Any]], int, int]: + """Find the first deterministic, hierarchy-preserving layout that fits.""" + + short_edge = min(width, height) + margin = max(3, int(round(short_edge * 0.055))) + content_width = width - 2 * margin + content_height = height - 2 * margin + if content_width < 8 or content_height < 8: + raise ValueError("End-card safe area is too small for deterministic copy rendering.") + scales = (1.0, 0.9, 0.8, 0.7, 0.6, 0.5, 0.4) + for scale in scales: + layout: list[dict[str, Any]] = [] + for field in fields: + requested_size = max( + int(field["minimum_size"]), + int(round(short_edge * float(field["size_ratio"]) * scale)), + ) + font = _font(requested_size, bold=bool(field["bold"])) + lines = _wrap_copy(draw, str(field["text"]), font, content_width) + if any(_text_width(draw, line, font) > content_width for line in lines): + layout = [] + break + line_spacing = max(0, int(round(requested_size * 0.15))) + rendered_text = "\n".join(lines) + block_box = draw.multiline_textbbox( + (0, 0), rendered_text, font=font, spacing=line_spacing, align="center" + ) + block_width = max(1, int(block_box[2] - block_box[0])) + block_height = max(1, int(block_box[3] - block_box[1])) + layout.append( + { + **field, + "font": font, + "font_size": requested_size, + "lines": lines, + "rendered_text": rendered_text, + "line_spacing": line_spacing, + "width": block_width, + "height": block_height, + "bbox_top": int(block_box[1]), + } + ) + if not layout: + continue + gap = max(1, int(round(short_edge * 0.012 * scale))) + total_height = sum(int(item["height"]) for item in layout) + gap * (len(layout) - 1) + if total_height <= content_height: + return layout, margin, gap + raise ValueError( + "Locked advertisement end-card copy does not fit the requested dimensions at the minimum " + "legible hierarchy. Increase the canvas size or shorten the governed copy; clipping is prohibited." + ) + + +class EndCardRenderer: + """Render a deterministic campaign-derived tail card for an exact frame count.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "advertisement_contract_json": ("STRING", {"forceInput": True, "multiline": True}), + "width": ("INT", {"default": 720, "min": 64, "max": 4096, "step": 8}), + "height": ("INT", {"default": 1280, "min": 64, "max": 4096, "step": 8}), + "background_color": ("STRING", {"default": "#10141f"}), + "brand_color": ("STRING", {"default": "#ffffff"}), + "accent_color": ("STRING", {"default": "#76f7c5"}), + } + } + + RETURN_TYPES = ("IMAGE", "STRING", "STRING", "BOOLEAN") + RETURN_NAMES = ("end_card_images", "end_card_report_json", "status", "ready") + FUNCTION = "render" + CATEGORY = CATEGORY + DESCRIPTION = ( + "Renders all campaign-locked finishing copy in a deterministic legible hierarchy and the exact " + "contracted end-card frame count. Copy that cannot fit is rejected instead of clipped." + ) + + def render( + self, + advertisement_contract_json: str, + width: int, + height: int, + background_color: str, + brand_color: str, + accent_color: str, + ): + contract = _contract(advertisement_contract_json) + w, h = int(width), int(height) + if w < 64 or h < 64: + raise ValueError("End-card dimensions must each be at least 64 pixels.") + end_card = contract.get("end_card", {}) + frame_count = int(end_card.get("frame_count", -1)) + seconds = float(end_card.get("duration_seconds", -1.0)) + if frame_count != int(round(seconds * H3_FPS)) or frame_count <= 0: + raise ValueError("Advertisement contract has no positive exact end-card frame count.") + estimated_tensor_bytes = frame_count * w * h * 3 * 4 + if estimated_tensor_bytes > MAX_END_CARD_TENSOR_BYTES: + raise ValueError( + "Advertisement end-card tensor would exceed the 2 GiB allocation ceiling; " + "reduce dimensions or contracted end-card duration." + ) + background = _color(background_color, "background_color") + brand_color_rgb = _color(brand_color, "brand_color") + accent = _color(accent_color, "accent_color") + exact_copy = contract.get("mandatory_copy") + if not isinstance(exact_copy, Mapping): + raise ValueError("Advertisement contract has no exact deterministic finishing copy.") + brand = str(exact_copy.get("brand_name", "")).strip() + product = str(exact_copy.get("product_name", "")).strip() + variant = str(exact_copy.get("variant_name", "")).strip() + headline = str(exact_copy.get("headline", "")).strip() + cta = str(exact_copy.get("call_to_action", "")).strip() + offer = str(exact_copy.get("offer_text", "")).strip() + price = str(exact_copy.get("price_text", "")).strip() + legal = str(exact_copy.get("legal_line", "")).strip() + raw_claims = exact_copy.get("approved_claims", []) + if not isinstance(raw_claims, list) or any(not isinstance(value, str) for value in raw_claims): + raise ValueError("Advertisement exact-copy approved_claims must be a JSON array of strings.") + claims = [value.strip() for value in raw_claims if value.strip()] + if not brand or not product or not headline: + raise ValueError("Advertisement exact finishing copy requires brand_name, product_name, and headline.") + if brand != str(contract.get("brand_name", "")) or product != str(contract.get("product_name", "")): + raise ValueError("Advertisement root identity and exact finishing copy disagree.") + if cta != str(contract.get("call_to_action", "")).strip(): + raise ValueError("Advertisement root CTA and exact finishing copy disagree.") + rendered_copy: dict[str, Any] = { + "brand_name": brand, + "product_name": product, + "headline": headline, + } + for key, value in ( + ("variant_name", variant), + ("offer_text", offer), + ("price_text", price), + ("call_to_action", cta), + ("legal_line", legal), + ): + if value: + rendered_copy[key] = value + if claims: + rendered_copy["approved_claims"] = claims + product_line = product + (f" — {variant}" if variant else "") + fields: list[dict[str, Any]] = [ + { + "field": "brand_name", + "text": brand, + "size_ratio": 0.052, + "minimum_size": 4, + "bold": True, + "fill": brand_color_rgb, + }, + { + "field": "product_name" + ("+variant_name" if variant else ""), + "text": product_line, + "size_ratio": 0.042, + "minimum_size": 4, + "bold": True, + "fill": accent, + }, + { + "field": "headline", + "text": headline, + "size_ratio": 0.075, + "minimum_size": 5, + "bold": True, + "fill": brand_color_rgb, + }, + ] + for field, text_value, size_ratio, minimum_size, bold, fill in ( + ("offer_text", offer, 0.040, 4, False, accent), + ("price_text", price, 0.060, 5, True, accent), + ("call_to_action", cta, 0.050, 4, True, brand_color_rgb), + ("approved_claims", "\n".join(claims), 0.025, 4, False, brand_color_rgb), + ("legal_line", legal, 0.022, 4, False, brand_color_rgb), + ): + if text_value: + fields.append( + { + "field": field, + "text": text_value, + "size_ratio": size_ratio, + "minimum_size": minimum_size, + "bold": bold, + "fill": fill, + } + ) + canvas = Image.new("RGB", (w, h), background) + draw = ImageDraw.Draw(canvas) + layout, margin, gap = _copy_layout(draw, w, h, fields) + draw.rounded_rectangle( + (max(1, margin // 3), max(1, margin // 3), w - max(1, margin // 3) - 1, h - max(1, margin // 3) - 1), + radius=max(2, margin // 2), + outline=accent, + width=max(1, margin // 10), + ) + total_height = sum(int(item["height"]) for item in layout) + gap * (len(layout) - 1) + y = max(margin, (h - total_height) // 2) + hierarchy: list[dict[str, Any]] = [] + for item in layout: + text_value = str(item["rendered_text"]) + x = (w - int(item["width"])) // 2 + draw.multiline_text( + (x, y - int(item["bbox_top"])), + text_value, + font=item["font"], + fill=item["fill"], + spacing=int(item["line_spacing"]), + align="center", + ) + hierarchy.append( + { + "field": item["field"], + "font_size": int(item["font_size"]), + "line_count": len(item["lines"]), + "top": y, + "bottom_exclusive": y + int(item["height"]), + } + ) + y += int(item["height"]) + gap + array = np.asarray(canvas, dtype=np.float32) / 255.0 + frame = torch.from_numpy(array).unsqueeze(0) + images = frame.repeat(frame_count, 1, 1, 1).contiguous() + report: dict[str, Any] = { + "schema": ADVERTISEMENT_END_CARD_SCHEMA, + "version": VERSION, + "campaign_id": contract["campaign_id"], + "advertisement_contract_sha256": contract["contract_sha256"], + "frame_count": frame_count, + "duration_seconds": seconds, + "fps": H3_FPS, + "pixel_dimensions": [w, h], + "estimated_tensor_bytes": estimated_tensor_bytes, + "pixel_sha256": _tensor_sha256(images), + "rendered_copy": rendered_copy, + "copy_hierarchy": hierarchy, + "copy_fit_policy": "deterministic_scale_and_pixel_wrap_fail_closed_no_clipping", + "deterministic": True, + "ready": True, + } + report["report_sha256"] = _manifest_sha(report) + return images, _json(report), f"Rendered exact {frame_count}-frame deterministic advertisement end card.", True + + +class AdvertisementMasterFinisher: + """Replace the exact contracted master tail with a verified end card.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "assembled_master": ("IMAGE",), + "master_audio": ("AUDIO",), + "end_card_images": ("IMAGE",), + "end_card_report_json": ("STRING", {"forceInput": True, "multiline": True}), + "advertisement_contract_json": ("STRING", {"forceInput": True, "multiline": True}), + "assembly_report_json": ("STRING", {"forceInput": True, "multiline": True}), + } + } + + RETURN_TYPES = ("IMAGE", "AUDIO", "STRING", "STRING", "BOOLEAN") + RETURN_NAMES = ("finished_master", "finished_audio", "finish_report_json", "status", "ready") + FUNCTION = "finish" + CATEGORY = CATEGORY + DESCRIPTION = "Replaces, rather than appends, the exact contracted master tail so delivery duration never drifts." + + def finish( + self, + assembled_master: Any, + master_audio: Any, + end_card_images: Any, + end_card_report_json: str, + advertisement_contract_json: str, + assembly_report_json: str, + ): + contract = _contract(advertisement_contract_json) + master = _rgb_images(assembled_master, "assembled_master") + card = _rgb_images(end_card_images, "end_card_images") + assembly = _parse_object(assembly_report_json, "assembly_report_json") + end_report = _parse_object(end_card_report_json, "end_card_report_json") + campaign = str(contract["campaign_id"]) + if assembly.get("schema") != ADVERTISEMENT_ASSEMBLY_SCHEMA or assembly.get("campaign_id") != campaign: + raise ValueError("Assembly report does not belong to this advertisement campaign.") + if end_report.get("schema") != ADVERTISEMENT_END_CARD_SCHEMA or end_report.get("campaign_id") != campaign: + raise ValueError("End-card report does not belong to this advertisement campaign.") + if assembly.get("advertisement_contract_sha256") != contract["contract_sha256"] or end_report.get("advertisement_contract_sha256") != contract["contract_sha256"]: + raise ValueError("Advertisement finishing inputs were produced from different contract revisions.") + _verify_embedded_hash(assembly, "report_sha256", "Assembly report") + _verify_embedded_hash(end_report, "report_sha256", "End-card report") + _verify_audio(contract, master_audio, exact_duration=True) + expected_frames = _expected_master_frames(contract) + end_frames = int(contract["end_card"]["frame_count"]) + if int(master.shape[0]) != expected_frames: + raise ValueError(f"Assembled advertisement master must contain exactly {expected_frames} frames.") + if int(card.shape[0]) != end_frames or int(end_report.get("frame_count", -1)) != end_frames: + raise ValueError(f"Advertisement end card must contain exactly {end_frames} frames.") + if master.shape[1:] != card.shape[1:]: + raise ValueError("Advertisement end-card and master pixel dimensions must match exactly.") + if str(assembly.get("assembled_pixel_sha256", "")) != _tensor_sha256(master): + raise ValueError("Assembled advertisement pixels do not match the assembly report.") + if str(end_report.get("pixel_sha256", "")) != _tensor_sha256(card): + raise ValueError("Advertisement end-card pixels do not match the end-card report.") + if end_frames > expected_frames: + raise ValueError("Advertisement end card cannot exceed the master duration.") + finished = torch.cat((master[:-end_frames], card), dim=0) + if int(finished.shape[0]) != expected_frames: + raise AssertionError("Advertisement finishing changed the master frame count.") + report: dict[str, Any] = { + "schema": "diffusiongemma.advertisement_master_finish", + "version": VERSION, + "campaign_id": campaign, + "advertisement_contract_sha256": contract["contract_sha256"], + "assembly_report_sha256": str(assembly.get("report_sha256", "")), + "end_card_report_sha256": str(end_report.get("report_sha256", "")), + "exact_master_frames": expected_frames, + "end_card_replaced_tail_frames": end_frames, + "duration_seconds": float(contract["production_duration_seconds"]), + "fps": H3_FPS, + "finished_pixel_sha256": _tensor_sha256(finished), + "master_audio_sha256": waveform_sha256(master_audio), + "duration_changed": False, + "ready": True, + } + report["report_sha256"] = _manifest_sha(report) + return finished, master_audio, _json(report), "Finished advertisement master without changing duration or audio.", True + + +def _slice_audio(audio: Mapping[str, Any], start_seconds: float, duration_seconds: float) -> tuple[dict[str, Any], int, int]: + waveform, sample_rate = _audio_parts(audio) + start = int(round(start_seconds * sample_rate)) + count = int(round(duration_seconds * sample_rate)) + end = start + count + if start < 0 or end > int(waveform.shape[-1]): + raise ValueError("Requested cutdown audio window is outside the locked master waveform.") + return {"waveform": waveform[..., start:end].clone(), "sample_rate": sample_rate}, start, count + + +def _requested_adaptations(contract: Mapping[str, Any]) -> list[dict[str, Any]]: + requested = contract.get("requested_deliverables", {}) + raw: list[Any] = [] + if isinstance(requested, Mapping): + value = requested.get("adaptations", []) + if isinstance(value, list): + raw = value + elif isinstance(requested, list): + raw = requested + master = str(contract.get("master_aspect_ratio", "")) + results: list[dict[str, Any]] = [] + seen: set[str] = set() + for item in raw: + text = str(item).strip() + match = re.search(r"(?:21:9|16:9|9:16|4:3|3:4|3:2|2:3|1:1)", text) + aspect = match.group(0) if match else text + if aspect in seen: + continue + seen.add(aspect) + if aspect == master: + status = "not_applicable_same_as_master" + reason = "The requested aspect is already the rendered master; no separate adaptation was produced." + elif not ASPECT_RE.fullmatch(aspect): + status = "impossible_invalid_aspect" + reason = "No supported aspect ratio could be parsed, so no pixels were produced." + else: + status = "planned_not_rendered" + reason = "A semantic reframe renderer was not supplied; a center crop is not represented as a finished adaptation." + results.append({"request": text, "aspect_ratio": aspect, "status": status, "reason": reason}) + return results + + +class AdvertisementAdaptationStatus: + """Expose requested format adaptations without pretending a reframe was rendered.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "advertisement_contract_json": ("STRING", {"forceInput": True, "multiline": True}), + } + } + + RETURN_TYPES = ("STRING", "INT", "BOOLEAN", "STRING", "BOOLEAN") + RETURN_NAMES = ( + "adaptation_status_json", + "requested_adaptation_count", + "all_requested_rendered", + "status", + "ready", + ) + FUNCTION = "evaluate" + CATEGORY = CATEGORY + DESCRIPTION = ( + "Reports every requested aspect adaptation as planned, inapplicable, or impossible. " + "It cannot emit a rendered status because this node receives no adapted pixels." + ) + + def evaluate(self, advertisement_contract_json: str): + contract = _contract(advertisement_contract_json) + adaptations = _requested_adaptations(contract) + report: dict[str, Any] = { + "schema": ADVERTISEMENT_ADAPTATION_SCHEMA, + "version": VERSION, + "campaign_id": contract["campaign_id"], + "advertisement_contract_sha256": contract["contract_sha256"], + "master_aspect_ratio": contract["master_aspect_ratio"], + "adaptations": adaptations, + "all_requested_rendered": False, + "pixel_inputs_received": False, + "ready": True, + } + report["report_sha256"] = _manifest_sha(report) + message = ( + f"Reported {len(adaptations)} requested advertisement adaptation(s); none is claimed rendered." + if adaptations + else "No separate advertisement aspect adaptation was requested." + ) + return _json(report), len(adaptations), False, message, True + + +class AdvertisementCutdownRenderer: + """Render real, sample-accurate 15-second and 6-second cutdowns.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "finished_master": ("IMAGE",), + "finished_audio": ("AUDIO",), + "advertisement_contract_json": ("STRING", {"forceInput": True, "multiline": True}), + "cutdown_15_start_seconds": ("FLOAT", {"default": 0.0, "min": 0.0, "step": 1.0 / H3_FPS}), + "cutdown_6_start_seconds": ("FLOAT", {"default": 24.0, "min": 0.0, "step": 1.0 / H3_FPS}), + }, + "optional": { + "adaptation_status_json": ("STRING", {"forceInput": True, "multiline": True}), + }, + } + + RETURN_TYPES = ("IMAGE", "AUDIO", "IMAGE", "AUDIO", "STRING", "STRING", "BOOLEAN") + RETURN_NAMES = ( + "cutdown_15_images", + "cutdown_15_audio", + "cutdown_6_images", + "cutdown_6_audio", + "delivery_manifest_json", + "status", + "ready", + ) + FUNCTION = "render" + CATEGORY = CATEGORY + DESCRIPTION = "Slices actual master frames and exact waveform samples for honest 15s and 6s rendered deliverables." + + def render( + self, + finished_master: Any, + finished_audio: Any, + advertisement_contract_json: str, + cutdown_15_start_seconds: float, + cutdown_6_start_seconds: float, + adaptation_status_json: str = "", + ): + contract = _contract(advertisement_contract_json) + master = _rgb_images(finished_master, "finished_master") + _verify_audio(contract, finished_audio, exact_duration=True) + expected_frames = _expected_master_frames(contract) + if int(master.shape[0]) != expected_frames: + raise ValueError(f"Finished advertisement master must contain exactly {expected_frames} frames.") + master_seconds = float(contract["production_duration_seconds"]) + requested = contract.get("requested_deliverables", {}) + requested_cutdowns = ( + requested.get("cutdowns_seconds", []) if isinstance(requested, Mapping) else [] + ) + if requested_cutdowns not in ([15, 6], [15.0, 6.0]): + raise ValueError( + "Advertisement v1 Cutdown Renderer requires the governed requested cutdowns [15, 6]." + ) + rendered: list[tuple[torch.Tensor, dict[str, Any], dict[str, Any]]] = [] + for seconds, raw_start in ((15.0, cutdown_15_start_seconds), (6.0, cutdown_6_start_seconds)): + start_seconds = float(raw_start) + if not math.isfinite(start_seconds) or start_seconds < 0: + raise ValueError("Cutdown start times must be non-negative finite numbers.") + frame_start_float = start_seconds * H3_FPS + frame_start = int(round(frame_start_float)) + if abs(frame_start_float - frame_start) > 1.0e-6: + raise ValueError("Cutdown start times must align exactly to a 24 fps master frame.") + frame_count = int(round(seconds * H3_FPS)) + if start_seconds + seconds > master_seconds + 1.0e-6 or frame_start + frame_count > expected_frames: + raise ValueError(f"The requested {seconds:g}s cutdown window is outside the advertisement master.") + images = master[frame_start : frame_start + frame_count].clone() + audio, sample_start, sample_count = _slice_audio(finished_audio, start_seconds, seconds) + report = { + "id": f"cutdown_{int(seconds)}s", + "status": "rendered", + "aspect_ratio": contract["master_aspect_ratio"], + "start_seconds": round(start_seconds, 6), + "duration_seconds": seconds, + "frame_start": frame_start, + "frame_count": frame_count, + "audio_sample_start": sample_start, + "audio_sample_count": sample_count, + "pixel_sha256": _tensor_sha256(images), + "audio_sha256": waveform_sha256(audio), + } + rendered.append((images, audio, report)) + adaptations = _requested_adaptations(contract) + if str(adaptation_status_json or "").strip(): + adaptation_report = _parse_object(adaptation_status_json, "adaptation_status_json") + if adaptation_report.get("schema") != ADVERTISEMENT_ADAPTATION_SCHEMA or adaptation_report.get("version") != VERSION: + raise ValueError("adaptation_status_json has an unsupported schema or version.") + if adaptation_report.get("campaign_id") != contract["campaign_id"] or adaptation_report.get("advertisement_contract_sha256") != contract["contract_sha256"]: + raise ValueError("Adaptation status does not belong to this advertisement contract revision.") + _verify_embedded_hash(adaptation_report, "report_sha256", "Adaptation status") + if adaptation_report.get("adaptations") != adaptations: + raise ValueError("Adaptation status does not match the contract's requested deliverables.") + manifest: dict[str, Any] = { + "schema": ADVERTISEMENT_DELIVERY_SCHEMA, + "version": VERSION, + "campaign_id": contract["campaign_id"], + "advertisement_contract_sha256": contract["contract_sha256"], + "master": { + "status": "rendered", + "aspect_ratio": contract["master_aspect_ratio"], + "duration_seconds": master_seconds, + "frame_count": expected_frames, + "pixel_sha256": _tensor_sha256(master), + "audio_sha256": waveform_sha256(finished_audio), + }, + "cutdowns": [entry[2] for entry in rendered], + "adaptations": adaptations, + "honesty_policy": "Only returned frame/audio tensors are marked rendered; format adaptations remain explicit non-renders.", + "ready": True, + } + manifest["manifest_sha256"] = _manifest_sha(manifest) + return ( + rendered[0][0], + rendered[0][1], + rendered[1][0], + rendered[1][1], + _json(manifest), + "Rendered exact 15s and 6s advertisement cutdowns; unrendered adaptations remain explicitly labeled.", + True, + ) + + +class MediaQAGate: + """Aggregate measured media checks without converting unknowns into passes.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "advertisement_contract_json": ("STRING", {"forceInput": True, "multiline": True}), + "delivery_manifest_json": ("STRING", {"forceInput": True, "multiline": True}), + "checks_json": ("STRING", {"default": "{}", "multiline": True}), + "required_checks_json": ( + "STRING", + { + "default": '["product_identity","performer_identity","copy_legibility","audio_sync","technical_integrity"]', + "multiline": True, + "tooltip": "Host-required identity, product, copy, sync, and integrity checks cannot be removed; this list may only add requirements.", + }, + ), + } + } + + RETURN_TYPES = ("STRING", ["pass", "fail", "not_measured"], "STRING", "BOOLEAN") + RETURN_NAMES = ("media_qa_report_json", "qa_status", "status", "ready") + FUNCTION = "evaluate" + CATEGORY = CATEGORY + DESCRIPTION = "Emits pass, fail, or not_measured; absent evidence can never silently pass delivery QA." + + def evaluate( + self, + advertisement_contract_json: str, + delivery_manifest_json: str, + checks_json: str, + required_checks_json: str, + ): + contract = _contract(advertisement_contract_json) + delivery = _parse_object(delivery_manifest_json, "delivery_manifest_json") + if delivery.get("schema") != ADVERTISEMENT_DELIVERY_SCHEMA or delivery.get("version") != VERSION: + raise ValueError("delivery_manifest_json has an unsupported schema or version.") + if delivery.get("campaign_id") != contract["campaign_id"] or delivery.get("advertisement_contract_sha256") != contract["contract_sha256"]: + raise ValueError("Delivery manifest does not belong to this advertisement contract revision.") + _verify_embedded_hash(delivery, "manifest_sha256", "Delivery manifest") + checks = _parse_object(checks_json, "checks_json") + try: + required = json.loads(str(required_checks_json or "[]")) + except json.JSONDecodeError as exc: + raise ValueError("required_checks_json must be valid JSON.") from exc + if not isinstance(required, list) or any(not str(value).strip() for value in required): + raise ValueError("required_checks_json must be a JSON array of check names.") + requested = [str(value).strip() for value in required] + if len(requested) != len(set(requested)): + raise ValueError("required_checks_json cannot contain duplicate check names.") + # The editable list is additive only. A saved-workflow edit cannot + # remove the host-owned delivery baseline and silently approve media. + required = list(MANDATORY_MEDIA_QA_CHECKS) + required.extend(name for name in requested if name not in MANDATORY_MEDIA_QA_CHECKS) + evaluated: list[dict[str, Any]] = [] + states: list[str] = [] + for raw_name in required: + name = str(raw_name).strip() + raw = checks.get(name, "not_measured") + evidence = "" + if isinstance(raw, Mapping): + state = str(raw.get("status", "not_measured")).strip().casefold() + evidence = str(raw.get("evidence", "")).strip() + else: + state = str(raw).strip().casefold() + if state not in QA_STATES: + raise ValueError(f"Media QA check {name!r} must be pass, fail, or not_measured.") + if state == "pass" and not evidence: + state = "not_measured" + evidence = "A pass status was supplied without evidence and was downgraded to not_measured." + states.append(state) + evaluated.append({"check": name, "status": state, "evidence": evidence}) + if "fail" in states: + overall = "fail" + elif "not_measured" in states: + overall = "not_measured" + else: + overall = "pass" + ready = overall == "pass" + report: dict[str, Any] = { + "schema": ADVERTISEMENT_MEDIA_QA_SCHEMA, + "version": VERSION, + "campaign_id": contract["campaign_id"], + "advertisement_contract_sha256": contract["contract_sha256"], + "delivery_manifest_sha256": str(delivery.get("manifest_sha256", "")), + "host_required_checks": list(MANDATORY_MEDIA_QA_CHECKS), + "checks": evaluated, + "overall_status": overall, + "ready_for_delivery": ready, + } + report["report_sha256"] = _manifest_sha(report) + if overall == "pass": + message = "All required advertisement media checks passed with supplied evidence." + elif overall == "fail": + message = "Advertisement media QA failed; delivery remains blocked." + else: + message = "Advertisement media QA is not measured completely; delivery remains blocked." + return _json(report), overall, message, ready + + +NODE_CLASS_MAPPINGS = { + "DiffusionGemmaAdvertisementMasterAssembler": AdvertisementMasterAssembler, + "DiffusionGemmaAdvertisementEndCardRenderer": EndCardRenderer, + "DiffusionGemmaAdvertisementMasterFinisher": AdvertisementMasterFinisher, + "DiffusionGemmaAdvertisementAdaptationStatus": AdvertisementAdaptationStatus, + "DiffusionGemmaAdvertisementCutdownRenderer": AdvertisementCutdownRenderer, + "DiffusionGemmaAdvertisementMediaQAGate": MediaQAGate, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "DiffusionGemmaAdvertisementMasterAssembler": "DiffusionGemma Advertisement Master Assembler", + "DiffusionGemmaAdvertisementEndCardRenderer": "DiffusionGemma Advertisement End Card Renderer", + "DiffusionGemmaAdvertisementMasterFinisher": "DiffusionGemma Advertisement Master Finisher", + "DiffusionGemmaAdvertisementAdaptationStatus": "DiffusionGemma Advertisement Adaptation Status", + "DiffusionGemmaAdvertisementCutdownRenderer": "DiffusionGemma Advertisement Cutdown Renderer", + "DiffusionGemmaAdvertisementMediaQAGate": "DiffusionGemma Advertisement Media QA Gate", +} + + +__all__ = [ + "AdvertisementMasterAssembler", + "EndCardRenderer", + "AdvertisementMasterFinisher", + "AdvertisementAdaptationStatus", + "AdvertisementCutdownRenderer", + "MediaQAGate", + "NODE_CLASS_MAPPINGS", + "NODE_DISPLAY_NAME_MAPPINGS", +] diff --git a/advertising_planning_nodes.py b/advertising_planning_nodes.py new file mode 100644 index 0000000..84006db --- /dev/null +++ b/advertising_planning_nodes.py @@ -0,0 +1,1591 @@ +# Copyright (c) 2026 exportAnything. All rights reserved. +# SPDX-License-Identifier: MIT + +"""Versioned advertisement planning and persistent H3 relay contracts. + +These nodes intentionally live beside the established music-video production +nodes. They import generic parsing/timing helpers but do not change any legacy +node class, return tuple, or saved-workflow contract. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import os +from pathlib import Path +import re +from typing import Any, Mapping + +import torch +import torch.nn.functional as torch_functional + +try: + from .production_planning_nodes import ( + H3_FPS, + MAX_H3_SHOTS, + MAX_PROJECT_SECONDS, + _carry_in_source_block, + _h3_frames, + _leading_shot_timestamp_seconds, + _render_sections, + _section_map, + _segment_prompt, + _shot_blocks, + _validate_source_timestamps, + ) +except ImportError: # Standalone repository tests. + from production_planning_nodes import ( + H3_FPS, + MAX_H3_SHOTS, + MAX_PROJECT_SECONDS, + _carry_in_source_block, + _h3_frames, + _leading_shot_timestamp_seconds, + _render_sections, + _section_map, + _segment_prompt, + _shot_blocks, + _validate_source_timestamps, + ) + + +CATEGORY = "prompt/diffusiongemma/advertising" +ADVERTISEMENT_MASTER_SCHEMA = "diffusiongemma.advertisement_master_contract" +ADVERTISEMENT_PLAN_SCHEMA = "diffusiongemma.advertisement_h3_plan" +ADVERTISEMENT_RELAY_SCHEMA = "diffusiongemma.advertisement_relay_artifact" +VERSION = 1 +SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +SLUG_RE = re.compile(r"^[a-z0-9]+(?:[a-z0-9_-]*[a-z0-9])?$") +PRODUCT_PRESENCE = ("absent", "background", "supporting", "hero", "consumed") +ADVERTISEMENT_PERFORMANCE_CARRIER_MODES = ( + "Dance / music sync", + "Lyrics + lip sync", + "Natural / audio-led sync", +) +BOUNDARY_MODES = ( + "hard_cut", + "match_state", + "relay_continuity", + "practical_transition", +) +EXPORTED_BOUNDARY_MODES = ("relay_continuity",) +EXPORTED_SEAM_STYLES = ("Campaign default",) +STATE_FIELDS = ( + "composition", + "performer_state", + "product_state", + "environment", + "transition_token", +) + + +def _json(value: Any) -> str: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + + +def _sha_text(value: Any) -> str: + return hashlib.sha256(str(value).encode("utf-8")).hexdigest() + + +def _sha_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def _parse_object(value: Any, label: str) -> dict[str, Any]: + try: + parsed = json.loads(str(value or "")) + except json.JSONDecodeError as exc: + raise ValueError(f"{label} must be valid JSON.") from exc + if not isinstance(parsed, dict): + raise ValueError(f"{label} must be a JSON object.") + return parsed + + +def _parse_array(value: Any, label: str) -> list[Any]: + try: + parsed = json.loads(str(value or "")) + except json.JSONDecodeError as exc: + raise ValueError(f"{label} must be valid JSON.") from exc + if not isinstance(parsed, list): + raise ValueError(f"{label} must be a JSON array.") + return parsed + + +def _positive(value: Any, label: str, *, allow_zero: bool = False) -> float: + try: + number = float(value) + except (TypeError, ValueError, OverflowError) as exc: + raise ValueError(f"{label} must be a finite number.") from exc + if not math.isfinite(number) or number < 0 or (not allow_zero and number <= 0): + raise ValueError(f"{label} must be a positive finite number.") + return number + + +def _hash(value: Any, label: str) -> str: + digest = str(value or "").strip().casefold() + if not SHA256_RE.fullmatch(digest): + raise ValueError(f"{label} must be an exact 64-character SHA-256 value.") + return digest + + +def _required_text(value: Any, label: str) -> str: + text = str(value or "").strip() + if not text: + raise ValueError(f"{label} must not be empty.") + return text + + +def _contract(value: Any) -> dict[str, Any]: + contract = _parse_object(value, "advertisement_contract_json") + if contract.get("schema") != ADVERTISEMENT_MASTER_SCHEMA or contract.get("version") != VERSION: + raise ValueError("advertisement_contract_json has an unsupported schema or version.") + if not contract.get("campaign_id"): + raise ValueError("advertisement_contract_json has no campaign_id.") + supplied_contract_hash = _hash(contract.get("contract_sha256"), "advertisement contract_sha256") + contract_identity = dict(contract) + contract_identity.pop("contract_sha256", None) + if _sha_text(_json(contract_identity)) != supplied_contract_hash: + raise ValueError("advertisement_contract_json contract_sha256 does not match its canonical content.") + supplied_campaign_id = _hash(contract.get("campaign_id"), "advertisement campaign_id") + campaign_identity = dict(contract_identity) + campaign_identity.pop("campaign_id", None) + if _sha_text(_json(campaign_identity)) != supplied_campaign_id: + raise ValueError("advertisement_contract_json campaign_id does not match its canonical identity.") + return contract + + +def _plan(value: Any) -> dict[str, Any]: + plan = _parse_object(value, "advertisement_plan_json") + if plan.get("schema") != ADVERTISEMENT_PLAN_SCHEMA or plan.get("version") != VERSION: + raise ValueError("advertisement_plan_json has an unsupported schema or version.") + if not plan.get("ready") or not isinstance(plan.get("lanes"), list): + raise ValueError("advertisement_plan_json is not ready.") + supplied_hash = _hash(plan.get("plan_sha256"), "advertisement plan_sha256") + identity = dict(plan) + identity.pop("plan_sha256", None) + if _sha_text(_json(identity)) != supplied_hash: + raise ValueError("advertisement_plan_json plan_sha256 does not match its canonical content.") + return plan + + +def _legacy_project(value: Any) -> dict[str, Any]: + project = _parse_object(value, "project_manifest_json") + if project.get("schema") != "diffusiongemma.project_master_contract": + raise ValueError("project_manifest_json must come from Project Master Contract.") + if not project.get("project_id"): + raise ValueError("project_manifest_json has no project_id.") + return project + + +class AdvertisementMasterContract: + """Compile the typed ad contracts and legacy timing lock into one master.""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "campaign_contract_json": ("STRING", {"forceInput": True, "multiline": True}), + "reference_contract_json": ("STRING", {"forceInput": True, "multiline": True}), + "soundtrack_contract_json": ("STRING", {"forceInput": True, "multiline": True}), + "project_manifest_json": ("STRING", {"forceInput": True, "multiline": True}), + "performer_hero_sha256": ("STRING", {"default": ""}), + "performer_contact_sheet_sha256": ("STRING", {"default": ""}), + "product_package_sha256": ("STRING", {"default": ""}), + "master_audio_sha256": ("STRING", {"default": "", "forceInput": True}), + "native_shot_count": ("INT", {"default": 8, "min": 1, "max": 99}), + "boundary_mode": ( + list(EXPORTED_BOUNDARY_MODES), + {"default": EXPORTED_BOUNDARY_MODES[0]}, + ), + } + } + + RETURN_TYPES = ("STRING", "STRING", "STRING", "BOOLEAN", "INT", "STRING") + RETURN_NAMES = ( + "advertisement_contract_json", + "campaign_id", + "status", + "ready", + "generation_lane_count", + "reference_manifest", + ) + FUNCTION = "build" + CATEGORY = CATEGORY + DESCRIPTION = ( + "Bridges Campaign, Reference, and Soundtrack contracts to a validated Project Master, three immutable " + "clean-reference hashes, independent performer/product retention, and deterministic delivery intent." + ) + + def build( + self, + campaign_contract_json: str, + reference_contract_json: str, + soundtrack_contract_json: str, + project_manifest_json: str, + performer_hero_sha256: str, + performer_contact_sheet_sha256: str, + product_package_sha256: str, + master_audio_sha256: str, + native_shot_count: int, + boundary_mode: str, + ): + campaign = _parse_object(campaign_contract_json, "campaign_contract_json") + references = _parse_object(reference_contract_json, "reference_contract_json") + soundtrack = _parse_object(soundtrack_contract_json, "soundtrack_contract_json") + if campaign.get("schema") != "diffusiongemma.advertisement_campaign_contract" or campaign.get("version") != VERSION or not campaign.get("ready"): + raise ValueError("campaign_contract_json is not a ready supported advertisement campaign contract.") + if references.get("schema") != "diffusiongemma.advertisement_reference_contract" or references.get("version") != VERSION or not references.get("ready"): + raise ValueError("reference_contract_json is not a ready supported advertisement reference contract.") + if soundtrack.get("schema") != "diffusiongemma.advertisement_soundtrack_contract" or soundtrack.get("version") != VERSION or not soundtrack.get("ready"): + raise ValueError("soundtrack_contract_json is not a ready supported advertisement soundtrack contract.") + project = _legacy_project(project_manifest_json) + brand_block = campaign.get("brand") + timeline = campaign.get("timeline") + mandatory_copy = campaign.get("copy") + if not isinstance(brand_block, Mapping) or not isinstance(timeline, Mapping) or not isinstance(mandatory_copy, Mapping): + raise ValueError("Advertisement campaign contract is missing brand, timeline, or exact-copy fields.") + brand = _required_text(brand_block.get("brand_name"), "campaign brand_name") + product = _required_text(brand_block.get("product_name"), "campaign product_name") + objective = _required_text(campaign.get("objective"), "campaign objective") + audience_text = _required_text(campaign.get("audience"), "campaign audience") + message_text = _required_text(mandatory_copy.get("headline"), "campaign headline") + cta = str(mandatory_copy.get("call_to_action", "")).strip() + slug_source = str(campaign.get("campaign_name") or f"{brand}-{product}").casefold() + slug = re.sub(r"[^a-z0-9_-]+", "-", slug_source).strip("-_") or f"campaign-{_sha_text(slug_source)[:12]}" + if not SLUG_RE.fullmatch(slug): + raise ValueError("Derived advertisement campaign_slug is invalid.") + slots = references.get("picture_slots") + if not isinstance(slots, list): + raise ValueError("Advertisement reference contract has no picture_slots.") + role_by_tag = { + str(item.get("picture_tag", "")): str(item.get("role", "")) + for item in slots + if isinstance(item, Mapping) + } + expected_roles = { + "": "performer_primary", + "": "performer_supplemental", + "": "product_package", + } + if any(role_by_tag.get(tag) != role for tag, role in expected_roles.items()): + raise ValueError( + "Advertisement H3 planning requires the Reference Contract policy with performer hero Picture 1, " + "performer contact sheet Picture 2, and product/package Picture 3." + ) + relay = references.get("relay") + if ( + references.get("slot_policy") != "performer_2_product_1_relay_1" + or not isinstance(relay, Mapping) + or relay.get("enabled") is not True + or relay.get("picture_tag") != "" + ): + raise ValueError( + "Advertisement H3 planning requires the shipped two-performer, one-product, Picture 4 relay policy." + ) + subjects = references.get("subjects") + if not isinstance(subjects, list) or len(subjects) != 2: + raise ValueError("Advertisement reference contract must contain exactly performer Subject 1 and product Subject 2.") + reference_hashes = { + "performer_hero": _hash(performer_hero_sha256, "performer_hero_sha256"), + "performer_contact_sheet": _hash( + performer_contact_sheet_sha256, "performer_contact_sheet_sha256" + ), + "product_package": _hash(product_package_sha256, "product_package_sha256"), + } + if len(set(reference_hashes.values())) != 3: + raise ValueError("The performer hero, performer contact sheet, and product package must be three distinct assets.") + audio_hash = _hash(master_audio_sha256, "master_audio_sha256") + project_audio = project.get("audio_lock") + if not isinstance(project_audio, Mapping): + raise ValueError("Project Master has no audio_lock.") + if str(project_audio.get("waveform_sha256", "")).casefold() != audio_hash: + raise ValueError("Advertisement and Project Master audio SHA-256 values disagree.") + duration = _positive(project.get("production_duration_seconds"), "production_duration_seconds") + campaign_duration = _positive(timeline.get("duration_seconds"), "campaign timeline duration_seconds") + if abs(campaign_duration - duration) > 1.0e-6: + raise ValueError("Campaign Contract and Project Master durations disagree.") + campaign_aspect = str(timeline.get("aspect_ratio", "")) + if campaign_aspect != str(project.get("master_aspect_ratio", "")): + raise ValueError("Campaign Contract and Project Master aspect ratios disagree.") + if duration > MAX_PROJECT_SECONDS: + raise ValueError(f"Advertisement H3 planning supports at most {MAX_PROJECT_SECONDS:g} seconds.") + segmentation = project.get("h3_segmentation") + if not isinstance(segmentation, Mapping): + raise ValueError("Project Master has no h3_segmentation contract.") + lane_ceiling = _positive(segmentation.get("maximum_shot_seconds"), "maximum_shot_seconds") + if lane_ceiling > 15.0: + raise ValueError("One H3 advertisement generation lane cannot exceed 15 seconds.") + lane_count = int(math.ceil(duration / lane_ceiling)) + if not 1 <= lane_count <= MAX_H3_SHOTS: + raise ValueError("Advertisement duration requires more than four H3 generation lanes.") + shots = int(native_shot_count) + if not 1 <= shots <= 99: + raise ValueError("native_shot_count must be from 1 to 99.") + mode = str(boundary_mode or "") + if mode not in BOUNDARY_MODES: + raise ValueError("boundary_mode is unsupported.") + end_card = timeline.get("end_card") + if not isinstance(end_card, Mapping): + raise ValueError("Campaign Contract has no end-card timing.") + end_seconds = _positive(end_card.get("duration_seconds"), "end_card duration_seconds", allow_zero=True) + if end_seconds > duration + 1.0e-6: + raise ValueError("Campaign end-card duration cannot exceed the advertisement duration.") + claims = campaign.get("claims", []) + if not isinstance(claims, list): + raise ValueError("Campaign Contract claims must be a JSON array.") + source_reference_manifest = _required_text(references.get("reference_manifest"), "reference_manifest") + reference_rows = [source_reference_manifest] + if "" not in source_reference_manifest: + reference_rows.append( + ": [dg:spatial,lighting,motion,temporal] optional prior-lane retained tail for opening continuity only; never performer or product identity authority." + ) + reference_rows.append("