diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 247d06aed..258da997f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,25 @@ env: CANVASTTY_REQUIRE_NATIVE_HELPERS: "1" jobs: + mascot-pipeline: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@v7 + - name: Install mascot pipeline dependencies in a virtual environment + run: | + python3 -m venv .mascot-venv + .mascot-venv/bin/python -m pip install -r assets/mascot-pipeline/requirements.txt + - name: Verify mascot intake, build, review, and delivery contracts + run: .mascot-venv/bin/python -B assets/mascot-pipeline/tests/smoke_pipeline.py + - name: Verify placement against bundled accepted artwork + run: .mascot-venv/bin/python -B assets/mascot-pipeline/tests/placement_regression.py + verify: runs-on: ubuntu-latest steps: @@ -49,6 +68,17 @@ jobs: run: npm run typecheck - name: Build production bundles run: npm run build + - name: Build Linux installers and verify bundled mascot resources + run: | + npm run package:linux + test -f release/linux-unpacked/resources/mascot-pipeline/SKILL.md + test -f release/linux-unpacked/resources/mascot-pipeline/scripts/prepare_input.py + test -f release/linux-unpacked/resources/mascot-pipeline/templates/plugin/player.js + - name: Install AppImage runtime dependency + run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libfuse2t64 + - name: Smoke-test packaged Linux AppImage + timeout-minutes: 2 + run: xvfb-run -a npm run smoke:appimage - name: Verify hidden terminal rendering and state restoration timeout-minutes: 2 run: xvfb-run -a npm run smoke:terminal-hidden @@ -116,6 +146,13 @@ jobs: env: SPARKLE_PUBLIC_ED_KEY: ${{ vars.SPARKLE_PUBLIC_ED_KEY || 'iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=' }} run: npm run package:mac + - name: Verify bundled macOS mascot resources + run: | + app="$(find release -type d -name 'CanvasTTY.app' -print -quit)" + test -n "$app" + test -f "$app/Contents/Resources/mascot-pipeline/SKILL.md" + test -f "$app/Contents/Resources/mascot-pipeline/scripts/prepare_input.py" + test -f "$app/Contents/Resources/mascot-pipeline/templates/plugin/player.js" - name: Smoke-test packaged macOS CLI resolution run: npm run smoke:cli-resolution:mac - name: Verify hidden terminal rendering and state restoration on macOS diff --git a/.gitignore b/.gitignore index 3534bad6c..9b38ab5d5 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ yarn-debug.log* yarn-error.log* .cache/ .electron/ +__pycache__/ +*.pyc +/.mascot-venv/ settings.json # Local agent and planning context — never publish diff --git a/assets/mascot-pipeline/SKILL.md b/assets/mascot-pipeline/SKILL.md new file mode 100644 index 000000000..1bf5c06ca --- /dev/null +++ b/assets/mascot-pipeline/SKILL.md @@ -0,0 +1,350 @@ +--- +name: canvastty-sprite-character +description: Create or improve an animated CanvasTTY mascot from a PNG or JPEG image, with image preparation, bundled motion examples, a live CanvasTTY browser preview, frame repair, scale review and approved installation. +--- + +# CanvasTTY mascot pipeline + +This is the complete single-page workflow. The portable pipeline and installed skill contain the same instructions, scripts, templates and motion examples. Read this page in full; no other Markdown page is required. Executable helpers and image/data assets remain separate files. All instructions are English; speak to the user and label the mascot menu in their language. + +## 1. Start the chat and show the workspace + +CanvasTTY accepts one PNG or JPG/JPEG, with or without a background, containing visible character pixels. Preserve a PNG unchanged as `references/character.png`; preserve original JPEG bytes as `references/character.jpg` and create the working `references/character.png` with corrected EXIF orientation and lossless PNG encoding of the decoded pixels. This encoding conversion does not redraw the character or remove its background. A background is accepted at intake, but animation frames must have real transparency. Reject corrupt, empty or unsupported input, not an opaque reference. Intake checks the actual PNG/JPEG format, a 20 MB limit and dimensions from 32 through 4096 pixels before launching. + +Find a Python 3.10+ executable that imports Pillow and NumPy before starting generation. Use the host-supplied quoted executable and pipeline paths. For standalone intake, run `scripts/prepare_input.py --image --project ` once. Do not run it again when the host has already prepared the working reference. Never overwrite an existing project. + +The CanvasTTY creation button explicitly starts the user's own Codex terminal in YOLO/bypass mode, with full file/command access and no approval prompts. Reuse the host's existing YOLO profile and browser integration; do not change unrelated global agent settings. The launch flag is `--dangerously-bypass-approvals-and-sandbox`. Pass the initial prompt before the variadic `--image ` argument so prompt text is not parsed as extra image paths. Attach exactly the uploaded image initially; load motion examples later when relevant. Full access does not replace creative choices, appearance approval or installation approval. + +At the beginning of the session, connect to CanvasTTY's browser and keep the work visible there throughout creation. The host starts `scripts/serve_preview.py --port 0` on loopback, opens its reported URL and supplies it to the chat. This workbench shows uploaded/prepared references, generated frames and the current built player; it refreshes as files change. Reuse the same tab. Before declaring browser control unavailable, discover the specialized `canvastty_browser` connector and its `browser_list_tabs`, `browser_observe`, `browser_activate_tab`, `browser_reload` and `browser_screenshot` tools. An empty general CUA inventory does not prove the CanvasTTY connector is unavailable. A new connector tab should use `engine: chromium` to be visible. Read the installed tool schemas before calling them. + +If the server stopped after an interruption, restart the supplied helper on a free port, read its URL, then navigate the existing CanvasTTY tab. Keep the process alive for the work session; bind only to `127.0.0.1`. The server serves only project images and plugin assets, not the entire user directory. Report unavailable browser access honestly; do not silently use an unrelated external browser. Keep preview availability separate from approval of what it shows. + +## 2. First question and mandatory source preparation + +Before asking for a name/action or generating art, ask in the user's language: + +> Which appearance should we use? 1. Keep the uploaded character's appearance and prepare it for animation. 2. Change its appearance first, review the result, then animate the approved version. + +Do not prescribe a body shape. For option 2, ask what to keep/change. Both options include the following mandatory preparation pass for JPEG photos and PNG drawings, including when the upload already has transparency. Explain that it creates a clean full-body 2D animation reference, preserves the uploaded identity and requires approval. JPEG-to-PNG intake conversion does not satisfy this image-generation step. Preserve the original and save the generated result separately as `references/prepared-character.png`. Resolve an ambiguous main subject before generation. Inspect the local reference before editing it, and use the available image-generation/editing tool with transparent output enabled. + +Use this preparation prompt, plus only the user's explicitly requested appearance changes: + +```text +Use the attached image as the reference for the main character. If the character +is cropped, complete the missing body parts to full height. Preserve the +recognizable appearance, proportions, facial expression, hairstyle, pose of the +visible body parts, colors, clothing and accessories. Continue missing details +logically from what is visible; do not add arbitrary elements. + +Redraw the character as a high-quality, detailed 2D illustration. Correct defects +in the source drawing: unclear contours, incorrect anatomy, merged details and +untidy shadows. Clearly separate hair, face, clothing and accessories. If glasses +are present, preserve their original shape, size and position. Draw both lenses, +the frame, bridge and temples clearly so they do not merge with the hair. Give +all other existing accessories the same careful treatment. + +Show one complete character from crown to feet, with a small margin around it. +The legs are straight and uncrossed; both feet rest evenly on the same horizontal +line as if standing on a flat surface. No extra limbs or fingers. + +Remove the entire background, other characters and extraneous objects. Keep only +the main character on a genuinely transparent PNG background with an alpha +channel. No halo, drawn floor, cast shadow, text or frame. +``` + +For an upright human character, the requested straight, uncrossed legs override the source leg pose; preserve other visible pose details. For a nonhuman design without human legs/feet, apply the equivalent stable support pose instead of inventing human anatomy. Keep worn accessories; remove unrelated scene objects. If removing an integral held object would change identity, clarify it. A later action can introduce its own props. + +Show the prepared full-resolution PNG in the workbench and chat. Check alpha, outline clarity, anatomy, glasses/accessories and retained design details. Ask for explicit approval; revise until approved or stop if the user rejects preparation. Do not silently substitute the original for the required pass. Save the approved result as `references/approved-character.png`. Use it to create/show the canonical idle frame for approval; the prepared image itself may become that frame if it already meets canvas and support requirements. Do not generate an action from an unapproved design. + +## 3. Establish the canonical reference and offer actions + +The accepted canonical frame fixes identity, physical scale, camera, palette, material and lighting. Reject blur, stains and unclear details here before producing many frames. Record visible invariants in one project `action-brief.md`: face structure, iris/pupil, obscured features, hair part/locks/ends/tone, skin marks, clothing colors/prints, fabric translucency, legwear, shoe shape/reflections, accessories and proportions. Keep redesign variants separate; the old upload never overrides a later approved design. + +Ask for a display name if missing. Then proactively offer a short, varied set of actions in the user's language rather than only asking an empty question. For example: + +> What should we animate next? We can make dancing, playing a handheld game, +> working at a laptop, smoking, listening to music, or drinking from a can. +> Or describe your own action, pose or scene, including objects, emotions and +> effects. For example: sitting on a cushion and coding on a laptop. + +Offer other relevant library patterns over subsequent rounds: resting/breathing and blinking, cheering, sadness, surprise, looking around, using a phone, taking a selfie, waving at the camera, yawning/stretching, dozing, a playful effect, eating a snack, hugging a plush object, tucking hair and waving. Adapt to the actual character and user; these are suggestions, not a mandatory catalog. After each completed action, offer a few unfinished choices plus a custom scene and “finish.” Do not create all suggested actions without selection. + +## 4. Use the bundled successful motion library + +Actual past sprite drawings are bundled in `examples/motion-library/`. They are visual motion evidence, not new character identity or a promise that every depicted detail is flawless. File names and metadata are neutral; no private paths, account details, original project IDs or named source mascots are required. + +- `set-a/motion.json` and its atlases contain the completed 152-drawing source set: dance, idle, cheer, sad, alert, phone, selfie, wavecam, sleepy, doze, look, smoke, energy, rainbow, game, snack, music, plush, laptop, tuck, wave and available entry/exit clips. +- `set-b/motion.json` contains 39 drawings: idle, seated-laptop staging (12 entrance drawings and a 12-drawing working cycle), and smoking (one entrance drawing, 12 main-cycle drawings over 3.55 seconds, and one exit drawing). The smoking sequence includes the corrected eighth main-cycle drawing, preserving its 0.20-second timing and neighboring grip continuity. The supplied reversed laptop exit is a structural example that must be checked for the new object's state. These completed sequences guide movement, facial acting, effects and prop contact; they do not exempt new frames from review. +- `set-c/motion.json` contains 16 drawings from a separately approved laptop scene: retrieval, bending/squatting/seating, opening, typing, thinking, idea reaction and exit. Its aligned atlas is the exact approved artwork. `examples/placement-regression/` contains an unaligned reconstruction from the same unchanged source drawings and a neutral diagnostic case. It is a placement failure example, never a positive motion reference. `python tests/placement_regression.py` distinguishes the drifting footwear rows from the aligned one-pixel range. Those regions, thresholds and tolerances apply only to this example; stable support does not prove correct anatomy, prop geometry or expressions. The reconstruction is not represented as an archived rejected package. No personal project paths, names or account IDs are required. +- The artwork was supplied by the project owner as workflow examples. Inclusion does not establish a new blanket license or ownership of depicted characters. Do not relabel it as public-domain/MIT artwork or publish invented attribution/permission claims. Example art stays out of each newly generated mascot plugin. + +Extract full-resolution reference frames and a timing/contact sheet using: + +```text +python scripts/reference_frames.py --set set-a --action smoke --output /motion-reference/smoke +python scripts/reference_frames.py --set set-b --action laptop:in --output /motion-reference/seated-entry +python scripts/reference_frames.py --set set-b --action smoke --output /motion-reference/alternate-smoke +``` + +Use a new empty output directory. The script crops existing atlas cells without redrawing them. Inspect the sequence sheet, then the relevant original-sized frames. The timing JSON preserves order/repetition. Do not attach every library image to the chat or every generation. + +For a known action, select its matching sequence and study phases, joint chains, contact, expression changes, effect origin and rhythm. For smoking, examine raise -> contact -> inhale -> move away -> exhale/cloud -> disperse -> ash tap, including the loop closure. Do not reduce the action to moving a hand. For seated work, examine reaching/holding -> lowering into the seat -> opening the laptop -> typing, thinking, reacting and recovering; check that its physical size stays consistent throughout. + +For a new action without a direct example, decompose it into known movement components. Combine a relevant reaching motion, seating transition, grip/contact, expression beat and effect progression. Explain which source frames guide each component; assemble a coherent new staging before adding intermediates. If a component has no suitable example, create and approve a few new key poses rather than pretending a match exists. The new accepted poses become local motion references. Do not copy the source character's face, clothes, palette, body proportions or unrequested props. + +Assign explicit roles in each generation prompt: + +1. Approved new character/canonical frame: identity, material, camera and physical scale; highest priority. +2. Accepted neighboring poses of that same new character: continuity and trajectory. +3. Selected library frames: motion/contact/emotion/effect pattern only. + +Attach only the few images relevant to that beat, within actual tool limits. Preserve identity anchors first if image slots are limited. Inspect local images before using them. Never generate a chain based only on the last generated frame; that compounds drift. A useful example can improve the first attempt but does not remove review. + +## 5. Plan the action and its mandatory phases + +Record the user's actual action, required phases and intended impression before generation. Use simple entry, main cycle and exit; note active limbs, body supports, prop shape/size/state and reference roles. Additional drawings connect accepted poses rather than introducing unnecessary choreography. Clarify whether a requested count covers the whole scene or only its main loop. + +For every required phase, record: phase ID, intended pose/expression, shoulder/elbow/wrist and palm orientation, prop state/contact, effect origin/progression, target clip, frame names and intended timing. Keep a brief phase matrix in `action-brief.md` and the machine-readable mapping in `character.json`: + +Before generation, write a facial beat map for anticipation -> effort/action -> contact -> reaction -> recovery: gaze, eyelid openness, brow shape and mouth state for each key pose. Facial identity (eye design, proportions, distinctive features) is fixed; the canonical frame's sleepy eyelids, O-shaped mouth or other momentary emotion is not. Specify the next expression explicitly rather than repeatedly asking for the canonical face. Record each persistent prop's fixed geometry and states, including grip, contact surface, hinge/pivot and occlusion. Inspect both visible and hidden limb chains in every active pose; a third hand or a hand without a connected arm is a rejection. + +```json +"required_phases": { + "smoke": [ + {"id": "inhale", "clip": "smoke", "frames": ["smoke-contact"]}, + {"id": "exhale", "clip": "smoke", "frames": ["smoke-exhale"]}, + {"id": "ash-tap", "clip": "smoke", "frames": ["smoke-ash"]}, + {"id": "recovery", "clip": "smoke", "frames": ["smoke-recover"]} + ] +} +``` + +This is an illustration; create real drawings and a complete mapping for the requested action. A repeatable phase belongs in the main clip when the user expects to see it every cycle. Entrance/exit frames do not count as missing main-loop content. The required phase list must retain the user's request. Never delete a phase because generation was difficult; changing scope needs an explicit user decision. A general “looks fine” does not retroactively authorize a silently omitted requirement. + +Start with compatible key poses, often three to six when suitable. Show a rough entry and main loop in the browser before producing many in-betweens. Mark drafts clearly. Correct incompatible anchors first. For each missing drawing, name its predecessor, successor and purpose: bridge, contact, accent or recovery. Numbering alone does not define playback order. + +## 6. Mandatory body AND object scale gate + +Run this gate after canonical approval, after every generated or repaired frame, after the key-pose set and before building/reviewing any completed action. Repeat it on the built atlas playback before approval. PNG dimensions alone are insufficient. + +1. Confirm the common source canvas and camera. Compare the canonical and neighboring drawings at exactly one display scale; never auto-fit each image independently. +2. Compare physical head size, torso width/length and relevant limb segments. Record actual landmark measurements in the brief for suspect frames and the visual reason for any change. Sitting lowers the head; bending and foreshortening change projected measurements. Do not force all heads to the same height or impose one pixel ratio on different perspectives. +3. Track real support contacts (feet, seated hips, hands) and trajectories. Distinguish whole-image translation from a wrongly enlarged body. Matching feet alone does not fix a changed head/body size. Smoke, symbols, hair and falling objects are not floor markers. +4. Compare every prop from its first appearance through entrance, main loop and exit: phones, cups, cans, cigarettes, toys, tools, furniture, weapons, laptops and any custom object. Record proportions relative to the same hand/body landmarks. A closed laptop behind the back must not become physically larger when opened on the lap; this is one example of the rule for all objects. Perspective changes projected geometry, not the object's actual dimensions. Check fixed proportions, joints/hinges and correct overlaps; no duplicate edges, floating contacts or unexplained growth. A planned physical change, such as inflation or unfolding, must be explicitly staged rather than treated as accidental drift. +5. Compare previous -> current -> next and the last -> first seam, using contact sheets/overlays as diagnostic views. A global size jump fails the gate even when the individual drawing looks good. Record a concrete scale/prop review result, not merely “same resolution.” +6. Repair the responsible layer before continuing: a source proportion error needs regeneration/targeted redraw; a translation needs a justified whole-image offset; wrong atlas addressing needs a build/player fix. Never independently stretch a frame to fill its bounding box or splice body parts to conceal the mismatch. + +Minor file-size discrepancies can be corrected with transparent padding when the physical drawing scale already matches. The historical seated-laptop import accepted approximately one pixel of width/two of height difference and applied a small whole-image floor offset; those were project-specific values, not global tolerances. Preserve exact source pixels and check clipping. Use explicit ground anchors only for known grounded poses, keeping airborne frames elevated. Do not align the whole character using an effect below the feet. + +Diagnose discontinuity before adding drawings. Inspect source -> explicit placement transform -> final atlas cell -> actual playback. A bridge cannot repair a whole drawing placed on a different floor. A progressive crown position is insufficient when footwear bounces. Track independent crown/face, shoulders, hips, footwear, hands and prop landmarks. For grounded poses, unexplained support movement fails review even when the head trajectory looks plausible. Preserve intended bending, sitting and facial acting; do not force constant crown height. + +Define support regions visually around real footwear, hips or other contact. Record the region and a diagnostic alpha threshold suitable for that artwork; faint stray alpha, hair and effects are not support. Check every nonzero-alpha pixel separately for clipping safety. A diagnostic threshold must never remove soft artwork. If an explicit translation would clip, choose a feasible common floor or justified common transparent padding and rebuild. Never crop real art or disable the clipping guard. + +After the key-pose build and after every final art/placement/order/timing change, maintain `continuity.json` and run `python scripts/review_continuity.py /character.json`. Annotate every active drawing using the current source hash from `build-report.json`. Coordinates and lengths are measured in the common SOURCE canvas, not auto-fitted thumbnail pixels. Include at least independent body landmarks and body sizes, plus measured dimensions/hinge/grip landmarks for every visible persistent prop. Use stable measurement names across neighboring drawings. Explain occlusion rather than inventing hidden measurements. Example record (replace every value with actual inspected measurements): + +```json +{ + "limits": {"support_px": 2, "landmark_px": 24, "size_fraction": 0.05}, + "frames": { + "action-01": { + "source_sha256": "", + "supports": [{"name": "footwear", "region": [100, 800, 500, 960], "alpha_threshold": 128, "grounded": true}], + "landmarks": {"face": [300, 180], "hips": [300, 580], "hand": [340, 520], "prop_hinge": [320, 540]}, + "sizes": {"head_width": 90, "torso_length": 260, "prop_base_width": 180}, + "anatomy": "", + "contact": "", + "face": "" + } + } +} +``` + +The example limits, threshold and regions are not universal defaults. Choose and explain useful limits for the project's canvas, perspective and movement; do not inflate them to hide a defect. An unsupported pose uses `"supports": []` and a nonempty `support_note`. Airborne/lifted contacts must be marked appropriately. The helper measures source and actual atlas support edges in declared regions, records applied offsets, and flags neighbor/seam/boundary changes in support, landmark trajectory and measured sizes. Source annotations must be updated after source edits. The report does not detect anatomy, infer trustworthy measurements or prove artistic success. Its flags require a concrete observed explanation for intentional motion/perspective, or a repair and new report. Support-only success never substitutes for body, object and facial review. + +## 7. Generate and repair exact beats + +Keep one concise frame brief with output filename, source references and their roles, both neighboring poses, phase, duration, exact change, retained details and rejection criteria. Specify image-left/image-right. Good prompts constrain the movement while retaining expressive face/body motion. + +```text +Create one complete transparent frame of the approved character. +Reference A fixes this character's identity, materials, colors and proportions. +Reference B fixes its canonical camera, physical scale and source canvas. +References C/D are accepted neighboring poses of this character. +Reference E, if provided, is a motion example only: do not copy its identity. +Beat: [one named phase and its progression from previous toward next]. +Supports: [actual body contacts]. Active motion: [trajectory and landmarks]. +Hand: [shoulder-elbow-wrist chain, palm side, grip, visible/occluded fingers]. +Object: [count, shape, fixed physical size, state, contact and overlap]. +Face: [gaze, eye/iris/pupil, brows, mouth, expression and progression]. +Effect: [source, direction, development and disappearance, if requested]. +Preserve [specific hair locks/tone, skin, clothing marks, fabric translucency, +shoe reflections, accessories, unchanged parts and lighting]. +Keep the full character, prop and effect inside the common transparent canvas. +Reject [specific risks]. Do not add new hair, accessories or background. +``` + +For an in-between, say exactly which part progresses how far between the accepted endpoints. Preserve grip/palm side unless the planned rotation changes them. For a repair, use the flawed frame for pose/composition, the canonical frame for identity/material and both neighbors for trajectory. Name only the defects to fix and preserve accepted features. Save a distinct replacement, inspect it, then change exact config references. If local repair damages anatomy/material, regenerate a coherent full frame from accepted anchors. + +Check the complete shoulder -> elbow -> forearm -> wrist -> palm -> fingers -> object chain. Hidden fingers need not be drawn. Adapt anatomy checks to the actual human, stylized or nonhuman design. Check lips/fingers/knees/support contact and object occlusion across the entire action when a defect recurs. + +Materials are invariants as well as colors: stable hair/skin/cloth patches must retain tone, texture, density and translucency. Natural reflections move with a pose; a global brightness shift, darker stockings or plastic-looking fabric is a defect. Check shoe highlights, iris/pupil and glasses as carefully as the hair. Preserve major hair locks, ends and length; no extra masses appearing near knees or elsewhere. Do not remove stains by flattening or blurring the whole material. + +Every action needs a deliberate facial-performance plan: gaze, eyes, brows and mouth develop through anticipation, action, peak and recovery. Include suitable emotional-effect beats in the action brief, guided by the successful library examples: an idea glint, a thought symbol, a joy accent, rhythm marks or another fitting effect. Show the proposed effect in the key-pose preview; adapt to the user's requested tone and retain an explicit no-effects preference. Do not put every symbol in every frame or cover the face with effects. Effects support readable facial acting; a changed arm or animated symbols over an unintentionally frozen face do not satisfy an expressive scene. For a faceless character use its designed equivalents, such as eye lights, ears or posture. Stable supports do not mean a frozen body. Breathing, gaze, facial movement and appropriate follow-through remain alive. + +Effects must have a physical or expressive source. Exhaled smoke starts at the mouth; an ember trail starts at the tip; ash separates from the tip and falls; a thought accent matches a visible facial beat. Preserve soft alpha. Check edges on light and dark backgrounds; invisible RGB under zero alpha is not a visible halo. Do not add an unrequested floor, shadow or effect. + +After two unsuccessful attempts at the same defect, change the references, prompt or staging and explain what remains wrong. Do not repeat blindly, remove requested phases or finalize a defective action. Respect tool refusals/capability limits and never claim missing artwork exists. + +## 8. Timing, counts and live review + +Budget seconds for phases before assigning holds. A bridge is usually shorter than a readable contact or emotional peak, but no universal duration applies. Neighboring effect drawings share a phase budget; do not give each a long pause. Recalculate totals when adding frames. Coherent typing/breathing/sway mini-cycles may repeat, but repeated steps are not new drawings and cannot replace missing movement. + +The completed 152-drawing reference provides these main-loop planning examples. Entrance/exit are excluded; counts are estimates, not automatic quality thresholds: + +| Pattern | Unique drawings | Steps | Approximate seconds where measured | +| --- | ---: | ---: | ---: | +| Dance | 20 | 20 | 3.28 | +| Idle breathing/blink | 7 | 7 | 2.82 | +| Cheer | 2 | 6 | | +| Sad reaction | 2 | 2 | | +| Alert | 1 | 1 | | +| Phone | 2 | 4 | | +| Selfie | 2 | 3 | | +| Camera wave | 1 | 1 | | +| Yawn/stretch | 3 | 3 | | +| Doze/wake | 2 | 4 | | +| Look around | 3 | 3 | | +| Smoking | 12 | 12 | 3.55 | +| Drink from a can | 6 | 6 | 5.30 | +| Short effect | 6 | 12 | | +| Handheld game | 4 | 6 | | +| Snack | 11 | 12 | 2.90 | +| Music | 10 | 17 | 3.89 | +| Plush hug | 9 | 9 | 2.30 | +| Laptop | 15 | 19 | 4.29 | +| Hair tuck | 5 | 5 | | +| Brief wave | 1 | 1 | | + +A one-drawing reaction is a held pose; add motion when animation is requested. Always report whole-scene drawings, main-loop drawings, repeated steps and seconds separately. A 12-drawing entrance plus 12-drawing main loop is 24 drawings if disjoint. Reusing the entrance for a physically valid reversed exit adds no new drawings. Never reverse irreversible consumption/spilling to invent an exit. + +Keep generated frames visible in the browser as they arrive. After accepted key poses, intermediate fills, repairs and timing changes, rebuild the current action and observe it in that same browser. The workbench refreshes files/builds automatically; verify its displayed project/build ID and the selected action. Use `/plugin/index.html?action=` for a direct player view if needed. After interruption, verify the loopback server still responds. Do not confuse an old installed canvas card with the preview. + +Review four distinct views: full-size source against canonical; previous/current/next at shared scale; close-ups of hands/props/materials/effects; actual built main loop at card size, separately from entry/exit. Watch at least two main loops. A finite demo containing entrance/loop/exit can conceal that the main loop only contains two drawings; show and report the main loop itself. A successful tool click, file count or green mechanical QA is not visual proof. + +Cover EVERY built playback step deterministically. The workbench's Deterministic atlas review pauses on a named clip and zero-based step; Previous/Next and the step selector cannot skip short holds. It displays build ID, drawing name, duration, previous/current/next actual atlas cells at one shared scale, and the current source before placement. Support lines/offsets come from the current continuity report. Use `scene/` to traverse idle, full entrance, two main loops, full exit and idle, including cross-clip boundaries. An individual clip wraps within itself, so inspect actual entrance/exit boundaries through the scene option. Also inspect full-size source and the live player; deterministic cells do not prove timing or the live runtime's clip selection. Capture real-time playback when available to compare with the ordered sheet; sparse screenshots alone cannot establish complete coverage. If capture is unavailable, report that limitation and still step through every frame and observe the full sequence live. + +## 9. Build configuration and commands + +Keep the upload, approved source, frames, reports and action brief outside `plugin/`. Start from `examples/character.example.json`, set the real ID/name, and use project-relative paths. Do not copy developer paths or example mascot IDs. Script paths below are relative to the pipeline; project paths belong to this user. + +```text +python scripts/qa_frames.py /character.json +python scripts/scaffold_plugin.py /character.json +python scripts/build_sprite.py /character.json +python scripts/preview_animation.py /character.json --action +python scripts/review_continuity.py /character.json +``` + +Scaffold only an empty plugin directory. Preserve a customized existing player/page during updates. Rebuild after art, timing, order or metadata changes. Draft QA/build/preview is allowed without final approval. + +- `clips` maps IDs to `[frame-name, seconds]` steps. Repeated mini-cycles use `{"seq": [["frame", 0.2]], "times": 3}`. Durations are finite positive seconds and repeat counts positive integers. +- `action:in`, `action`, `action:out` separate entry, main loop and exit. Prefer explicit clips. Legacy `to-action`/`from-action` frames are included by QA/build if present. `deck` lists selectable actions; `labels` uses the user's UI language. `loops` controls finite repeats in All actions, and `follow` optional related actions. +- The selected action enters once, repeats its main clip until changed, then exits at a compatible clip boundary. Latest selection during transitions wins. Same selection does not restart. Do not return to idle after every main cycle. Design shorter transitions if quicker switching is needed; do not cut into incompatible poses. +- `floor_frame` identifies the canonical reference, reviewed even outside playable clips. `scale: 1` preserves source detail. Larger positive integers downsample every drawing equally with premultiplied alpha. One common crop/scale retains the sequence's full extent. +- Coordinates/alpha are preserved by default. No automatic chroma key or alpha threshold removes soft smoke. Optional `floor_y` and `ground_anchors` supply actual grounded contact rows, for example `"floor_y": 900, "ground_anchors": {"wave-02": 897}` shifts that whole frame down three pixels. `airborne` frames are excluded. Clipping a translated drawing fails. No per-frame stretching/warping is allowed. +- `cols`, `rows_per_sheet`, `margin_x`, `pad_bottom` govern packing. Atlas sides are capped at 4096 pixels; oversized cells need less padding or a justified shared scale. Keep host size limits. Generated `shadow` defaults to false. +- `name-fixed.png` takes precedence over `name.png` for compatibility. QA/build reports record the exact resolved source. Prefer explicit replacement names and retain accepted originals. + +`build-report.json` records config/source/package hashes, `build_id`, per-clip drawings/steps/seconds and source-canvas/crop/common-scale/per-frame placement transforms. `frames.js` provides index-to-name mapping; the player exposes the build ID (`document.documentElement.dataset.buildId`). The ID includes current source, config, runtime and atlas asset hashes. Changing art, placement, order, timing or package assets invalidates previous build-specific approval. Preview/finalization reject stale package evidence. The atlas-based WebP demonstration repeats the whole entry/cycles/exit when its file loops; the live player's selected mode has different, intentional behavior. The source PNG review remains separate. Ordered sheets show names, atlas indices, holds and build ID at a common scale; report entrance, main-loop and exit counts/seconds separately. + +## 10. Approval and explicit installation + +Preserve the user's complete agreed action/phase configuration as `character-full-plan.json` before reducing `character.json` for a partial delivery. Keep all requested actions and phase IDs in that full plan, including unfinished work. Update it only when the user explicitly changes scope, and record the reason in the brief. Never remove a squat, transition, object interaction or other requirement merely to make validation green. A new full-plan hash needs new build-specific user acceptance. + +After actual full-size and playback review, record a specific QA note: + +```text +python scripts/qa_frames.py /character.json --approve "" +``` + +Create `review.json` for the exact current `build_id`. It must include `user_approved: true` only after the user approves that shown build; `checks` with nonempty observation notes for `source`, `scale`, `neighbors`, `materials_contact`, `effects`, `main_loop`, `boundaries`, `browser`; and `phases` mapping each action and each required phase ID to its observed result. Record why a check is inapplicable where genuinely so; do not fabricate an observation. Example structure: + +```json +{ + "build_id": "", + "user_approved": true, + "installation_approved": false, + "checks": { + "source": "", + "scale": "", + "neighbors": "", + "materials_contact": "", + "effects": "", + "main_loop": "", + "boundaries": "", + "browser": "" + }, + "phases": {"": {"": ""}}, + "coverage": {"": {"steps": [0, 1, 2], "note": ""}}, + "playback": {"": {"main_loops": 2, "entrance": true, "exit": true, "note": ""}}, + "continuity_manifest_sha256": "", + "continuity_report_sha256": "", + "continuity_decisions": {"": ""}, + "unresolved": [] +} +``` + +This structure records review evidence; filled text alone cannot prove artistic quality. Never autofill positive claims from mechanical QA. The finalizer checks mapped phase presence, review coverage, current hashes and build approval. Retain every user-required phase; deleting a requirement to pass validation is invalid. + +Add `observations` for every key in `checks`. Each entry has `mode`, `evidence` (the actual inspected view/capture and finding), and `build_id` for `observed_current_build`. New source art, scale, neighboring steps, main-loop timing, boundaries and browser runtime are different observations; an unchanged atlas does not prove new timing or placement. `reused_unchanged_pixels` is allowed only for `source`, `materials_contact` or `effects`; record `previous_build_id`, the prior observation's evidence, and `source_sha256` mapping every source drawing claimed unchanged to its exact current hash. This reuses only that stated pixel evidence, never user approval or live playback. `unverified` is not final approval. The finalizer rejects missing/stale provenance, but it cannot establish whether an observation is truthful: actually inspect the views. Do not manufacture coverage notes or bulk-dismiss flags with generic descriptions of poses. When `character-full-plan.json` exists, include its exact `full_plan_sha256` in the review. Final delivery must retain every full-plan action/phase. + +### Explicitly accepted draft delivery + +If the user requests the current incomplete result for inspection, do not force it through final approval. Explain its exact build, missing phases and known defects first. Preserve the full plan and create separate `draft-review.json` with the same checks, provenance, coverage, phases, playback and continuity hashes as above, plus: + +```json +{ + "build_id": "", + "user_approved": false, + "draft_approved": true, + "installation_approved": true, + "full_plan_sha256": "", + "deferred_phases": ["/"], + "unresolved": [""], + "limitations": ["", ""] +} +``` + +Merge these fields into the complete evidence record, not a standalone minimal approval. `deferred_phases` lists every absent full-plan phase in full-plan action/phase order; `limitations` contains every deferred ID and every unresolved concern verbatim, with explanatory context where useful. `draft_approved` and `installation_approved` reflect the user's explicit acceptance of this identified draft and its disclosed limits. Keep `review.json` untouched; do not copy old approval into it. An unresolved continuity flag remains its exact flag ID in `unresolved` and `limitations`, rather than an invented positive decision. A check that cannot be observed uses provenance mode `unverified` and its check key in both lists; state why and what remains to inspect. Every available built step and live sequence must still be reviewed where accessible. Structural/source/package/hash QA remains mandatory; draft mode does not allow missing files, invalid geometry, stale builds or arbitrary runtime code. + +If live playback itself cannot be observed, keep `main_loop` in both limitation lists and record that action's `playback` as `{"mode": "unverified", "note": ""}`. Do not invent loops or transition observations. This exception applies only to a disclosed, user-accepted draft; final delivery still requires the real live sequence. + +Run `python scripts/finalize_mascot.py /character.json --draft` to record an accepted draft without requesting installation; use `--draft --install-approved` only with that draft's separate installation consent. The host verifies the same mode with `--draft --verify-only --install-approved`. A draft handoff carries `delivery: draft` and visible `limitations`; it never means the complete animation is finally approved. Continue deferred work against the preserved full plan. Final delivery uses `review.json`, fresh current-build evidence and no unresolved concerns, without `--draft`. + +`coverage` must list every zero-based step exactly once for every configured clip, including idle, full entrance and exit, not only unique drawings. `playback` records at least two actual repeating main loops per selectable action and full entrance/exit observation where present. Keep unresolved concerns visible while working; an approved final result requires an explicitly empty `unresolved` list. Every current continuity flag needs a substantive observed decision; do not use boilerplate to dismiss unexplained growth, support drift, disconnected limbs or impossible hinges. The finalizer rejects stale continuity annotations/reports and missing step coverage. Re-run QA after the final placement/config/source change, rebuild, measure the final atlas, then make new visual findings for that build. Never copy approval of an earlier draft forward. + +Report machine checks, full-size art inspection, all-step/adjacent-frame inspection, live playback, user approval, host-installed ID and loaded runtime ID as separate facts. An observed `installedBuildId` confirms the host's installed package; it does not independently establish drag/resize/zoom/close or that an already open runtime loaded the same build. Check those separately when reporting them. + +Run `python scripts/finalize_mascot.py /character.json` to produce `status: reviewed`, which does not request installation. Then ask separately whether to install/replace this project/build on the canvas, unless the user has already explicitly authorized installation of this reviewed result. Show project identity and build ID. “Finish the animation” or appearance approval alone is not installation approval. + +After the user's installation decision, set `installation_approved: true` for that build and run: + +```text +python scripts/finalize_mascot.py /character.json --install-approved +``` + +This writes `mascot-result.json` with `ready_for_host`, matching `buildId`, `installationApproved: true`, actions, plugin directory and transparent/resizable presentation. CanvasTTY verifies it, installs/replaces only the same project's package, saves the previous installed version, and reloads its card. An old `installedAt` must not prevent an approved update. Host validation uses `--verify-only --install-approved` without rewriting the requested result. + +Use only this finalizer and CanvasTTY's registered local-mascot installer. Never write `ready_for_host` by hand, copy a package into the host's plugins directory, edit its registry, reset the project record on disk or rewrite packaged application files to bypass review. Package files alone do not register a plugin. An existing unregistered destination is preserved and reported as a collision; establish its owner and obtain approval for any archive/move before recovery. Do not overwrite an unknown directory. + +After a rejection, inspect the project's `mascot-project.json`, `mascot-result.json` and `installation-error.log`. State the stage (creation, handoff, package/review validation, registration or card load), short root cause, requested build, installed build and registered plugin as separate facts. `window.canvasTTY.mascots.list()` in the trusted host renderer exposes these fields; do not attempt this host-only API from an untrusted plugin iframe. The full log retains stderr/traceback; do not rely on a truncated command prefix. Correct the underlying issue, regenerate a current approved handoff through the finalizer, and let the host retry the changed result even if the project was previously `failed`. An unchanged rejected result is not polled repeatedly. Use Settings > Mascots > Retry installation (or the trusted host's `mascots.retry(projectId)`) for an explicit retry, including transient failures, without restarting or editing in-memory state through disk. A retry queued during a check is handled on the following poll. Registry confirmation still does not prove the open card's runtime build; inspect that independently. If runtime/browser access is unavailable, report it as unverified rather than promising success. + +`ready_for_host` is a request, not proof of installation. Confirm the loaded runtime build matches the reviewed build. Check the actual card's transparent background, drag by the character, menu interaction outside drag regions, proportional resizing down to small sizes, hover-only resize corner (available for touch), close button and visible mascot when zoomed out. The parent card belongs to CanvasTTY; iframe CSS cannot make the parent transparent. Report any unobserved host behavior as unverified. Do not patch application files as a generic mascot-creation fallback. + +## 11. Continue safely and preserve the result + +On resume, read the project brief, actual config, accepted references and current files. Verify preview server/build, then continue missing work. Do not rerun obsolete experiment scripts just because their filenames sound relevant. New art/order/timing/package changes invalidate dependent review and installation approval. + +Keep one concise brief with source provenance, exact prompts/reference roles, accepted/rejected frames and reasons, phase order/timing, measurements for scale repairs, current preview and unresolved defects. Preserve original art and previous accepted versions. Do not modify this global skill during ordinary mascot creation; improvements belong to a separate user-requested task. + +The result is complete only when the requested action set, scale gate, visual review and agreed delivery stage are complete. Known defects stay unresolved until rechecked. Technical QA cannot guarantee perfect generative output; it prevents structural errors while source/sequence/browser review decides whether the art works. diff --git a/assets/mascot-pipeline/examples/character.example.json b/assets/mascot-pipeline/examples/character.example.json new file mode 100644 index 000000000..d51776920 --- /dev/null +++ b/assets/mascot-pipeline/examples/character.example.json @@ -0,0 +1,21 @@ +{ + "id": "new-mascot", + "name": "New Mascot", + "description": "An animated mascot for CanvasTTY.", + "frames_dir": "frames", + "plugin_dir": "plugin", + "floor_frame": "idle-01", + "airborne": [], + "scale": 1, + "cols": 6, + "rows_per_sheet": 5, + "pad_bottom": 48, + "margin_x": 40, + "shadow": false, + "clips": { + "idle": [["idle-01", 0.45]] + }, + "deck": [], + "required_phases": {}, + "labels": {} +} diff --git a/assets/mascot-pipeline/examples/motion-library/set-a/atlas-0.webp b/assets/mascot-pipeline/examples/motion-library/set-a/atlas-0.webp new file mode 100644 index 000000000..24ef94d57 Binary files /dev/null and b/assets/mascot-pipeline/examples/motion-library/set-a/atlas-0.webp differ diff --git a/assets/mascot-pipeline/examples/motion-library/set-a/atlas-1.webp b/assets/mascot-pipeline/examples/motion-library/set-a/atlas-1.webp new file mode 100644 index 000000000..1e589417e Binary files /dev/null and b/assets/mascot-pipeline/examples/motion-library/set-a/atlas-1.webp differ diff --git a/assets/mascot-pipeline/examples/motion-library/set-a/atlas-2.webp b/assets/mascot-pipeline/examples/motion-library/set-a/atlas-2.webp new file mode 100644 index 000000000..f6f8b4400 Binary files /dev/null and b/assets/mascot-pipeline/examples/motion-library/set-a/atlas-2.webp differ diff --git a/assets/mascot-pipeline/examples/motion-library/set-a/atlas-3.webp b/assets/mascot-pipeline/examples/motion-library/set-a/atlas-3.webp new file mode 100644 index 000000000..6c29c56f6 Binary files /dev/null and b/assets/mascot-pipeline/examples/motion-library/set-a/atlas-3.webp differ diff --git a/assets/mascot-pipeline/examples/motion-library/set-a/atlas-4.webp b/assets/mascot-pipeline/examples/motion-library/set-a/atlas-4.webp new file mode 100644 index 000000000..d870c8656 Binary files /dev/null and b/assets/mascot-pipeline/examples/motion-library/set-a/atlas-4.webp differ diff --git a/assets/mascot-pipeline/examples/motion-library/set-a/atlas-5.webp b/assets/mascot-pipeline/examples/motion-library/set-a/atlas-5.webp new file mode 100644 index 000000000..56e88f801 Binary files /dev/null and b/assets/mascot-pipeline/examples/motion-library/set-a/atlas-5.webp differ diff --git a/assets/mascot-pipeline/examples/motion-library/set-a/motion.json b/assets/mascot-pipeline/examples/motion-library/set-a/motion.json new file mode 100644 index 000000000..4b7826fec --- /dev/null +++ b/assets/mascot-pipeline/examples/motion-library/set-a/motion.json @@ -0,0 +1,882 @@ +{ + "cols": 6, + "perSheet": 30, + "cell": [ + 508, + 792 + ], + "sheets": [ + { + "file": "atlas-0.webp", + "rows": 5 + }, + { + "file": "atlas-1.webp", + "rows": 5 + }, + { + "file": "atlas-2.webp", + "rows": 5 + }, + { + "file": "atlas-3.webp", + "rows": 5 + }, + { + "file": "atlas-4.webp", + "rows": 5 + }, + { + "file": "atlas-5.webp", + "rows": 1 + } + ], + "clips": { + "dance": [ + [ + 0, + 0.26 + ], + [ + 1, + 0.1 + ], + [ + 2, + 0.26 + ], + [ + 3, + 0.1 + ], + [ + 4, + 0.26 + ], + [ + 5, + 0.1 + ], + [ + 6, + 0.1 + ], + [ + 7, + 0.26 + ], + [ + 8, + 0.1 + ], + [ + 9, + 0.1 + ], + [ + 10, + 0.26 + ], + [ + 11, + 0.1 + ], + [ + 12, + 0.26 + ], + [ + 13, + 0.1 + ], + [ + 14, + 0.1 + ], + [ + 15, + 0.26 + ], + [ + 16, + 0.1 + ], + [ + 17, + 0.26 + ], + [ + 18, + 0.1 + ], + [ + 19, + 0.1 + ] + ], + "idle": [ + [ + 20, + 0.45 + ], + [ + 21, + 0.45 + ], + [ + 22, + 0.45 + ], + [ + 23, + 0.12 + ], + [ + 24, + 0.45 + ], + [ + 25, + 0.45 + ], + [ + 26, + 0.45 + ] + ], + "cheer": [ + [ + 27, + 0.3 + ], + [ + 28, + 0.3 + ], + [ + 27, + 0.3 + ], + [ + 28, + 0.3 + ], + [ + 27, + 0.3 + ], + [ + 28, + 0.3 + ] + ], + "sad": [ + [ + 29, + 1.2 + ], + [ + 30, + 1.4 + ] + ], + "alert": [ + [ + 31, + 1.6 + ] + ], + "phone": [ + [ + 32, + 1.2 + ], + [ + 33, + 0.9 + ], + [ + 32, + 0.9 + ], + [ + 33, + 1 + ] + ], + "selfie": [ + [ + 34, + 1.2 + ], + [ + 35, + 1.2 + ], + [ + 34, + 0.8 + ] + ], + "wavecam": [ + [ + 36, + 1.6 + ] + ], + "sleepy": [ + [ + 37, + 1.3 + ], + [ + 38, + 1 + ], + [ + 39, + 0.9 + ] + ], + "doze": [ + [ + 40, + 1.6 + ], + [ + 41, + 0.6 + ], + [ + 40, + 1.4 + ], + [ + 41, + 0.9 + ] + ], + "look": [ + [ + 42, + 1.1 + ], + [ + 43, + 1.1 + ], + [ + 44, + 1.4 + ] + ], + "smoke": [ + [ + 45, + 0.4 + ], + [ + 46, + 0.2 + ], + [ + 47, + 0.2 + ], + [ + 48, + 0.25 + ], + [ + 49, + 0.4 + ], + [ + 50, + 0.35 + ], + [ + 51, + 0.2 + ], + [ + 52, + 0.2 + ], + [ + 53, + 0.3 + ], + [ + 54, + 0.4 + ], + [ + 55, + 0.35 + ], + [ + 56, + 0.3 + ] + ], + "energy": [ + [ + 57, + 1 + ], + [ + 58, + 0.5 + ], + [ + 59, + 0.7 + ], + [ + 60, + 0.9 + ], + [ + 61, + 0.8 + ], + [ + 62, + 1.4 + ] + ], + "rainbow": [ + [ + 63, + 0.8 + ], + [ + 64, + 0.35 + ], + [ + 65, + 0.18 + ], + [ + 66, + 0.18 + ], + [ + 65, + 0.18 + ], + [ + 66, + 0.18 + ], + [ + 65, + 0.18 + ], + [ + 66, + 0.18 + ], + [ + 65, + 0.18 + ], + [ + 66, + 0.18 + ], + [ + 67, + 0.6 + ], + [ + 68, + 1.2 + ] + ], + "game": [ + [ + 69, + 0.9 + ], + [ + 70, + 0.4 + ], + [ + 71, + 0.4 + ], + [ + 70, + 0.4 + ], + [ + 71, + 0.4 + ], + [ + 72, + 1.2 + ] + ], + "snack": [ + [ + 73, + 0.3 + ], + [ + 74, + 0.2 + ], + [ + 75, + 0.2 + ], + [ + 76, + 0.2 + ], + [ + 77, + 0.3 + ], + [ + 78, + 0.2 + ], + [ + 79, + 0.3 + ], + [ + 80, + 0.25 + ], + [ + 79, + 0.25 + ], + [ + 81, + 0.2 + ], + [ + 82, + 0.2 + ], + [ + 83, + 0.3 + ] + ], + "music": [ + [ + 84, + 0.3 + ], + [ + 85, + 0.18 + ], + [ + 86, + 0.18 + ], + [ + 87, + 0.18 + ], + [ + 86, + 0.18 + ], + [ + 85, + 0.18 + ], + [ + 86, + 0.18 + ], + [ + 87, + 0.18 + ], + [ + 86, + 0.18 + ], + [ + 88, + 0.25 + ], + [ + 89, + 0.25 + ], + [ + 88, + 0.25 + ], + [ + 89, + 0.25 + ], + [ + 90, + 0.3 + ], + [ + 91, + 0.25 + ], + [ + 92, + 0.3 + ], + [ + 93, + 0.3 + ] + ], + "plush": [ + [ + 94, + 0.3 + ], + [ + 95, + 0.2 + ], + [ + 96, + 0.3 + ], + [ + 97, + 0.2 + ], + [ + 98, + 0.2 + ], + [ + 99, + 0.3 + ], + [ + 100, + 0.25 + ], + [ + 101, + 0.25 + ], + [ + 102, + 0.3 + ] + ], + "laptop": [ + [ + 103, + 0.18 + ], + [ + 104, + 0.18 + ], + [ + 105, + 0.18 + ], + [ + 106, + 0.18 + ], + [ + 103, + 0.18 + ], + [ + 104, + 0.18 + ], + [ + 105, + 0.18 + ], + [ + 106, + 0.18 + ], + [ + 107, + 0.2 + ], + [ + 108, + 0.2 + ], + [ + 109, + 0.25 + ], + [ + 110, + 0.3 + ], + [ + 111, + 0.2 + ], + [ + 112, + 0.3 + ], + [ + 113, + 0.2 + ], + [ + 114, + 0.3 + ], + [ + 115, + 0.3 + ], + [ + 116, + 0.3 + ], + [ + 117, + 0.3 + ] + ], + "dance:in": [ + [ + 118, + 0.18 + ] + ], + "dance:out": [ + [ + 119, + 0.18 + ] + ], + "cheer:in": [ + [ + 120, + 0.18 + ] + ], + "cheer:out": [ + [ + 121, + 0.18 + ] + ], + "phone:in": [ + [ + 122, + 0.18 + ] + ], + "phone:out": [ + [ + 123, + 0.18 + ] + ], + "selfie:in": [ + [ + 124, + 0.18 + ] + ], + "selfie:out": [ + [ + 125, + 0.18 + ] + ], + "wavecam:in": [ + [ + 126, + 0.18 + ] + ], + "wavecam:out": [ + [ + 127, + 0.18 + ] + ], + "sleepy:in": [ + [ + 128, + 0.18 + ] + ], + "sleepy:out": [ + [ + 129, + 0.18 + ] + ], + "doze:in": [ + [ + 130, + 0.18 + ] + ], + "doze:out": [ + [ + 131, + 0.18 + ] + ], + "look:in": [ + [ + 132, + 0.18 + ] + ], + "look:out": [ + [ + 133, + 0.18 + ] + ], + "smoke:in": [ + [ + 134, + 0.18 + ] + ], + "smoke:out": [ + [ + 135, + 0.18 + ] + ], + "energy:in": [ + [ + 136, + 0.18 + ] + ], + "energy:out": [ + [ + 137, + 0.18 + ] + ], + "rainbow:in": [ + [ + 138, + 0.18 + ] + ], + "rainbow:out": [ + [ + 139, + 0.18 + ] + ], + "game:in": [ + [ + 140, + 0.18 + ] + ], + "game:out": [ + [ + 141, + 0.18 + ] + ], + "snack:in": [ + [ + 142, + 0.18 + ] + ], + "snack:out": [ + [ + 143, + 0.18 + ] + ], + "music:in": [ + [ + 144, + 0.18 + ] + ], + "music:out": [ + [ + 145, + 0.18 + ] + ], + "plush:in": [ + [ + 146, + 0.18 + ] + ], + "plush:out": [ + [ + 147, + 0.18 + ] + ], + "laptop:in": [ + [ + 148, + 0.18 + ] + ], + "laptop:out": [ + [ + 149, + 0.18 + ] + ], + "tuck": [ + [ + 20, + 0.45 + ], + [ + 21, + 0.45 + ], + [ + 150, + 1.6 + ], + [ + 25, + 0.45 + ], + [ + 26, + 0.45 + ] + ], + "wave": [ + [ + 151, + 1.4 + ] + ] + } +} diff --git a/assets/mascot-pipeline/examples/motion-library/set-b/atlas-0.webp b/assets/mascot-pipeline/examples/motion-library/set-b/atlas-0.webp new file mode 100644 index 000000000..4d964adb7 Binary files /dev/null and b/assets/mascot-pipeline/examples/motion-library/set-b/atlas-0.webp differ diff --git a/assets/mascot-pipeline/examples/motion-library/set-b/atlas-1.webp b/assets/mascot-pipeline/examples/motion-library/set-b/atlas-1.webp new file mode 100644 index 000000000..3e6ea822b Binary files /dev/null and b/assets/mascot-pipeline/examples/motion-library/set-b/atlas-1.webp differ diff --git a/assets/mascot-pipeline/examples/motion-library/set-b/atlas-2.webp b/assets/mascot-pipeline/examples/motion-library/set-b/atlas-2.webp new file mode 100644 index 000000000..f9a533c60 Binary files /dev/null and b/assets/mascot-pipeline/examples/motion-library/set-b/atlas-2.webp differ diff --git a/assets/mascot-pipeline/examples/motion-library/set-b/atlas-3.webp b/assets/mascot-pipeline/examples/motion-library/set-b/atlas-3.webp new file mode 100644 index 000000000..12fd739bf Binary files /dev/null and b/assets/mascot-pipeline/examples/motion-library/set-b/atlas-3.webp differ diff --git a/assets/mascot-pipeline/examples/motion-library/set-b/atlas-4.webp b/assets/mascot-pipeline/examples/motion-library/set-b/atlas-4.webp new file mode 100644 index 000000000..975eaa12a Binary files /dev/null and b/assets/mascot-pipeline/examples/motion-library/set-b/atlas-4.webp differ diff --git a/assets/mascot-pipeline/examples/motion-library/set-b/motion.json b/assets/mascot-pipeline/examples/motion-library/set-b/motion.json new file mode 100644 index 000000000..170e79f4e --- /dev/null +++ b/assets/mascot-pipeline/examples/motion-library/set-b/motion.json @@ -0,0 +1,191 @@ +{ + "cols": 4, + "perSheet": 8, + "cell": [ + 861, + 1668 + ], + "sheets": [ + { + "file": "atlas-0.webp", + "rows": 2 + }, + { + "file": "atlas-1.webp", + "rows": 2 + }, + { + "file": "atlas-2.webp", + "rows": 2 + }, + { + "file": "atlas-3.webp", + "rows": 2 + }, + { + "file": "atlas-4.webp", + "rows": 2 + } + ], + "clips": { + "smoke:in": [[1, 0.18]], + "smoke": [[2, 0.4], [3, 0.2], [4, 0.2], [5, 0.25], [6, 0.4], [7, 0.35], [8, 0.2], [9, 0.2], [10, 0.3], [11, 0.4], [12, 0.35], [13, 0.3]], + "smoke:out": [[14, 0.18]], + "idle": [ + [ + 0, + 0.6 + ] + ], + "laptop": [ + [ + 15, + 0.2 + ], + [ + 16, + 0.22 + ], + [ + 17, + 0.28 + ], + [ + 18, + 0.24 + ], + [ + 19, + 0.34 + ], + [ + 20, + 0.4 + ], + [ + 21, + 0.3 + ], + [ + 22, + 0.34 + ], + [ + 23, + 0.22 + ], + [ + 24, + 0.42 + ], + [ + 25, + 0.24 + ], + [ + 26, + 0.2 + ] + ], + "laptop:in": [ + [ + 27, + 0.2 + ], + [ + 28, + 0.22 + ], + [ + 29, + 0.22 + ], + [ + 30, + 0.26 + ], + [ + 31, + 0.2 + ], + [ + 32, + 0.2 + ], + [ + 33, + 0.2 + ], + [ + 34, + 0.22 + ], + [ + 35, + 0.2 + ], + [ + 36, + 0.28 + ], + [ + 37, + 0.24 + ], + [ + 38, + 0.28 + ] + ], + "laptop:out": [ + [ + 38, + 0.28 + ], + [ + 37, + 0.24 + ], + [ + 36, + 0.28 + ], + [ + 35, + 0.2 + ], + [ + 34, + 0.22 + ], + [ + 33, + 0.2 + ], + [ + 32, + 0.2 + ], + [ + 31, + 0.2 + ], + [ + 30, + 0.26 + ], + [ + 29, + 0.22 + ], + [ + 28, + 0.22 + ], + [ + 27, + 0.2 + ] + ] + } +} diff --git a/assets/mascot-pipeline/examples/motion-library/set-c/atlas-0.webp b/assets/mascot-pipeline/examples/motion-library/set-c/atlas-0.webp new file mode 100644 index 000000000..238c69ce6 Binary files /dev/null and b/assets/mascot-pipeline/examples/motion-library/set-c/atlas-0.webp differ diff --git a/assets/mascot-pipeline/examples/motion-library/set-c/motion.json b/assets/mascot-pipeline/examples/motion-library/set-c/motion.json new file mode 100644 index 000000000..046adc727 --- /dev/null +++ b/assets/mascot-pipeline/examples/motion-library/set-c/motion.json @@ -0,0 +1,148 @@ +{ + "cols": 5, + "perSheet": 20, + "cell": [ + 512, + 780 + ], + "clips": { + "idle": [ + [ + 0, + 0.6 + ] + ], + "laptop:in": [ + [ + 1, + 0.24 + ], + [ + 2, + 0.24 + ], + [ + 3, + 0.25 + ], + [ + 4, + 0.24 + ], + [ + 5, + 0.24 + ], + [ + 6, + 0.24 + ], + [ + 7, + 0.24 + ], + [ + 8, + 0.28 + ], + [ + 9, + 0.24 + ], + [ + 10, + 0.24 + ], + [ + 11, + 0.22 + ], + [ + 12, + 0.3 + ] + ], + "laptop": [ + [ + 12, + 0.35 + ], + [ + 13, + 0.35 + ], + [ + 14, + 0.45 + ], + [ + 15, + 0.45 + ], + [ + 12, + 0.35 + ], + [ + 13, + 0.35 + ] + ], + "laptop:out": [ + [ + 11, + 0.22 + ], + [ + 10, + 0.24 + ], + [ + 9, + 0.24 + ], + [ + 8, + 0.28 + ], + [ + 7, + 0.24 + ], + [ + 6, + 0.24 + ], + [ + 5, + 0.24 + ], + [ + 4, + 0.24 + ], + [ + 3, + 0.25 + ], + [ + 2, + 0.24 + ], + [ + 1, + 0.24 + ], + [ + 0, + 0.25 + ] + ] + }, + "sheets": [ + { + "file": "atlas-0.webp", + "rows": 4 + } + ] +} diff --git a/assets/mascot-pipeline/examples/placement-regression/case.json b/assets/mascot-pipeline/examples/placement-regression/case.json new file mode 100644 index 000000000..346178ca6 --- /dev/null +++ b/assets/mascot-pipeline/examples/placement-regression/case.json @@ -0,0 +1,295 @@ +{ + "provenance": "Approved aligned atlas; unaligned reconstruction from the exact same unchanged source drawings. Not an archived rejected build.", + "accepted_source_build": "210ae17bf35d0195", + "aligned_sha256": "ab5818c4546aeefc69e1dde121687f6ec2022fc5fde57ef59205216f2f03ec71", + "unaligned_sha256": "0ca0c886bb19c53468af4908c10b372ff85a3b200934502eff95c0ee4d8c2b53", + "source_canvas": [ + 1024, + 1536 + ], + "scale": 2, + "floor_y": 1446, + "cols": 5, + "cell": [ + 512, + 780 + ], + "alpha_threshold": 128, + "aligned_max_range_px": 1, + "unaligned_min_range_px": 25, + "regions": [ + { + "index": 0, + "source_support_y": 1457, + "shift_y": -11, + "unaligned_region": [ + 0, + 710, + 512, + 734 + ], + "aligned_region": [ + 0, + 705, + 512, + 729 + ] + }, + { + "index": 1, + "source_support_y": 1459, + "shift_y": -13, + "unaligned_region": [ + 0, + 711, + 512, + 735 + ], + "aligned_region": [ + 0, + 705, + 512, + 729 + ] + }, + { + "index": 2, + "source_support_y": 1460, + "shift_y": -14, + "unaligned_region": [ + 0, + 712, + 512, + 736 + ], + "aligned_region": [ + 0, + 705, + 512, + 729 + ] + }, + { + "index": 3, + "source_support_y": 1457, + "shift_y": -11, + "unaligned_region": [ + 0, + 710, + 512, + 734 + ], + "aligned_region": [ + 0, + 705, + 512, + 729 + ] + }, + { + "index": 4, + "source_support_y": 1407, + "shift_y": 39, + "unaligned_region": [ + 0, + 685, + 512, + 709 + ], + "aligned_region": [ + 0, + 705, + 512, + 729 + ] + }, + { + "index": 5, + "source_support_y": 1408, + "shift_y": 38, + "unaligned_region": [ + 0, + 686, + 512, + 710 + ], + "aligned_region": [ + 0, + 705, + 512, + 729 + ] + }, + { + "index": 6, + "source_support_y": 1453, + "shift_y": -7, + "unaligned_region": [ + 0, + 708, + 512, + 732 + ], + "aligned_region": [ + 0, + 705, + 512, + 729 + ] + }, + { + "index": 7, + "source_support_y": 1454, + "shift_y": -8, + "unaligned_region": [ + 0, + 709, + 512, + 733 + ], + "aligned_region": [ + 0, + 705, + 512, + 729 + ] + }, + { + "index": 8, + "source_support_y": 1436, + "shift_y": 10, + "unaligned_region": [ + 0, + 700, + 512, + 724 + ], + "aligned_region": [ + 0, + 705, + 512, + 729 + ] + }, + { + "index": 9, + "source_support_y": 1439, + "shift_y": 7, + "unaligned_region": [ + 0, + 701, + 512, + 725 + ], + "aligned_region": [ + 0, + 705, + 512, + 729 + ] + }, + { + "index": 10, + "source_support_y": 1441, + "shift_y": 5, + "unaligned_region": [ + 0, + 702, + 512, + 726 + ], + "aligned_region": [ + 0, + 705, + 512, + 729 + ] + }, + { + "index": 11, + "source_support_y": 1443, + "shift_y": 3, + "unaligned_region": [ + 0, + 703, + 512, + 727 + ], + "aligned_region": [ + 0, + 705, + 512, + 729 + ] + }, + { + "index": 12, + "source_support_y": 1467, + "shift_y": -21, + "unaligned_region": [ + 0, + 715, + 512, + 739 + ], + "aligned_region": [ + 0, + 705, + 512, + 729 + ] + }, + { + "index": 13, + "source_support_y": 1466, + "shift_y": -20, + "unaligned_region": [ + 0, + 715, + 512, + 739 + ], + "aligned_region": [ + 0, + 705, + 512, + 729 + ] + }, + { + "index": 14, + "source_support_y": 1467, + "shift_y": -21, + "unaligned_region": [ + 0, + 715, + 512, + 739 + ], + "aligned_region": [ + 0, + 705, + 512, + 729 + ] + }, + { + "index": 15, + "source_support_y": 1466, + "shift_y": -20, + "unaligned_region": [ + 0, + 715, + 512, + 739 + ], + "aligned_region": [ + 0, + 705, + 512, + 729 + ] + } + ], + "limits_note": "Footwear-region diagnostic values for this example only; no alpha is removed. Stable support does not prove body/prop scale, anatomy or facial performance." +} diff --git a/assets/mascot-pipeline/examples/placement-regression/unaligned.webp b/assets/mascot-pipeline/examples/placement-regression/unaligned.webp new file mode 100644 index 000000000..495cbb4d3 Binary files /dev/null and b/assets/mascot-pipeline/examples/placement-regression/unaligned.webp differ diff --git a/assets/mascot-pipeline/requirements.txt b/assets/mascot-pipeline/requirements.txt new file mode 100644 index 000000000..0d59398ea --- /dev/null +++ b/assets/mascot-pipeline/requirements.txt @@ -0,0 +1,2 @@ +numpy +Pillow diff --git a/assets/mascot-pipeline/scripts/build_sprite.py b/assets/mascot-pipeline/scripts/build_sprite.py new file mode 100644 index 000000000..06d65d983 --- /dev/null +++ b/assets/mascot-pipeline/scripts/build_sprite.py @@ -0,0 +1,286 @@ +"""Build sprite sheets and animation data for a CanvasTTY mascot plugin. + +Usage: python build_sprite.py path/to/character.json +Paths in character.json are relative to that file. Run qa_frames.py first. +""" +import json +import hashlib +import math +import os +import re +from pathlib import Path +import sys + +import numpy as np +from PIL import Image, ImageDraw, ImageFilter + + +def load_config(path): + with open(path, encoding='utf-8') as f: + c = json.load(f) + root = Path(path).resolve().parent + for key in ('frames_dir', 'plugin_dir'): + if key not in c: + raise ValueError(f'missing {key} in {path}') + c[key] = str((root / c[key]).resolve()) + c.setdefault('scale', 1) + c.setdefault('cols', 6) + c.setdefault('pad_bottom', 48) + c.setdefault('margin_x', 40) + c.setdefault('shadow', False) + c.setdefault('transition_sec', 0.18) + c.setdefault('airborne', []) + c.setdefault('ground_anchors', {}) + if not c.get('clips'): + raise ValueError('character.json needs at least one clip') + for key in ('scale', 'cols', 'rows_per_sheet'): + value = c.get(key, 5) + if not isinstance(value, int) or isinstance(value, bool) or value < 1: + raise ValueError(f'{key} must be a positive integer') + return c + + +def expand(seq): + """Clip entries are [frame, seconds] or {"seq": [[frame, seconds], ...], "times": n}.""" + out = [] + for item in seq: + if isinstance(item, dict): + times = item.get('times', 1) + if not isinstance(times, int) or isinstance(times, bool) or times < 1: + raise ValueError('sequence times must be a positive integer') + out += expand(item['seq']) * times + else: + name, duration = item[0], float(item[1]) + if not re.fullmatch(r'[a-z0-9][a-z0-9._-]*', name) or not math.isfinite(duration) or duration <= 0: + raise ValueError(f'invalid frame name or duration: {item}') + out.append((name, duration)) + return out + + +def digest(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def frame_path(directory, name): + for suffix in ('-fixed.png', '.png'): + path = Path(directory) / f'{name}{suffix}' + if path.is_file(): + return path + return None + + +def resolve_clips(c): + """QA, build and handoff must refer to the same explicit and legacy clips.""" + clips = {name: expand(seq) for name, seq in c['clips'].items()} + breath = c.get('breath') + if breath: + frames = [(n, float(breath.get('sec', 0.45))) for n in breath['frames']] + half = len(frames) // 2 + blink = [(breath['blink'], 0.12)] if breath.get('blink') else [] + clips['idle'] = expand(frames[:half] + blink + frames[half:]) + elif c.get('idle'): + clips['idle'] = expand(c['idle']) + for action in list(clips): + if action != 'idle' and ':' not in action: + for suffix, prefix in (('in', 'to'), ('out', 'from')): + name = f'{prefix}-{action}' + if frame_path(c['frames_dir'], name): + clips.setdefault(f'{action}:{suffix}', expand([[name, c['transition_sec']]])) + if any(not sequence for sequence in clips.values()): + raise ValueError('every clip must contain at least one frame') + return clips + + +def main(cfg_path): + c = load_config(cfg_path) + frames_dir, plugin_dir = c['frames_dir'], c['plugin_dir'] + if not os.path.isdir(frames_dir): + raise ValueError(f'frames directory does not exist: {frames_dir}') + if os.path.exists(plugin_dir) and os.path.samefile(frames_dir, plugin_dir): + raise ValueError('frames_dir and plugin_dir must differ') + + def path_of(name): + return frame_path(frames_dir, name) + + def read(name): + # Soft effects and green artwork are data, not a guessed background. + return np.asarray(Image.open(path_of(name)).convert('RGBA')).copy() + + # ---- clips ---- + clips = resolve_clips(c) + unknown = set(c.get('deck', [])) - set(clips) + if unknown: + raise ValueError('deck refers to unknown actions: ' + ', '.join(sorted(unknown))) + + names, missing = [], [] + for seq in clips.values(): + for n, _ in seq: + if path_of(n): + if n not in names: + names.append(n) + elif n not in missing: + missing.append(n) + if missing: + raise ValueError('missing frames: ' + ', '.join(missing)) + if not names: + raise ValueError('no frames found in ' + frames_dir) + + # ---- floor line from the base frame ---- + base = c.get('floor_frame') or names[0] + if not path_of(base): + raise ValueError(f'floor_frame is missing: {base}') + base_pixels = read(base) + if not np.any(base_pixels[..., 3]): + raise ValueError(f'floor_frame is empty: {base}') + floor = c.get('floor_y', int(np.where(base_pixels[..., 3] > 0)[0].max())) + if not isinstance(floor, int) or not 0 <= floor < base_pixels.shape[0]: + raise ValueError('floor_y must be a row inside the source canvas') + if c['ground_anchors'] and 'floor_y' not in c: + raise ValueError('explicit ground_anchors require an explicit floor_y') + unknown_anchors = set(c['ground_anchors']) - set(names) + if unknown_anchors: + raise ValueError('ground_anchors refer to unused frames: ' + ', '.join(sorted(unknown_anchors))) + + raw, placements = {}, {} + base_shape = base_pixels.shape + for n in names: + a = read(n) + if a.shape != base_shape: + raise ValueError(f'{n} has size {a.shape[1]}x{a.shape[0]}; expected {base_shape[1]}x{base_shape[0]}') + if not np.any(a[..., 3]): + raise ValueError(f'{n} is empty') + if np.all(a[..., 3] > 0): + raise ValueError(f'{n} has no fully transparent background pixels') + shift = 0 + if n in c['ground_anchors'] and n not in c['airborne']: + anchor = c['ground_anchors'][n] + if not isinstance(anchor, int) or not 0 <= anchor < len(a): + raise ValueError(f'invalid ground anchor for {n}') + shift = floor - anchor + visible_y = np.where(a[..., 3] > 0)[0] + if visible_y.min() + shift < 0 or visible_y.max() + shift >= len(a): + raise ValueError(f'ground translation clips visible art: {n}') + moved = np.zeros_like(a) + if shift >= 0: + moved[shift:] = a[:len(a) - shift] + else: + moved[:shift] = a[-shift:] + a = moved + placements[n] = {'source': path_of(n).name, 'anchor': c['ground_anchors'].get(n), + 'shift_y': shift, 'airborne': n in c['airborne']} + raw[n] = np.vstack([a, np.zeros((c['pad_bottom'], a.shape[1], 4), np.uint8)]) + + xs = np.concatenate([np.where(f[..., 3].any(0))[0] for f in raw.values()]) + width = next(iter(raw.values())).shape[1] + x0 = max(0, xs.min() - c['margin_x']); x1 = min(width, xs.max() + 1 + c['margin_x']) + s = c['scale'] + + def with_shadow(a): + img = Image.fromarray(a) + if not c['shadow']: + return img + ys, cols = np.where(a[..., 3] > 0) + lift = max(0, floor - ys.max()) # how high the feet are above the floor + feet = cols[ys > ys.max() - 60] + cx = (feet.min() + feet.max()) / 2 + w = (feet.max() - feet.min()) * 1.15 * max(0.45, 1 - lift / 500) + alpha = int(110 * max(0.3, 1 - lift / 400)) + layer = Image.new('RGBA', img.size, (0, 0, 0, 0)) + ImageDraw.Draw(layer).ellipse([cx - w / 2, floor - w * 0.07, cx + w / 2, floor + w * 0.07], fill=(0, 0, 0, alpha)) + layer = layer.filter(ImageFilter.GaussianBlur(8)) + layer.alpha_composite(img) + return layer + + cells = {} + for n, a in raw.items(): + img = with_shadow(a[:, x0:x1]) + w, h = img.size + cells[n] = img if s == 1 else img.convert('RGBa').resize( + (max(1, w // s), max(1, h // s)), Image.LANCZOS + ).convert('RGBA') + + cw, ch = next(iter(cells.values())).size + # Bound decoded surfaces as well as compressed file sizes. + max_side = 4096 + if max(cw, ch) > max_side: + raise ValueError('a sprite cell exceeds 4096 px; reduce padding or increase scale') + cols = min(c['cols'], len(names), max_side // cw) + rows_per_sheet = min(c.get('rows_per_sheet', 5), max_side // ch) + per_sheet = cols * rows_per_sheet + os.makedirs(plugin_dir, exist_ok=True) + for old in os.listdir(plugin_dir): + if old.startswith('sprite') and old.endswith('.webp'): + os.remove(os.path.join(plugin_dir, old)) + sheets = [] + for start in range(0, len(names), per_sheet): + chunk = names[start:start + per_sheet] + rows = (len(chunk) + cols - 1) // cols + sheet = Image.new('RGBA', (cw * cols, ch * rows), (0, 0, 0, 0)) + for i, n in enumerate(chunk): + sheet.paste(cells[n], ((i % cols) * cw, (i // cols) * ch)) + file = f'sprite-{len(sheets)}.webp' + sheet.save(os.path.join(plugin_dir, file), lossless=True, method=6) + if os.path.getsize(os.path.join(plugin_dir, file)) > 8_000_000: + raise ValueError(f'{file} exceeds CanvasTTY\'s 8 MB asset limit; lower rows_per_sheet or increase scale') + sheets.append({'file': file, 'rows': rows}) + + index = {n: i for i, n in enumerate(names)} + table = { + 'cols': cols, 'perSheet': per_sheet, 'sheets': sheets, 'cell': [cw, ch], + 'clips': {k: [[index[n], d] for n, d in v if n in index] for k, v in clips.items()}, + 'deck': c.get('deck', [k for k in c['clips'] if k != 'idle']), + 'labels': c.get('labels', {}), 'loops': c.get('loops', {}), 'follow': c.get('follow', {}), + 'frameNames': names, + } + sources = {n: {'source': path_of(n).name, 'sha256': digest(path_of(n))} + for n in dict.fromkeys([*names, base])} + config_hash = digest(cfg_path) + table['clips'] = {k: v for k, v in table['clips'].items() if v} + manifest_path = Path(plugin_dir) / 'canvastty.plugin.json' + if manifest_path.exists(): + manifest = json.loads(manifest_path.read_text(encoding='utf-8')) + manifest['name'] = c['name'] + manifest['description'] = c.get('description', f"Animated {c['name']} mascot for CanvasTTY") + for contribution in manifest.get('contributions', []): + if contribution.get('kind') == 'canvas-app': + contribution['title'] = c['name'] + contribution['description'] = manifest['description'] + height = contribution['defaultSize']['height'] + contribution['defaultSize']['width'] = max(128, min(1600, round(height * cw / ch))) + contribution['minSize']['width'] = 128 + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') + assets = {p.relative_to(plugin_dir).as_posix(): digest(p) + for p in sorted(Path(plugin_dir).rglob('*')) if p.is_file() and p.name != 'frames.js'} + table['buildId'] = hashlib.sha256((config_hash + json.dumps(sources, sort_keys=True) + + json.dumps(assets, sort_keys=True)).encode()).hexdigest()[:16] + with open(os.path.join(plugin_dir, 'frames.js'), 'w', encoding='utf-8') as f: + f.write('window.SPRITE = ' + json.dumps(table, ensure_ascii=False) + ';\n') + package_size = sum(path.stat().st_size for path in Path(plugin_dir).rglob('*') if path.is_file()) + if package_size > 25_000_000: + raise ValueError('plugin exceeds CanvasTTY\'s 25 MB package limit; reduce frames or image size') + + report = { + 'build_id': table['buildId'], 'config_sha256': config_hash, 'frames': sources, + 'placement': {'source_canvas': [base_shape[1], base_shape[0]], 'crop_x': int(x0), + 'crop_width': int(x1 - x0), 'padded_height': base_shape[0] + c['pad_bottom'], + 'scale': s, 'cell': [cw, ch], + 'floor_y': floor, 'frames': placements}, + 'files': {p.relative_to(plugin_dir).as_posix(): digest(p) + for p in sorted(Path(plugin_dir).rglob('*')) if p.is_file()}, + 'clips': {k: {'unique_drawings': len({n for n, _ in v}), 'playback_steps': len(v), + 'seconds': round(sum(d for _, d in v), 6)} for k, v in clips.items()}, + } + (Path(cfg_path).resolve().parent / 'build-report.json').write_text( + json.dumps(report, indent=2, ensure_ascii=False) + '\n', encoding='utf-8' + ) + + size_mb = sum(os.path.getsize(os.path.join(plugin_dir, s['file'])) for s in sheets) / 1e6 + print(f'frames {len(names)} cell {cw}x{ch} sheets {[(s["file"], s["rows"]) for s in sheets]} {size_mb:.1f} MB floor y={floor}') + print('clips:', {k: len(v) for k, v in table['clips'].items() if ':' not in k}) + print('transitions:', sorted({k.split(':')[0] for k in table['clips'] if ':' in k}) or 'none') + + +if __name__ == '__main__': + if len(sys.argv) != 2: + sys.exit(__doc__) + main(sys.argv[1]) diff --git a/assets/mascot-pipeline/scripts/finalize_mascot.py b/assets/mascot-pipeline/scripts/finalize_mascot.py new file mode 100644 index 000000000..8ee42234b --- /dev/null +++ b/assets/mascot-pipeline/scripts/finalize_mascot.py @@ -0,0 +1,231 @@ +"""Validate a built mascot plugin and write the CanvasTTY handoff result. + +Usage: python scripts/finalize_mascot.py path/to/character.json [--install-approved] +Default: record reviewed only. --install-approved requests host installation. +--verify-only validates the same evidence without rewriting the result. +""" + +import argparse +import json +import hashlib +from pathlib import Path + +from qa_frames import frame_path, referenced_names +from build_sprite import load_config, resolve_clips + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def verify_build(config_path): + """Check the current sources and package before preview or handoff.""" + config = load_config(config_path) + project = Path(config_path).resolve().parent + report_path = project / 'build-report.json' + if not report_path.is_file(): + raise ValueError('build report is missing; rebuild the plugin') + report = json.loads(report_path.read_text(encoding='utf-8')) + if report.get('config_sha256') != digest(Path(config_path)): + raise ValueError('build is stale: configuration changed') + records = report.get('frames', {}) + if set(records) != set(referenced_names(config)): + raise ValueError('build does not cover current clips') + for name, record in records.items(): + source = frame_path(config['frames_dir'], name) + if source is None or source.name != record.get('source') or digest(source) != record.get('sha256'): + raise ValueError(f'build is stale: source changed: {name}') + plugin = Path(config['plugin_dir']) + actual = {p.relative_to(plugin).as_posix(): digest(p) for p in plugin.rglob('*') if p.is_file()} + if not actual or actual != report.get('files'): + raise ValueError('build is stale: plugin files changed') + return report + + +def verify_review(config_path, build, draft=False): + config = load_config(config_path) + review_path = config_path.parent / ('draft-review.json' if draft else 'review.json') + if not review_path.is_file(): + raise ValueError(f'{review_path.name} is missing; review the current build with the user') + review = json.loads(review_path.read_text(encoding='utf-8')) + if review.get('build_id') != build['build_id'] or review.get('draft_approved' if draft else 'user_approved') is not True: + raise ValueError('current build has not been approved by the user') + if draft and review.get('user_approved') is not False: + raise ValueError('draft acceptance must not claim final visual approval') + plan_path = config_path.parent / 'character-full-plan.json' + deferred = [] + if draft or plan_path.is_file(): + if not plan_path.is_file() or review.get('full_plan_sha256') != digest(plan_path): + raise ValueError('preserved full plan is missing or its review hash is stale') + plan = json.loads(plan_path.read_text(encoding='utf-8')) + for action in plan.get('deck', []): + phases = plan.get('required_phases', {}).get(action) + if not isinstance(phases, list) or not phases: + raise ValueError(f'full plan has no required phases: {action}') + actual = {p['id'] for p in config.get('required_phases', {}).get(action, [])} if action in config['deck'] else set() + deferred.extend(f'{action}/{p["id"]}' for p in phases if p['id'] not in actual) + if not plan.get('deck'): + raise ValueError('full plan has no requested actions') + if not draft and deferred: + raise ValueError('final delivery is missing full-plan phases: ' + ', '.join(deferred)) + if draft and review.get('deferred_phases') != deferred: + raise ValueError('draft must list every deferred full-plan phase in plan order') + unresolved = review.get('unresolved') + if not isinstance(unresolved, list) or any(not isinstance(item, str) or not item.strip() for item in unresolved): + raise ValueError('unresolved must explicitly list known concerns') + if draft: + limits = review.get('limitations') + if not isinstance(limits, list) or not limits or any(not isinstance(item, str) or not item.strip() for item in limits): + raise ValueError('draft needs explicit user-accepted limitations') + if not set(unresolved + deferred) <= set(limits): + raise ValueError('draft limitations must retain all unresolved concerns and deferred phases') + for check in ('source', 'scale', 'neighbors', 'materials_contact', 'effects', 'main_loop', 'boundaries', 'browser'): + note = review.get('checks', {}).get(check) + if not isinstance(note, str) or not note.strip(): + raise ValueError(f'missing observed review: {check}') + observation = review.get('observations', {}).get(check, {}) + if not isinstance(observation.get('evidence'), str) or not observation['evidence'].strip(): + raise ValueError(f'missing observation provenance: {check}') + mode = observation.get('mode') + if mode == 'observed_current_build': + if observation.get('build_id') != build['build_id']: + raise ValueError(f'observation belongs to another build: {check}') + elif mode == 'reused_unchanged_pixels' and check in ('source', 'materials_contact', 'effects'): + hashes = observation.get('source_sha256') + if (not isinstance(hashes, dict) or not hashes or not observation.get('previous_build_id') + or any(build['frames'].get(name, {}).get('sha256') != value for name, value in hashes.items())): + raise ValueError(f'reused pixel evidence is stale: {check}') + elif mode == 'unverified' and draft and check in unresolved: + continue + else: + raise ValueError(f'fresh current-build observation is required: {check}') + clips = resolve_clips(config) + requirements = config.get('required_phases', {}) + for action in config['deck']: + phases = requirements.get(action) + if not isinstance(phases, list) or not phases: + raise ValueError(f'required action phases are missing: {action}') + seen = set() + for phase in phases: + name = phase.get('id') + clip = phase.get('clip', action) + frames = phase.get('frames') + if not isinstance(name, str) or not name.strip() or name in seen: + raise ValueError(f'invalid or duplicate phase: {action}') + seen.add(name) + if clip not in (action, f'{action}:in', f'{action}:out') or not frames: + raise ValueError(f'phase has no valid clip/frames: {action}/{name}') + if not set(frames) <= {f for f, _ in clips.get(clip, [])}: + raise ValueError(f'phase frames are absent from its clip: {action}/{name}') + note = review.get('phases', {}).get(action, {}).get(name) + if not isinstance(note, str) or not note.strip(): + raise ValueError(f'phase was not visually reviewed: {action}/{name}') + if not draft and unresolved != []: + raise ValueError('review must explicitly report no unresolved concerns before approval') + for clip, sequence in clips.items(): + coverage = review.get('coverage', {}).get(clip, {}) + steps = coverage.get('steps') + if (not isinstance(steps, list) or any(type(step) is not int for step in steps) + or sorted(steps) != list(range(len(sequence))) + or not isinstance(coverage.get('note'), str) or not coverage['note'].strip()): + raise ValueError(f'every built step must be inspected and recorded: {clip}') + for action in config['deck']: + playback = review.get('playback', {}).get(action, {}) + if draft and 'main_loop' in unresolved and playback.get('mode') == 'unverified': + if not isinstance(playback.get('note'), str) or not playback['note'].strip(): + raise ValueError(f'unverified draft playback needs an explicit limitation: {action}') + continue + if (type(playback.get('main_loops')) is not int or playback['main_loops'] < 2 + or not isinstance(playback.get('note'), str) or not playback['note'].strip() + or any(playback.get(phase) is not True for phase in ('entrance', 'exit') + if f'{action}:{"in" if phase == "entrance" else "out"}' in clips)): + raise ValueError(f'full entrance/exit and two live main loops must be observed: {action}') + manifest_path = config_path.parent / 'continuity.json' + continuity_path = config_path.parent / 'continuity-report.json' + if not manifest_path.is_file() or not continuity_path.is_file(): + raise ValueError('source-to-atlas continuity evidence is missing') + continuity = json.loads(continuity_path.read_text(encoding='utf-8')) + manifest_hash = digest(manifest_path) + if (continuity.get('build_id') != build['build_id'] + or continuity.get('manifest_sha256') != manifest_hash + or review.get('continuity_manifest_sha256') != manifest_hash + or review.get('continuity_report_sha256') != digest(continuity_path) + or set(continuity.get('frames', {})) != {name for seq in clips.values() for name, _ in seq}): + raise ValueError('continuity evidence/review is stale or incomplete') + for flag in continuity.get('flags', []): + note = review.get('continuity_decisions', {}).get(flag['id']) + if draft and flag['id'] in unresolved: + continue + if not isinstance(note, str) or not note.strip(): + raise ValueError(f'continuity flag needs an observed explanation or repair: {flag["id"]}') + return review + + +def main(config_file, install_approved=False, verify_only=False, draft=False): + config_path = Path(config_file).expanduser().resolve(strict=True) + config = json.loads(config_path.read_text(encoding="utf-8")) + plugin = (config_path.parent / config["plugin_dir"]).resolve() + required = ("canvastty.plugin.json", "index.html", "player.js", "frames.js") + missing = [name for name in required if not (plugin / name).is_file()] + sprites = sorted(plugin.glob("sprite-*.webp")) if plugin.is_dir() else [] + if missing or not sprites: + raise ValueError(f"incomplete plugin: missing {missing or ['sprite-*.webp']}") + manifest = json.loads((plugin / "canvastty.plugin.json").read_text(encoding="utf-8")) + if manifest.get("id") != config["id"]: + raise ValueError("plugin ID differs from character.json") + actions = [name for name in config.get("deck", []) if name in config.get("clips", {})] + if not actions: + raise ValueError("no completed selectable actions are configured") + frames = (config_path.parent / config["frames_dir"]).resolve() + report_path = frames / "qa-report.json" + if not report_path.is_file(): + raise ValueError("frame QA is missing") + report = json.loads(report_path.read_text(encoding="utf-8")) + if report.get("config_sha256") != digest(config_path) or report.get("missing"): + raise ValueError("frame QA is stale; rerun it and review the frames") + if not draft and report.get("visual_review", {}).get("status") != "approved": + raise ValueError("full-size visual review is pending") + records = report.get("frames") or [] + if not records or any(record.get("issues") for record in records): + raise ValueError("frame QA has unresolved issues") + if {record.get("name") for record in records} != set(referenced_names(load_config(config_path))): + raise ValueError("frame QA does not cover the current clips") + for record in records: + source = frame_path(frames, record["name"]) + if source is None or source.name != record.get("source") or digest(source) != record.get("sha256"): + raise ValueError(f"frame changed after review: {record['name']}") + build = verify_build(config_path) + review = verify_review(config_path, build, draft) + if install_approved and review.get('installation_approved') is not True: + raise ValueError('installation needs the user\'s explicit approval for this build') + if verify_only: + print(f'Verified build: {build["build_id"]}') + return + result = { + "schemaVersion": 1, + "status": "ready_for_host" if install_approved else "reviewed", + "buildId": build['build_id'], + "installationApproved": install_approved, + "delivery": "draft" if draft else "final", + "limitations": review['limitations'] if draft else [], + "id": config["id"], + "name": config["name"], + "pluginDirectory": str(plugin), + "actions": list(dict.fromkeys(actions)), + "presentation": {"transparentCard": True, "resizable": True}, + } + target = config_path.parent / "mascot-result.json" + temporary = target.with_suffix(".json.tmp") + temporary.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + temporary.replace(target) + print(f"handoff result: {target}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('config') + parser.add_argument('--install-approved', action='store_true') + parser.add_argument('--verify-only', action='store_true') + parser.add_argument('--draft', action='store_true', help='Accept an explicitly reviewed draft without claiming final approval') + args = parser.parse_args() + main(args.config, args.install_approved, args.verify_only, args.draft) diff --git a/assets/mascot-pipeline/scripts/prepare_input.py b/assets/mascot-pipeline/scripts/prepare_input.py new file mode 100644 index 000000000..cf96c37d1 --- /dev/null +++ b/assets/mascot-pipeline/scripts/prepare_input.py @@ -0,0 +1,56 @@ +"""Validate an input PNG/JPEG and create a fresh mascot project workspace. + +Usage: python scripts/prepare_input.py --image IMAGE.png|IMAGE.jpg --project PROJECT_DIR +The destination must not contain files; existing work is never overwritten. +""" + +import argparse +import shutil +from pathlib import Path + +from PIL import Image, ImageOps + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--image", type=Path, required=True) + parser.add_argument("--project", type=Path, required=True) + args = parser.parse_args() + + source = args.image.expanduser().resolve(strict=True) + project = args.project.expanduser().resolve() + if project.exists() and any(project.iterdir()): + raise ValueError(f"project directory is not empty: {project}") + if source.stat().st_size > 20 * 1024 * 1024: + raise ValueError("choose a PNG or JPEG up to 20 MB") + with Image.open(source) as image: + image_format = image.format + if image_format not in ("PNG", "JPEG"): + raise ValueError("the reference must be a PNG or JPEG file") + prepared = ImageOps.exif_transpose(image).convert("RGBA") if image_format == "JPEG" else image.convert("RGBA") + alpha = prepared.getchannel("A") + minimum, maximum = alpha.getextrema() + if maximum == 0: + raise ValueError("the image has no visible character pixels") + dimensions = prepared.size + if not all(32 <= dimension <= 4096 for dimension in dimensions): + raise ValueError("image dimensions must be between 32 and 4096 pixels") + + references = project / "references" + frames = project / "frames" + references.mkdir(parents=True, exist_ok=True) + frames.mkdir(parents=True, exist_ok=True) + target = references / "character.png" + original = references / ("character.jpg" if image_format == "JPEG" else "character.png") + shutil.copy2(source, original) + if image_format == "JPEG": + prepared.save(target, format="PNG") + print(f"original: {original}") + print(f"reference: {target}") + print(f"dimensions: {dimensions[0]}x{dimensions[1]}") + print(f"background removal required: {minimum == 255}") + print(f"frames: {frames}") + + +if __name__ == "__main__": + main() diff --git a/assets/mascot-pipeline/scripts/preview_animation.py b/assets/mascot-pipeline/scripts/preview_animation.py new file mode 100644 index 000000000..4d30b6877 --- /dev/null +++ b/assets/mascot-pipeline/scripts/preview_animation.py @@ -0,0 +1,89 @@ +"""Create a contact sheet and timed demonstration from the current built atlases. + +Usage: python preview_animation.py path/to/character.json --action wave +The demonstration includes idle, entry, repeated main cycles and exit. Looping the +demonstration repeats its entrance; the live player's selected mode does not. +""" + +import argparse +import json +import textwrap +from pathlib import Path + +from PIL import Image, ImageDraw + +from build_sprite import load_config +from finalize_mascot import verify_build + + +def main(config_path, action, cycles=2, height=520): + config_path = Path(config_path).resolve() + config = load_config(config_path) + report = verify_build(config_path) + plugin = Path(config['plugin_dir']) + text = (plugin / 'frames.js').read_text(encoding='utf-8').strip() + sprite = json.loads(text.removeprefix('window.SPRITE = ').removesuffix(';')) + if action not in sprite['deck'] or action not in sprite['clips']: + raise ValueError('action must be an existing selectable action') + if cycles < 1 or height < 32: + raise ValueError('cycles must be positive and preview height at least 32') + clips = sprite['clips'] + phases = [('idle', clips.get('idle', [])), ('entry', clips.get(f'{action}:in', []))] + phases.extend((f'loop {i + 1}', clips[action]) for i in range(cycles)) + phases.extend([('exit', clips.get(f'{action}:out', [])), ('idle', clips.get('idle', []))]) + cw, ch = sprite['cell'] + display_size = (max(1, round(cw * height / ch)), height) + atlases, frames, thumbnails, durations = {}, [], [], [] + for phase, sequence in phases: + for index, seconds in sequence: + sheet, local = divmod(index, sprite['perSheet']) + if sheet not in atlases: + atlases[sheet] = Image.open(plugin / sprite['sheets'][sheet]['file']).convert('RGBA') + x, y = local % sprite['cols'] * cw, local // sprite['cols'] * ch + frame = atlases[sheet].crop((x, y, x + cw, y + ch)) + frame = frame.convert('RGBa').resize(display_size, Image.Resampling.LANCZOS).convert('RGBA') + frames.append(frame) + durations.append(max(1, round(seconds * 1000))) + if not phase.startswith('loop ') or phase == 'loop 1': + tile = Image.new('RGBA', frame.size, (60, 55, 72, 255)) + tile.alpha_composite(frame) + tile = tile.convert('RGB') + tile.thumbnail((160, 220)) + labeled = Image.new('RGB', (tile.width, tile.height + 66), (30, 28, 36)) + labeled.paste(tile, (0, 66)) + name = sprite.get('frameNames', [str(i) for i in range(len(report['frames']))])[index] + label = f'{phase} / cell {index} / {seconds:.2f}s\n' + '\n'.join(textwrap.wrap(name, 23)) + ImageDraw.Draw(labeled).text((3, 3), label, fill='white') + thumbnails.append(labeled) + destination = config_path.parent / 'previews' + destination.mkdir(exist_ok=True) + animated = destination / f'{action}.webp' + frames[0].save(animated, save_all=True, append_images=frames[1:], + duration=durations, loop=0, lossless=True, method=4) + columns = min(6, len(thumbnails)) + cell_w = max(tile.width for tile in thumbnails) + cell_h = max(tile.height for tile in thumbnails) + sheet = Image.new('RGB', (columns * cell_w, 36 + ((len(thumbnails) + columns - 1) // columns) * cell_h), (30, 28, 36)) + ImageDraw.Draw(sheet).text((3, 3), f'Build {report["build_id"]} / {action}\nOrdered atlas cells at one shared scale', fill='white') + for index, tile in enumerate(thumbnails): + sheet.paste(tile, (index % columns * cell_w, 36 + index // columns * cell_h)) + contact = destination / f'{action}-sheet.png' + sheet.save(contact) + print(f'Build {report["build_id"]}; action {action}; {len(frames)} demo steps; {sum(durations) / 1000:.3f}s') + scene = [step for key in (f'{action}:in', action, f'{action}:out') for step in clips.get(key, [])] + print(f'Whole-scene unique drawings: {len({step[0] for step in scene})}') + for key in (f'{action}:in', action, f'{action}:out'): + print(f'{key}: {report["clips"].get(key, {"unique_drawings": 0, "playback_steps": 0, "seconds": 0})}') + print(f'Animated preview: {animated}') + print(f'Contact sheet: {contact}') + print('This finite demo repeats entry and exit when its file loops. Verify live menu switching separately.') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('config') + parser.add_argument('--action', required=True) + parser.add_argument('--cycles', type=int, default=2) + parser.add_argument('--height', type=int, default=520) + args = parser.parse_args() + main(args.config, args.action, args.cycles, args.height) diff --git a/assets/mascot-pipeline/scripts/qa_frames.py b/assets/mascot-pipeline/scripts/qa_frames.py new file mode 100644 index 000000000..20834b357 --- /dev/null +++ b/assets/mascot-pipeline/scripts/qa_frames.py @@ -0,0 +1,165 @@ +"""Validate referenced mascot frames and create a contact sheet for visual review. + +Usage: python qa_frames.py path/to/character.json + python qa_frames.py path/to/character.json --approve "review notes" +Exit code 1 means a required frame is missing or a mechanical check failed. +Approval requires a separate full-size visual review; metrics cannot judge art quality. +""" + +import argparse +import hashlib +import json +from pathlib import Path + +import numpy as np +from PIL import Image, ImageDraw + +from build_sprite import frame_path, load_config, resolve_clips + + +def digest(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def load(path): + pixels = np.asarray(Image.open(path).convert('RGBA')).copy() + background = 'alpha' if np.any(pixels[..., 3] == 0) else 'OPAQUE' + return pixels, background + + +def referenced_names(config): + names = [] + for sequence in resolve_clips(config).values(): + names.extend(name for name, _ in sequence) + names.append(config.get('floor_frame') or names[0]) + return list(dict.fromkeys(names)) + + +def main(config_path): + config_path = Path(config_path).resolve() + config = load_config(config_path) + directory = Path(config['frames_dir']) + names = referenced_names(config) + missing = [name for name in names if frame_path(directory, name) is None] + if missing: + if directory.is_dir(): + (directory / 'qa-report.json').write_text( + json.dumps({'missing': missing, 'frames': [], 'visual_review_required': True}, indent=2) + '\n', + encoding='utf-8', + ) + raise ValueError('missing frames: ' + ', '.join(missing)) + + floor_name = config.get('floor_frame') or names[0] + reference, _ = load(frame_path(directory, floor_name)) + reference_y = np.where(reference[..., 3] > 0)[0] + if not reference_y.size: + raise ValueError(f'floor_frame is empty: {floor_name}') + floor = int(reference_y.max()) + reference_ys, reference_xs = np.where(reference[..., 3] > 0) + reference_width = int(reference_xs.max() - reference_xs.min() + 1) + reference_height = int(reference_ys.max() - reference_ys.min() + 1) + airborne = set(config.get('airborne', [])) + thumbnails = [] + failures = [] + records = [] + + for name in names: + source = frame_path(directory, name) + pixels, background = load(source) + issues = [] + visual_flags = [] + bounds = None + size_ratio = None + if pixels.shape != reference.shape: + issues.append(f'SIZE {pixels.shape[1]}x{pixels.shape[0]}') + if background == 'OPAQUE': + issues.append('BG') + alpha = pixels[..., 3] + ys, xs = np.where(alpha > 0) + if not ys.size: + issues.append('EMPTY') + else: + height, width = alpha.shape + bounds = [int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1] + size_ratio = { + 'width': round((bounds[2] - bounds[0]) / reference_width, 3), + 'height': round((bounds[3] - bounds[1]) / reference_height, 3), + } + if abs(size_ratio['width'] - 1) > 0.01 or abs(size_ratio['height'] - 1) > 0.01: + visual_flags.append('Compare figure scale with the base at full resolution; poses and props can change bounds.') + if xs.min() <= 1 or xs.max() >= width - 2 or ys.min() <= 1 or ys.max() >= height - 2: + issues.append('EDGE') + offset = floor - int(ys.max()) + if name not in airborne and abs(offset) > 30: + visual_flags.append(f'Visible bottom differs by {offset:+d}px; inspect body supports separately from effects/props. No automatic alignment is applied.') + if issues: + failures.append(f'{name}: {", ".join(issues)}') + records.append({ + 'name': name, 'source': source.name, 'sha256': digest(source), + 'bounds': bounds, 'size_ratio_to_base': size_ratio, + 'visual_flags': visual_flags, 'issues': issues, + }) + print(f'{name}: {background}; {", ".join(issues) if issues else "OK"}') + + image = Image.fromarray(pixels) + backing = Image.new('RGBA', image.size, (60, 55, 72, 255)) + backing.alpha_composite(image) + thumb = backing.convert('RGB').resize((max(1, image.width // 6), max(1, image.height // 6))) + ImageDraw.Draw(thumb).text((4, 4), name, fill=(255, 255, 0)) + thumbnails.append(thumb) + + columns = min(8, len(thumbnails)) + cell_w = max(image.width for image in thumbnails) + cell_h = max(image.height for image in thumbnails) + rows = (len(thumbnails) + columns - 1) // columns + sheet = Image.new('RGB', (columns * cell_w, rows * cell_h), (30, 28, 36)) + for index, image in enumerate(thumbnails): + sheet.paste(image, ((index % columns) * cell_w, (index // columns) * cell_h)) + sheet_path = directory / 'qa-sheet.png' + sheet.save(sheet_path) + report_path = directory / 'qa-report.json' + report_path.write_text( + json.dumps({ + 'missing': [], 'frames': records, 'config_sha256': digest(config_path), + 'visual_review_required': True, 'visual_review': {'status': 'pending'}, + }, indent=2) + '\n', + encoding='utf-8', + ) + print(f'Visual review: {sheet_path}') + print(f'Machine-readable report: {report_path}') + if failures: + raise ValueError('frame checks failed:\n' + '\n'.join(failures)) + print(f'Mechanical checks passed for {len(names)} frames. Inspect all frames at full size, then run --approve.') + + +def approve(config_path, notes): + if len(notes.strip()) < 20: + raise ValueError('record specific full-size visual review notes') + config_path = Path(config_path).resolve() + config = load_config(config_path) + report_path = Path(config['frames_dir']) / 'qa-report.json' + report = json.loads(report_path.read_text(encoding='utf-8')) + if report.get('config_sha256') != digest(config_path) or report.get('missing'): + raise ValueError('configuration changed or required frames are missing; rerun QA') + records = report.get('frames') or [] + if not records or any(record.get('issues') for record in records): + raise ValueError('mechanical QA has not passed') + directory = Path(config['frames_dir']) + for record in records: + source = frame_path(directory, record['name']) + if source is None or source.name != record['source'] or digest(source) != record['sha256']: + raise ValueError(f"frame changed after QA: {record['name']}") + report['visual_review'] = {'status': 'approved', 'notes': notes} + report_path.write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') + print(f'Visual review recorded: {report_path}') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('config') + parser.add_argument('--approve', metavar='NOTES', help='record a completed full-size visual review') + args = parser.parse_args() + if args.approve is not None: + approve(args.config, args.approve) + else: + main(args.config) diff --git a/assets/mascot-pipeline/scripts/reference_frames.py b/assets/mascot-pipeline/scripts/reference_frames.py new file mode 100644 index 000000000..ef4986cf8 --- /dev/null +++ b/assets/mascot-pipeline/scripts/reference_frames.py @@ -0,0 +1,60 @@ +"""Extract original-sized motion examples from the bundled reference atlases. + +Usage: python reference_frames.py --set set-a --action smoke --output PROJECT/motion-reference +The output is motion evidence, never the new mascot's identity reference. +""" + +import argparse +import json +from pathlib import Path + +from PIL import Image, ImageDraw + + +def main(set_name, action, output): + root = Path(__file__).resolve().parent.parent / 'examples' / 'motion-library' + source = root / set_name + if source.resolve().parent != root.resolve(): + raise ValueError('invalid reference set') + data = json.loads((source / 'motion.json').read_text(encoding='utf-8')) + if action not in data['clips']: + raise ValueError(f'unknown action; available: {list(data["clips"])}') + output = Path(output) + if output.exists() and any(output.iterdir()): + raise ValueError('choose an empty output directory to preserve existing references') + output.mkdir(parents=True, exist_ok=True) + cw, ch = data['cell'] + atlases, thumbnails = {}, [] + sequence = data['clips'][action] + for index, seconds in sequence: + sheet, local = divmod(index, data['perSheet']) + if sheet not in atlases: + atlases[sheet] = Image.open(source / data['sheets'][sheet]['file']).convert('RGBA') + x, y = local % data['cols'] * cw, local // data['cols'] * ch + frame = atlases[sheet].crop((x, y, x + cw, y + ch)) + frame.save(output / f'frame-{index:03d}.png') + tile = Image.new('RGBA', frame.size, (65, 62, 72, 255)) + tile.alpha_composite(frame) + tile = tile.convert('RGB') + tile.thumbnail((180, 260)) + ImageDraw.Draw(tile).text((4, 4), f'{index} / {seconds:g}s', fill='white') + thumbnails.append(tile) + cols = min(6, len(thumbnails)) + width, height = max(t.width for t in thumbnails), max(t.height for t in thumbnails) + contact = Image.new('RGB', (cols * width, ((len(thumbnails) + cols - 1) // cols) * height), (30, 28, 36)) + for i, tile in enumerate(thumbnails): + contact.paste(tile, (i % cols * width, i // cols * height)) + contact.save(output / 'sequence.jpg', quality=95) + (output / 'timing.json').write_text(json.dumps(sequence, indent=2) + '\n', encoding='utf-8') + print(f'{set_name}/{action}: {len(set(i for i, _ in sequence))} unique drawings, ' + f'{len(sequence)} steps, {sum(t for _, t in sequence):.3f}s') + print(output / 'sequence.jpg') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--set', required=True) + parser.add_argument('--action', required=True) + parser.add_argument('--output', required=True) + args = parser.parse_args() + main(args.set, args.action, args.output) diff --git a/assets/mascot-pipeline/scripts/review_continuity.py b/assets/mascot-pipeline/scripts/review_continuity.py new file mode 100644 index 000000000..553882d33 --- /dev/null +++ b/assets/mascot-pipeline/scripts/review_continuity.py @@ -0,0 +1,141 @@ +"""Measure declared support regions in source AND atlas; flag neighbor changes. + +Usage: python review_continuity.py PROJECT/character.json +Requires manually inspected continuity.json. Flags require visual decisions; +neither measurements nor absence of flags constitute artistic approval. +""" + +import argparse +import json +import math +from pathlib import Path + +import numpy as np +from PIL import Image + +from build_sprite import load_config, resolve_clips, digest, frame_path +from finalize_mascot import verify_build + + +def support_row(image, region, threshold): + x0, y0, x1, y1 = region + if not (0 <= x0 < x1 <= image.width and 0 <= y0 < y1 <= image.height): + raise ValueError('support region does not fit the inspected image after placement') + alpha = np.asarray(image.getchannel('A'))[y0:y1, x0:x1] + rows = np.where(alpha > threshold)[0] + if not len(rows): + raise ValueError('declared support region contains no alpha above its diagnostic threshold') + return int(rows.max() + y0) + + +def finite(value): + return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) + + +def measure(config_path): + config_path = Path(config_path).resolve() + config = load_config(config_path) + build = verify_build(config_path) + project = config_path.parent + manifest_path = project / 'continuity.json' + manifest = json.loads(manifest_path.read_text(encoding='utf-8')) + limits = manifest.get('limits', {}) + for key in ('support_px', 'landmark_px', 'size_fraction'): + if not finite(limits.get(key)) or limits[key] < 0: + raise ValueError(f'declare project-specific finite nonnegative {key}') + sprite = json.loads((Path(config['plugin_dir']) / 'frames.js').read_text(encoding='utf-8') + .strip().removeprefix('window.SPRITE = ').removesuffix(';')) + placement = build['placement'] + cw, ch = sprite['cell'] + sx, sy = cw / placement['crop_width'], ch / placement['padded_height'] + atlas_cache, records = {}, {} + for name in sprite['frameNames']: + entry = manifest.get('frames', {}).get(name, {}) + if entry.get('source_sha256') != build['frames'][name]['sha256']: + raise ValueError(f'continuity annotation missing or stale: {name}') + for field in ('anatomy', 'contact', 'face'): + if not isinstance(entry.get(field), str) or not entry[field].strip(): + raise ValueError(f'missing inspected {field}: {name}') + points, sizes = entry.get('landmarks', {}), entry.get('sizes', {}) + if not points or not sizes: + raise ValueError(f'declare independent body/prop landmarks and sizes: {name}') + if any(not isinstance(v, list) or len(v) != 2 or not all(finite(n) for n in v) + for v in points.values()): + raise ValueError(f'invalid source-coordinate landmarks: {name}') + if any(not finite(v) or v <= 0 for v in sizes.values()): + raise ValueError(f'invalid measured sizes: {name}') + transform = placement['frames'][name] + source = Image.open(frame_path(config['frames_dir'], name)).convert('RGBA') + index = sprite['frameNames'].index(name) + sheet, local = divmod(index, sprite['perSheet']) + if sheet not in atlas_cache: + atlas_cache[sheet] = Image.open(Path(config['plugin_dir']) / sprite['sheets'][sheet]['file']).convert('RGBA') + x, y = local % sprite['cols'] * cw, local // sprite['cols'] * ch + cell = atlas_cache[sheet].crop((x, y, x + cw, y + ch)) + supports = {} + declared = entry.get('supports') + if not isinstance(declared, list): + raise ValueError(f'declare supports (empty only for an unsupported pose): {name}') + if not declared and not str(entry.get('support_note', '')).strip(): + raise ValueError(f'explain absent support: {name}') + for item in declared: + key, region, threshold = item.get('name'), item.get('region'), item.get('alpha_threshold') + if (not isinstance(key, str) or not key or key in supports or not isinstance(region, list) + or len(region) != 4 or any(not isinstance(v, int) or isinstance(v, bool) for v in region) + or not (0 <= region[0] < region[2] <= source.width and 0 <= region[1] < region[3] <= source.height) + or not isinstance(threshold, int) or isinstance(threshold, bool) or not 0 <= threshold < 255 + or not isinstance(item.get('grounded'), bool)): + raise ValueError(f'invalid visually defined support region: {name}') + built_region = [max(0, math.floor((region[0] - placement['crop_x']) * sx)), + max(0, math.floor((region[1] + transform['shift_y']) * sy)), + min(cw, math.ceil((region[2] - placement['crop_x']) * sx)), + min(ch, math.ceil((region[3] + transform['shift_y']) * sy))] + supports[key] = {'source_y': support_row(source, region, threshold), + 'built_y': support_row(cell, built_region, threshold), + 'grounded': item['grounded'] and not transform['airborne'], + 'region': region, 'alpha_threshold': threshold} + records[name] = {'source': transform['source'], 'shift_y': transform['shift_y'], 'supports': supports, + 'landmarks': points, + 'built_landmarks': {k: [(v[0] - placement['crop_x']) * sx, + (v[1] + transform['shift_y']) * sy] for k, v in points.items()}, + 'sizes': sizes, 'anatomy': entry['anatomy'], 'contact': entry['contact'], 'face': entry['face']} + clips = resolve_clips(config) + pairs = [] + for clip, seq in clips.items(): + pairs.extend((f'{clip}:{i}->{clip}:{i+1}', a[0], b[0]) for i, (a, b) in enumerate(zip(seq, seq[1:]))) + if ':' not in clip: + pairs.append((f'{clip}:seam', seq[-1][0], seq[0][0])) + for action in config.get('deck', []): + ordered = [(k, clips[k]) for k in ('idle', f'{action}:in', action, f'{action}:out', 'idle') if k in clips] + pairs.extend((f'{a[0]}:end->{b[0]}:start', a[1][-1][0], b[1][0][0]) for a, b in zip(ordered, ordered[1:])) + flags = [] + for boundary, left, right in pairs: + a, b = records[left], records[right] + def flag(kind, key, value, limit): + if value > limit: + flags.append({'id': f'{boundary}/{kind}/{key}', 'previous': left, 'current': right, + 'kind': kind, 'landmark': key, 'change': round(value, 4), 'limit': limit}) + for key in a['supports'].keys() & b['supports'].keys(): + aa, bb = a['supports'][key], b['supports'][key] + if aa['grounded'] and bb['grounded']: + # Report in source-pixel equivalents after inspecting the actual downsampled atlas. + flag('support', key, abs(aa['built_y'] - bb['built_y']) / sy, limits['support_px']) + for key in a['built_landmarks'].keys() & b['built_landmarks'].keys(): + aa, bb = a['built_landmarks'][key], b['built_landmarks'][key] + flag('trajectory', key, math.hypot((aa[0]-bb[0])/sx, (aa[1]-bb[1])/sy), limits['landmark_px']) + for key in a['sizes'].keys() & b['sizes'].keys(): + flag('size', key, abs(b['sizes'][key] / a['sizes'][key] - 1), limits['size_fraction']) + report = {'build_id': build['build_id'], 'manifest_sha256': digest(manifest_path), + 'limits': limits, 'placement': placement, 'frames': records, + 'compared_boundaries': [p[0] for p in pairs], 'flags': flags} + target = project / 'continuity-report.json' + target.write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') + print(f'Build {build["build_id"]}: {len(records)} drawings, {len(pairs)} neighbor/boundary checks, {len(flags)} flags') + print('Flags need visual decisions; this is not visual approval.') + return report + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('config') + measure(parser.parse_args().config) diff --git a/assets/mascot-pipeline/scripts/scaffold_plugin.py b/assets/mascot-pipeline/scripts/scaffold_plugin.py new file mode 100644 index 000000000..d03d1fbc9 --- /dev/null +++ b/assets/mascot-pipeline/scripts/scaffold_plugin.py @@ -0,0 +1,50 @@ +"""Create a CanvasTTY mascot plugin from one character.json config. + +Usage: python scaffold_plugin.py path/to/character.json +The destination must be empty; existing plugin files are never overwritten. +""" + +import json +import re +import sys +from pathlib import Path + +TEMPLATE = Path(__file__).resolve().parent.parent / 'templates' / 'plugin' + + +def main(config_path): + config_path = Path(config_path).resolve() + config = json.loads(config_path.read_text(encoding='utf-8')) + mascot_id = config['id'] + if not re.fullmatch(r'[a-z0-9]+(?:-[a-z0-9]+)*', mascot_id) or mascot_id == 'host': + raise ValueError('id must use lowercase letters, digits and single hyphens') + name = config['name'].strip() + if not name: + raise ValueError('name must not be empty') + destination = (config_path.parent / config['plugin_dir']).resolve() + if destination.exists() and any(destination.iterdir()): + raise ValueError(f'plugin directory is not empty: {destination}') + values = { + '__ID__': mascot_id, + '__NAME__': name, + '__DESCRIPTION__': config.get('description', f'Animated {name} mascot for CanvasTTY'), + } + for source in TEMPLATE.rglob('*'): + if not source.is_file(): + continue + target = destination / source.relative_to(TEMPLATE) + target.parent.mkdir(parents=True, exist_ok=True) + content = source.read_text(encoding='utf-8') + for key, value in values.items(): + replacement = json.dumps(value, ensure_ascii=False)[1:-1] if source.suffix == '.json' else value + content = content.replace(key, replacement) + if source.suffix == '.json': + json.loads(content) + target.write_text(content, encoding='utf-8') + print(f'plugin scaffold: {destination}') + + +if __name__ == '__main__': + if len(sys.argv) != 2: + raise SystemExit(__doc__) + main(sys.argv[1]) diff --git a/assets/mascot-pipeline/scripts/serve_preview.py b/assets/mascot-pipeline/scripts/serve_preview.py new file mode 100644 index 000000000..f04097fc2 --- /dev/null +++ b/assets/mascot-pipeline/scripts/serve_preview.py @@ -0,0 +1,182 @@ +"""Serve a live, read-only mascot workbench on loopback. + +Usage: python serve_preview.py PROJECT --port 0 +Prints its URL as one JSON line. Keep the process alive during creation. +""" + +import argparse +import functools +import hashlib +import json +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import quote, unquote, urlsplit + + +PAGE = b'''Mascot workbench + +

Mascot workbench

Waiting for source images...

+

Drafts stay visible for review. Displayed images are not automatically approved.

+

Uploaded and prepared references

+

Generated frames

+

Built animation

+

Deterministic atlas review

+

Paused inspection uses actual atlas cells. Source placement is shown separately. +Support lines use declared diagnostic regions; they do not prove anatomy or scale.

+ + + +

Build an animation to inspect its named steps.

+

+'''
+
+
+class Handler(SimpleHTTPRequestHandler):
+    def end_headers(self):
+        self.send_header('Cache-Control', 'no-store')
+        super().end_headers()
+
+    def reply(self, data, content_type):
+        self.send_response(200)
+        self.send_header('Content-Type', content_type)
+        self.send_header('Content-Length', str(len(data)))
+        self.end_headers()
+        self.wfile.write(data)
+
+    def do_GET(self):
+        root = Path(self.directory).resolve()
+        url = urlsplit(self.path).path
+        if url == '/':
+            return self.reply(PAGE, 'text/html; charset=utf-8')
+        if url == '/state':
+            images = []
+            for group in ('references', 'frames'):
+                for path in sorted((root / group).glob('*')):
+                    if (path.suffix.lower() in ('.png', '.jpg', '.jpeg') and path.is_file()
+                            and path.resolve().is_relative_to(root)):
+                        images.append(dict(group=group, name=path.name,
+                                           url=f'/{group}/{quote(path.name)}', modified=path.stat().st_mtime_ns))
+            sprite = {}
+            try:
+                sprite = json.loads((root / 'plugin' / 'frames.js').read_text(encoding='utf-8')
+                                    .strip().removeprefix('window.SPRITE = ').removesuffix(';'))
+            except (OSError, ValueError):
+                pass
+            data = dict(project=root.name, images=images, build=sprite.get('buildId'),
+                        actions=[dict(id=a, label=sprite.get('labels', {}).get(a, a)) for a in sprite.get('deck', [])])
+            try:
+                build_report = json.loads((root / 'build-report.json').read_text(encoding='utf-8'))
+                if (build_report.get('build_id') == sprite.get('buildId') and sprite.get('frameNames')
+                        and build_report.get('placement')):
+                    continuity = None
+                    try:
+                        candidate = json.loads((root / 'continuity-report.json').read_text(encoding='utf-8'))
+                        manifest_hash = hashlib.sha256((root / 'continuity.json').read_bytes()).hexdigest()
+                        if (candidate.get('build_id') == sprite['buildId']
+                                and candidate.get('manifest_sha256') == manifest_hash):
+                            continuity = candidate
+                    except (OSError, ValueError):
+                        pass
+                    data['review'] = dict(sprite=sprite, placement=build_report['placement'], continuity=continuity)
+            except (OSError, ValueError):
+                pass
+            return self.reply(json.dumps(data).encode(), 'application/json')
+        relative = Path(unquote(url).lstrip('/'))
+        resolved = (root / relative).resolve()
+        if (not resolved.is_relative_to(root) or not relative.parts
+                or relative.parts[0] not in ('references', 'frames', 'previews', 'plugin')
+                or resolved.suffix.lower() not in ('.png', '.webp', '.jpg', '.jpeg', '.html', '.js', '.css')
+                or not resolved.is_file()):
+            return self.send_error(404)
+        return super().do_GET()
+
+    def do_HEAD(self):
+        self.send_error(405)
+
+    def log_message(self, *args):
+        pass
+
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('project', type=Path)
+    parser.add_argument('--port', type=int, default=0)
+    args = parser.parse_args()
+    root = args.project.resolve(strict=True)
+    server = ThreadingHTTPServer(('127.0.0.1', args.port), functools.partial(Handler, directory=str(root)))
+    print(json.dumps({'url': f'http://127.0.0.1:{server.server_port}/', 'project': root.name}), flush=True)
+    try:
+        server.serve_forever()
+    except KeyboardInterrupt:
+        pass
+    finally:
+        server.server_close()
diff --git a/assets/mascot-pipeline/templates/launch-prompt.txt b/assets/mascot-pipeline/templates/launch-prompt.txt
new file mode 100644
index 000000000..cc9ed4dcc
--- /dev/null
+++ b/assets/mascot-pipeline/templates/launch-prompt.txt
@@ -0,0 +1,14 @@
+Create an animated CanvasTTY mascot from the attached character image in [PROJECT_DIR]. Read [PIPELINE_DIR]/SKILL.md in full; it is the complete single-page workflow. Use the supplied image and this project's paths.
+
+This session was launched by the user through the Mascots creation button in YOLO/bypass mode. Connect to the specialized CanvasTTY browser connector now and activate the workbench at [PREVIEW_URL]. Keep this same browser preview visible and current throughout preparation, frame generation, repair and animation review. Check the specialized connector before concluding that browser access is unavailable.
+
+Your first creative question is whether to keep the uploaded appearance or change it before animation. Both options require the source-preparation prompt on the skill page: a clear full-body 2D character on actual transparency, followed by explicit canonical approval. Preserve the original. Accept source PNGs or JPG/JPEG photos with or without a background. JPEG intake preserves the original and provides an EXIF-oriented PNG working copy; conversion alone does not remove the background or replace mandatory image-generation preparation.
+
+Offer suggested actions from the bundled motion library plus a custom action/scene with props and emotions. For each chosen action, inspect matching successful example frames or combine relevant movement components for a new action. Examples guide motion only; the approved new character defines identity, scale, colors and materials. Use accepted neighbors for continuity.
+
+Plan every user-required phase, show key poses, then fill named gaps. Perform the mandatory body and prop scale gate after every new/repaired frame and before sequence approval. Do not remove requested phases after failed generation. Show and report the actual main loop separately from entrance and exit, with unique drawings, steps and seconds. Keep the browser on the current build.
+
+Distinguish drawing placement from physical size and intended pose motion before repairing a jump. Track actual footwear/hip support, body and every prop in source and final atlas; an intermediate pose alone cannot fix a shifted floor. Maintain the skill's continuity.json evidence and run review_continuity.py after final changes. Use the workbench's deterministic named-step review to inspect every entrance/main/exit step and boundary, including short holds. Define facial beats separately from facial identity and trace all limb/prop contact chains. Record exact step coverage, live playback, current continuity decisions and unresolved concerns in review.json. Never promote mechanical QA or sparse screenshots into visual approval.
+
+Use the supplied scripts and the skill's review.json contract. Mechanical QA is not visual or user approval. Finalization defaults to reviewed without installation. Request the user's separate approval for installing/replacing the identified current build before using --install-approved. Report installed behavior only after actual host confirmation.
+Preserve character-full-plan.json before any partial delivery. Use separate draft-review.json and --draft for an explicitly accepted incomplete build, retaining every deferred phase and unresolved limitation. Record actual observation provenance; never transfer old build approval or manufacture positive flag explanations. Use only the finalizer and the registered host installer, never manual plugin copies, registry edits or handwritten handoffs. After rejection, inspect the error stage and installation-error.log; submit a corrected handoff or use the host's explicit Retry installation without resetting records or restarting. Report requested, installed, registered and observed runtime state separately.
diff --git a/assets/mascot-pipeline/templates/plugin/canvastty.plugin.json b/assets/mascot-pipeline/templates/plugin/canvastty.plugin.json
new file mode 100644
index 000000000..ec1908a0b
--- /dev/null
+++ b/assets/mascot-pipeline/templates/plugin/canvastty.plugin.json
@@ -0,0 +1,19 @@
+{
+  "apiVersion": 1,
+  "id": "__ID__",
+  "name": "__NAME__",
+  "version": "1.0.0",
+  "description": "__DESCRIPTION__",
+  "permissions": [],
+  "contributions": [
+    {
+      "id": "character",
+      "kind": "canvas-app",
+      "title": "__NAME__",
+      "description": "__DESCRIPTION__",
+      "entry": "index.html",
+      "defaultSize": { "width": 320, "height": 520 },
+      "minSize": { "width": 128, "height": 140 }
+    }
+  ]
+}
diff --git a/assets/mascot-pipeline/templates/plugin/index.html b/assets/mascot-pipeline/templates/plugin/index.html
new file mode 100644
index 000000000..b57dadd60
--- /dev/null
+++ b/assets/mascot-pipeline/templates/plugin/index.html
@@ -0,0 +1,41 @@
+
+
+
+  
+  
+  
+
+
+  
+  
+ + +
+ + + + diff --git a/assets/mascot-pipeline/templates/plugin/player.js b/assets/mascot-pipeline/templates/plugin/player.js new file mode 100644 index 000000000..4a4a7c322 --- /dev/null +++ b/assets/mascot-pipeline/templates/plugin/player.js @@ -0,0 +1,191 @@ +// CanvasTTY sprite-character player. Data comes from frames.js (window.SPRITE), written by build_sprite.py: +// { cols, perSheet, sheets:[{file, rows}], cell:[w,h], clips:{name:[[frameIndex, seconds], ...]}, +// deck:[...], labels:{...}, loops:{action:times}, follow:{action:[otherAction, probability]} } +// Clips "X:in" / "X:out" (from to-X / from-X frames) are played around action X automatically. +// "all" mode deals every deck action once per round in random order; the gear menu loops a single action. +(() => { + const S = window.SPRITE; + const el = document.querySelector('.sprite'); + if (!S || !el) return; + let dragging = null; + const sendDrag = (phase, event) => parent.postMessage({ + source: 'canvastty-plugin', type: 'mascot-drag', phase, + x: event.screenX, y: event.screenY, pointerId: event.pointerId + }, '*'); + el.addEventListener('pointerdown', (event) => { + if (event.button !== 0) return; + dragging = event.pointerId; + el.setPointerCapture(event.pointerId); + sendDrag('start', event); + }); + el.addEventListener('pointermove', (event) => { + if (dragging === event.pointerId) sendDrag('move', event); + }); + const endDrag = (event) => { + if (dragging !== event.pointerId) return; + dragging = null; + sendDrag('end', event); + }; + el.addEventListener('pointerup', endDrag); + el.addEventListener('pointercancel', endDrag); + el.addEventListener('lostpointercapture', endDrag); + el.style.aspectRatio = `${S.cell[0]} / ${S.cell[1]}`; + // fit inside the window at any size without distortion + el.style.height = `min(100vh, calc(100vw * ${S.cell[1]} / ${S.cell[0]}))`; + // Frames live in several sheets (one huge sheet is not drawn) and are painted on a canvas from images + // decoded up front: swapping a CSS background between sheets showed an empty frame (flicker) while decoding. + // The canvas keeps the cell's own size and gets a 1:1 copy; the browser scales the element once. + // Sizing the bitmap to the window resampled twice (CanvasTTY zoom is invisible here) - blurry. + const [cw, ch] = S.cell; + const canvas = document.createElement('canvas'); + canvas.width = cw; + canvas.height = ch; + canvas.style.cssText = 'width:100%;height:100%;display:block'; + el.appendChild(canvas); + const ctx = canvas.getContext('2d'); + const sheets = S.sheets.map((s) => { const img = new Image(); img.src = s.file; return img; }); + const ready = Promise.all(sheets.map((img) => img.decode().catch(() => {}))); + const show = (i) => { + const s = Math.floor(i / S.perSheet), local = i % S.perSheet, img = sheets[s]; + if (!img.complete || !img.naturalWidth) return; // not decoded yet: keep the previous frame + ctx.clearRect(0, 0, cw, ch); + ctx.drawImage(img, (local % S.cols) * cw, Math.floor(local / S.cols) * ch, cw, ch, 0, 0, cw, ch); + }; + + // Finish the current clip before exiting so a menu click cannot splice incompatible poses. + // The latest selection is read at boundaries; transitions never queue stale selections. + const sleep = (s) => new Promise((resolve) => setTimeout(resolve, s * 1000)); + const has = (name) => (S.clips[name] || []).length > 0; + const chance = (p) => Math.random() < p; + const play = async (name, times = 1) => { + const clip = S.clips[name] || []; + for (let t = 0; t < times; t++) for (const [i, d] of clip) { show(i); await sleep(d); } + }; + // standing -> to-X -> X -> from-X -> standing + const perform = async (name) => { + await play(`${name}:in`); + let cycles = 0; + do { + await play(name); + cycles++; + } while (mode === name || (mode === 'all' && cycles < ((S.loops || {})[name] || 1))); + await play(`${name}:out`); + const follow = (S.follow || {})[name]; + if (mode === 'all' && follow && has(follow[0]) && chance(follow[1])) await perform(follow[0]); + }; + + const shuffle = (list) => { + for (let i = list.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [list[i], list[j]] = [list[j], list[i]]; + } + return list; + }; + const deal = (last) => { + const deck = shuffle((S.deck || []).filter(has)); + for (let i = 1; i < deck.length; i++) { // separate identical neighbours + if (deck[i] === deck[i - 1]) { + const k = deck.findIndex((n, j) => j > i && n !== deck[i]); + if (k > 0) [deck[i], deck[k]] = [deck[k], deck[i]]; + } + } + if (deck[0] === last && deck.length > 1) deck.push(deck.shift()); + return deck; + }; + + // gear menu: "all", "idle" and every deck action that has frames + const previewAction = new URLSearchParams(window.location.search).get('action'); + let mode = (previewAction === 'idle' && has('idle')) || ((S.deck || []).includes(previewAction) && has(previewAction)) + ? previewAction : 'all'; + document.documentElement.dataset.buildId = S.buildId || ''; + const picker = document.getElementById('picker'); + const toggle = document.getElementById('picker-toggle'); + const menu = document.getElementById('picker-menu'); + if (picker && toggle && menu) { + const defaultLabels = { + en: { all: 'All actions', idle: 'Idle' }, + ru: { all: 'Всё подряд', idle: 'Ожидание' } + }; + let locale = navigator.language.toLowerCase().startsWith('ru') ? 'ru' : 'en'; + const names = ['all', ...(has('idle') ? ['idle'] : []), ...new Set((S.deck || []).filter(has))]; + const close = () => { picker.classList.remove('open'); toggle.setAttribute('aria-expanded', 'false'); }; + const options = new Map(); + for (const name of names) { + const option = document.createElement('button'); + option.type = 'button'; + option.className = 'picker-option'; + option.setAttribute('role', 'menuitemradio'); + option.setAttribute('aria-checked', String(name === mode)); + option.addEventListener('click', () => { + mode = name; + for (const [key, item] of options) item.setAttribute('aria-checked', String(key === name)); + close(); + toggle.focus(); + }); + options.set(name, option); + menu.appendChild(option); + } + const renderLabels = () => { + const labels = Object.assign({}, defaultLabels[locale], S.labels || {}); + for (const [name, option] of options) option.textContent = labels[name] || name; + document.documentElement.lang = locale; + toggle.setAttribute('aria-label', locale === 'ru' ? 'Выбрать анимацию' : 'Choose animation'); + menu.setAttribute('aria-label', locale === 'ru' ? 'Анимации' : 'Animations'); + }; + renderLabels(); + window.addEventListener('message', (event) => { + if (event.source !== parent || event.data?.source !== 'canvastty-host' || event.data?.type !== 'context') return; + const hostLocale = event.data.value?.appearance?.locale; + if (hostLocale !== 'ru' && hostLocale !== 'en') return; + locale = hostLocale; + renderLabels(); + }); + toggle.addEventListener('click', () => { + const open = picker.classList.toggle('open'); + toggle.setAttribute('aria-expanded', String(open)); + if (open) menu.querySelector('button')?.focus(); + }); + document.addEventListener('pointerdown', (event) => { if (!picker.contains(event.target)) close(); }); + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && picker.classList.contains('open')) { close(); toggle.focus(); } + }); + menu.addEventListener('keydown', (event) => { + const items = [...options.values()]; + const current = items.indexOf(document.activeElement); + let next = current; + if (event.key === 'ArrowDown') next = (current + 1) % items.length; + else if (event.key === 'ArrowUp') next = (current - 1 + items.length) % items.length; + else if (event.key === 'Home') next = 0; + else if (event.key === 'End') next = items.length - 1; + else return; + event.preventDefault(); + items[next]?.focus(); + }); + } + + const step = async (last) => { + if (mode === 'idle') { await play('idle'); return last; } + if (mode !== 'all') { + const action = mode; + await perform(action); + await play('idle'); + return action; + } + const deck = deal(last); + if (!deck.length) { if (has('idle')) await play('idle'); else await sleep(1); return last; } + for (const action of deck) { + await play('idle', chance(0.6) ? 1 : 2); + if (mode !== 'all') break; + await perform(action); + last = action; + if (mode !== 'all') break; + } + return last; + }; + + (async () => { + await ready; // start only when every sheet is decoded + let last = ''; + for (;;) last = await step(last); + })(); +})(); diff --git a/assets/mascot-pipeline/tests/placement_regression.py b/assets/mascot-pipeline/tests/placement_regression.py new file mode 100644 index 000000000..85d7bd958 --- /dev/null +++ b/assets/mascot-pipeline/tests/placement_regression.py @@ -0,0 +1,31 @@ +"""Distinguish placement drift from unchanged-source translation using real art.""" +import hashlib +import json +from pathlib import Path +import numpy as np +from PIL import Image + +root = Path(__file__).resolve().parent.parent / 'examples' +case = json.loads((root / 'placement-regression/case.json').read_text()) +paths = {'aligned': root / 'motion-library/set-c/atlas-0.webp', + 'unaligned': root / 'placement-regression/unaligned.webp'} +rows = {} +cw, ch = case['cell'] +for kind, path in paths.items(): + assert hashlib.sha256(path.read_bytes()).hexdigest() == case[f'{kind}_sha256'] + atlas = Image.open(path).convert('RGBA') + rows[kind] = [] + for record in case['regions']: + i = record['index'] + x, y = i % case['cols'] * cw, i // case['cols'] * ch + cell = atlas.crop((x, y, x + cw, y + ch)) + left, top, right, bottom = record[f'{kind}_region'] + alpha = np.asarray(cell.getchannel('A'))[top:bottom, left:right] + visible = np.where(alpha > case['alpha_threshold'])[0] + assert len(visible), f'no visible footwear at cell {i}' + rows[kind].append(int(visible.max() + top)) +assert max(rows['aligned']) - min(rows['aligned']) <= case['aligned_max_range_px'] +assert max(rows['unaligned']) - min(rows['unaligned']) >= case['unaligned_min_range_px'] +assert rows['aligned'] != rows['unaligned'] +print(f'Placement regression passed: aligned rows {min(rows["aligned"])}–{max(rows["aligned"])}; ' + f'unaligned rows {min(rows["unaligned"])}–{max(rows["unaligned"])}. Artistic review remains separate.') diff --git a/assets/mascot-pipeline/tests/player-behavior.cjs b/assets/mascot-pipeline/tests/player-behavior.cjs new file mode 100644 index 000000000..f6d5f92f1 --- /dev/null +++ b/assets/mascot-pipeline/tests/player-behavior.cjs @@ -0,0 +1,88 @@ +// Exercise the real player with a deterministic clock; no browser or artwork is required. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const source = fs.readFileSync(path.join(__dirname, '../templates/plugin/player.js'), 'utf8'); +const flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); }; + +async function player(withIdle = true, action = 'a') { + const drawn = [], timers = []; + const element = () => ({ + style: {}, dataset: {}, children: [], listeners: {}, attributes: {}, + classList: { remove() {}, contains() { return false; }, toggle() { return true; } }, + addEventListener(name, fn) { this.listeners[name] = fn; }, + setAttribute(name, value) { this.attributes[name] = value; }, + appendChild(child) { this.children.push(child); }, + focus() {}, contains() { return false; }, querySelector() { return this.children[0]; }, + setPointerCapture() {}, + getContext() { return { clearRect() {}, drawImage(_image, x) { drawn.push(x); } }; } + }); + const sprite = element(), picker = element(), toggle = element(), menu = element(); + const document = { + documentElement: element(), querySelector: () => sprite, + getElementById: (id) => ({ picker, 'picker-toggle': toggle, 'picker-menu': menu }[id]), + createElement: element, addEventListener() {} + }; + const clips = { 'a:in': [[1, .1]], a: [[2, .1], [3, .1]], 'a:out': [[4, .1]], + 'b:in': [[5, .1]], b: [[6, .1]], 'b:out': [[7, .1]] }; + if (withIdle) clips.idle = [[0, .1]]; + const data = { cell: [1, 1], cols: 8, perSheet: 8, sheets: [{ file: 'synthetic.webp' }], + deck: ['a', 'b'], clips, buildId: 'fixture' }; + const window = { SPRITE: data, location: { search: `?action=${action}` }, addEventListener() {} }; + vm.runInNewContext(source, { window, document, navigator: { language: 'en' }, + parent: { postMessage() {} }, URLSearchParams, + Image: class { complete = true; naturalWidth = 8; decode() { return Promise.resolve(); } }, + setTimeout: (fn) => timers.push(fn), Math: Object.assign(Object.create(Math), { random: () => .5 }) }); + await flush(); + return { + drawn, + select(name) { + const option = menu.children.find((item) => item.textContent === name); + assert.ok(option, `missing option ${name}`); + option.listeners.click(); + }, + async tick(count = 1) { + for (let i = 0; i < count; i++) { + assert.ok(timers.length, 'player must schedule its next frame'); + timers.shift()(); + await flush(); + } + } + }; +} + +(async () => { + const selected = await player(); + await selected.tick(6); + assert.deepEqual(selected.drawn, [1, 2, 3, 2, 3, 2, 3], 'selected action must enter once and stay in its loop'); + selected.select('a'); + await selected.tick(2); + assert.equal(selected.drawn.filter((i) => i === 1).length, 1, 'same selection must not restart entry'); + selected.select('b'); + await selected.tick(5); + assert.deepEqual(selected.drawn.slice(-5), [4, 0, 5, 6, 6], 'switch must exit, bridge through idle and loop new action'); + + const rapid = await player(); + rapid.select('b'); // A is still entering. + await rapid.tick(3); + assert.equal(rapid.drawn.at(-1), 4, 'complete a compatible cycle before exiting'); + rapid.select('a'); + rapid.select('Idle'); // Latest request wins during the exit. + await rapid.tick(3); + assert.deepEqual(rapid.drawn.slice(-3), [0, 0, 0]); + assert.ok(!rapid.drawn.includes(5), 'stale intermediate selection must not start'); + + const noIdle = await player(false, 'b'); + await noIdle.tick(3); + assert.deepEqual(noIdle.drawn, [5, 6, 6, 6], 'selected action must work without an idle clip'); + noIdle.select('a'); + await noIdle.tick(3); + assert.deepEqual(noIdle.drawn.slice(-3), [7, 1, 2]); + + const all = await player(true, 'all'); + await all.tick(20); + assert.ok(all.drawn.includes(4) && all.drawn.includes(7), 'All actions must leave actions rather than hold one forever'); + console.log('Player behavior checks passed: held action, same selection, switching, latest request, no idle, all actions.'); +})().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/assets/mascot-pipeline/tests/smoke_pipeline.py b/assets/mascot-pipeline/tests/smoke_pipeline.py new file mode 100644 index 000000000..056bf87b3 --- /dev/null +++ b/assets/mascot-pipeline/tests/smoke_pipeline.py @@ -0,0 +1,329 @@ +"""Exercise missing-frame rejection and a complete portable mascot build.""" + +import json +import hashlib +import subprocess +import sys +import tempfile +from urllib.request import urlopen +from urllib.error import HTTPError +from pathlib import Path + +from PIL import Image, ImageDraw, ImageOps + +ROOT = Path(__file__).resolve().parent.parent + + +def run(script, config, should_pass=True, extra=()): + result = subprocess.run( + [sys.executable, str(ROOT / 'scripts' / script), str(config), *extra], + text=True, + capture_output=True, + check=False, + ) + if (result.returncode == 0) != should_pass: + raise AssertionError(f'{script} returned {result.returncode}\n{result.stdout}\n{result.stderr}') + return result + + +def draw_frame(path, hand_height): + image = Image.new('RGBA', (128, 192), (0, 0, 0, 0)) + pen = ImageDraw.Draw(image) + pen.ellipse((53, 25, 75, 47), fill=(235, 190, 180, 255)) + pen.rectangle((50, 48, 78, 125), fill=(70, 90, 175, 255)) + pen.rectangle((52, 126, 59, 164), fill=(40, 40, 60, 255)) + pen.rectangle((69, 126, 76, 164), fill=(40, 40, 60, 255)) + pen.line((77, 60, 91, hand_height), fill=(235, 190, 180, 255), width=5) + pen.rectangle((25, 70, 38, 83), fill=(80, 120, 210, 80)) + image.save(path) + + +def approve_fixture(project): + build = json.loads((project / 'build-report.json').read_text(encoding='utf-8')) + manifest = {'limits': {'support_px': 2, 'landmark_px': 100, 'size_fraction': .1}, 'frames': { + name: {'source_sha256': record['sha256'], + 'supports': [{'name': 'feet', 'region': [50, 150, 80, 172], 'alpha_threshold': 128, 'grounded': True}], + 'landmarks': {'face': [64, 36], 'hips': [64, 125]}, + 'sizes': {'head_width': 23, 'torso_length': 78}, + 'anatomy': 'Synthetic connected arm and two legs.', 'contact': 'Synthetic feet support.', + 'face': 'Synthetic static fixture; no generated character approval.'} + for name, record in build['frames'].items() if name in build['placement']['frames']}} + (project / 'continuity.json').write_text(json.dumps(manifest), encoding='utf-8') + run('review_continuity.py', project / 'character.json') + continuity = json.loads((project / 'continuity-report.json').read_text(encoding='utf-8')) + review = {'build_id': build['build_id'], 'user_approved': True, 'installation_approved': True, + 'checks': {key: 'Synthetic fixture, no user artwork.' for key in + ('source', 'scale', 'neighbors', 'materials_contact', 'effects', 'main_loop', 'boundaries', 'browser')}, + 'phases': {'wave': {'raise': 'Synthetic arm raised.', 'peak': 'Synthetic peak.'}}, + 'coverage': {clip: {'steps': list(range(record['playback_steps'])), 'note': 'Synthetic test coverage.'} + for clip, record in build['clips'].items()}, + 'playback': {'wave': {'main_loops': 2, 'entrance': True, 'exit': True, 'note': 'Synthetic test evidence.'}}, + 'continuity_manifest_sha256': continuity['manifest_sha256'], + 'continuity_report_sha256': hashlib.sha256((project / 'continuity-report.json').read_bytes()).hexdigest(), + 'continuity_decisions': {}, 'unresolved': []} + review['observations'] = {key: {'mode': 'observed_current_build', 'build_id': build['build_id'], + 'evidence': 'Synthetic test observation; no user artwork.'} for key in review['checks']} + (project / 'review.json').write_text(json.dumps(review), encoding='utf-8') + + +def main(): + with tempfile.TemporaryDirectory() as temporary: + opaque = Path(temporary) / 'opaque.png' + Image.new('RGB', (32, 32), (255, 255, 255)).save(opaque) + rejected = subprocess.run( + [sys.executable, str(ROOT / 'scripts' / 'prepare_input.py'), + '--image', str(opaque), '--project', str(Path(temporary) / 'rejected')], + text=True, capture_output=True, check=False, + ) + assert rejected.returncode == 0, rejected.stderr + assert 'background removal required: True' in rejected.stdout + jpeg = Path(temporary) / 'photo.JPEG' + photo = Image.new('RGB', (32, 48), (35, 75, 125)) + ImageDraw.Draw(photo).rectangle((0, 0, 12, 18), fill=(220, 30, 40)) + exif = Image.Exif() + exif[274] = 6 + photo.save(jpeg, exif=exif) + jpeg_project = Path(temporary) / 'jpeg-project' + normalized = subprocess.run([sys.executable, str(ROOT / 'scripts' / 'prepare_input.py'), + '--image', str(jpeg), '--project', str(jpeg_project)], + text=True, capture_output=True, check=False) + assert normalized.returncode == 0, normalized.stderr + assert (jpeg_project / 'references' / 'character.jpg').read_bytes() == jpeg.read_bytes() + with Image.open(jpeg) as original, Image.open(jpeg_project / 'references' / 'character.png') as working: + expected = ImageOps.exif_transpose(original).convert('RGBA') + assert working.size == (48, 32) + assert working.convert('RGBA').tobytes() == expected.tobytes(), 'JPEG normalization must only orient/encode decoded pixels' + impostor = Path(temporary) / 'not-a-jpeg.jpg' + Image.new('RGB', (32, 32)).save(impostor, format='GIF') + invalid = subprocess.run([sys.executable, str(ROOT / 'scripts' / 'prepare_input.py'), + '--image', str(impostor), '--project', str(Path(temporary) / 'invalid-input')], + text=True, capture_output=True, check=False) + assert invalid.returncode != 0 + assert not (Path(temporary) / 'invalid-input').exists() + intake = Path(temporary) / 'upload.png' + draw_frame(intake, 100) + project = Path(temporary) / 'project' + prepared = subprocess.run( + [sys.executable, str(ROOT / 'scripts' / 'prepare_input.py'), + '--image', str(intake), '--project', str(project)], + text=True, capture_output=True, check=False, + ) + assert prepared.returncode == 0, prepared.stderr + assert (project / 'references' / 'character.png').is_file() + (project / 'references' / 'character.jpg').write_bytes(jpeg.read_bytes()) + config = json.loads((ROOT / 'examples' / 'character.example.json').read_text(encoding='utf-8')) + config['name'] = 'Sample "Mascot"' + config['description'] = 'A mascot with a "quoted" name.' + config['clips']['wave'] = [['wave-01', 0.25], ['wave-02', 0.25], ['wave-03', 0.35]] + config['deck'] = ['wave'] + config['labels']['wave'] = 'Wave' + config['required_phases'] = {'wave': [ + {'id': 'raise', 'clip': 'wave', 'frames': ['wave-01', 'wave-02']}, + {'id': 'peak', 'clip': 'wave', 'frames': ['wave-03']}, + ]} + config_path = project / 'character.json' + config_path.write_text(json.dumps(config), encoding='utf-8') + frames = project / 'frames' + for name, hand in [('idle-01', 100), ('idle-02', 99), ('wave-01', 90), ('wave-02', 75), + ('to-wave', 100), ('from-wave', 100)]: + draw_frame(frames / f'{name}.png', hand) + with Image.open(frames / 'wave-02.png') as original: + effect = original.copy() + ImageDraw.Draw(effect).rectangle((30, 178, 35, 182), fill=(200, 210, 230, 4)) + effect.save(frames / 'wave-02.png') + + missing = run('qa_frames.py', config_path, should_pass=False) + assert 'wave-03' in missing.stderr + assert 'wave-03' in (frames / 'qa-report.json').read_text(encoding='utf-8') + Image.new('RGBA', (100, 192), (255, 0, 0, 255)).save(frames / 'wave-03.png') + invalid = run('qa_frames.py', config_path, should_pass=False) + assert 'SIZE' in invalid.stderr + draw_frame(frames / 'wave-03-fixed.png', 60) + + run('qa_frames.py', config_path) + assert (frames / 'qa-sheet.png').is_file() + report = json.loads((frames / 'qa-report.json').read_text(encoding='utf-8')) + repaired = next(frame for frame in report['frames'] if frame['name'] == 'wave-03') + assert repaired['source'] == 'wave-03-fixed.png' + assert {'to-wave', 'from-wave'} <= {frame['name'] for frame in report['frames']} + run('scaffold_plugin.py', config_path) + run('build_sprite.py', config_path) + run('build_sprite.py', config_path) + pending = run('finalize_mascot.py', config_path, should_pass=False) + assert 'visual review is pending' in pending.stderr + run('qa_frames.py', config_path, extra=('--approve', 'Full-size frame, face, alpha edge, scale and motion review passed.')) + pending = run('finalize_mascot.py', config_path, should_pass=False) + assert 'review.json is missing' in pending.stderr + approve_fixture(project) + review_path = project / 'review.json' + review = json.loads(review_path.read_text()) + del review['phases']['wave']['peak'] + review_path.write_text(json.dumps(review), encoding='utf-8') + incomplete = run('finalize_mascot.py', config_path, should_pass=False) + assert 'phase was not visually reviewed' in incomplete.stderr + approve_fixture(project) + review = json.loads(review_path.read_text()) + review['coverage']['wave']['steps'].pop() + review_path.write_text(json.dumps(review), encoding='utf-8') + incomplete = run('finalize_mascot.py', config_path, should_pass=False) + assert 'every built step' in incomplete.stderr + approve_fixture(project) + continuity_path = project / 'continuity-report.json' + continuity_path.write_text(continuity_path.read_text() + '\n', encoding='utf-8') + stale = run('finalize_mascot.py', config_path, should_pass=False) + assert 'continuity evidence/review is stale' in stale.stderr + approve_fixture(project) + review = json.loads(review_path.read_text()) + review['installation_approved'] = False + review_path.write_text(json.dumps(review), encoding='utf-8') + refused = run('finalize_mascot.py', config_path, should_pass=False, extra=('--install-approved',)) + assert 'explicit approval' in refused.stderr + approve_fixture(project) + run('finalize_mascot.py', config_path) + assert json.loads((project / 'mascot-result.json').read_text())['status'] == 'reviewed' + run('finalize_mascot.py', config_path, extra=('--install-approved',)) + + # Draft consent keeps the full plan and known defects; it cannot become final approval. + full_plan = json.loads(config_path.read_text()) + full_plan['required_phases']['wave'].append({'id': 'squat', 'clip': 'wave', 'frames': ['not-drawn']}) + full_plan_path = project / 'character-full-plan.json' + full_plan_path.write_text(json.dumps(full_plan), encoding='utf-8') + review = json.loads(review_path.read_text()) + review['full_plan_sha256'] = hashlib.sha256(full_plan_path.read_bytes()).hexdigest() + review_path.write_text(json.dumps(review), encoding='utf-8') + assert 'full-plan phases' in run('finalize_mascot.py', config_path, should_pass=False).stderr + draft = dict(review, user_approved=False, draft_approved=True, deferred_phases=['wave/squat'], + unresolved=['Sharp transition.'], limitations=['wave/squat', 'Sharp transition.']) + draft_path = project / 'draft-review.json' + draft_path.write_text(json.dumps(draft), encoding='utf-8') + run('finalize_mascot.py', config_path, extra=('--draft', '--install-approved')) + result = json.loads((project / 'mascot-result.json').read_text()) + assert result['delivery'] == 'draft' and result['limitations'] == draft['limitations'] + run('finalize_mascot.py', config_path, extra=('--draft', '--verify-only', '--install-approved')) + draft['limitations'] = ['wave/squat'] + draft_path.write_text(json.dumps(draft), encoding='utf-8') + assert 'retain all unresolved' in run('finalize_mascot.py', config_path, should_pass=False, extra=('--draft',)).stderr + draft['limitations'].append('Sharp transition.') + draft['observations']['main_loop']['mode'] = 'reused_unchanged_pixels' + draft_path.write_text(json.dumps(draft), encoding='utf-8') + assert 'fresh current-build' in run('finalize_mascot.py', config_path, should_pass=False, extra=('--draft',)).stderr + full_plan_path.unlink() # Remove only this synthetic fixture's expanded draft plan. + approve_fixture(project) + run('finalize_mascot.py', config_path, extra=('--install-approved',)) + + package = project / 'plugin' + manifest = json.loads((package / 'canvastty.plugin.json').read_text(encoding='utf-8')) + assert manifest['id'] == config['id'] + assert manifest['name'] == config['name'] + assert manifest['description'] == config['description'] + sprite = json.loads((package / 'frames.js').read_text(encoding='utf-8').removeprefix('window.SPRITE = ').removesuffix(';\n')) + assert manifest['contributions'][0]['defaultSize']['width'] == max( + 128, round(manifest['contributions'][0]['defaultSize']['height'] * sprite['cell'][0] / sprite['cell'][1]) + ) + assert manifest['contributions'][0]['minSize']['width'] == 128 + assert (package / 'sprite-0.webp').is_file() + alpha = Image.open(package / 'sprite-0.webp').getchannel('A') + assert alpha.histogram()[40:110] != [0] * 70 + assert alpha.histogram()[4] > 0, 'soft alpha must not be thresholded away' + index = sprite['clips']['wave'][1][0] + sheet_index, local = divmod(index, sprite['perSheet']) + with Image.open(package / sprite['sheets'][sheet_index]['file']) as atlas: + x = local % sprite['cols'] * sprite['cell'][0] + y = local // sprite['cols'] * sprite['cell'][1] + assert atlas.getpixel((x + 60, y + 30)) == effect.getpixel((60, 30)), 'effect bounds must not move the body' + assert 'wave' in (package / 'frames.js').read_text(encoding='utf-8') + assert not any(path.name.startswith('idle-') for path in package.iterdir()) + handoff = json.loads((project / 'mascot-result.json').read_text(encoding='utf-8')) + assert handoff['status'] == 'ready_for_host' + assert handoff['actions'] == ['wave'] + assert handoff['presentation'] == {'transparentCard': True, 'resizable': True} + build = json.loads((project / 'build-report.json').read_text(encoding='utf-8')) + assert build['clips']['wave'] == {'unique_drawings': 3, 'playback_steps': 3, 'seconds': .85} + assert build['build_id'] == sprite['buildId'] + run('preview_animation.py', config_path, extra=('--action', 'wave', '--height', '96')) + assert (project / 'previews' / 'wave.webp').is_file() + assert (project / 'previews' / 'wave-sheet.png').is_file() + server = subprocess.Popen([sys.executable, str(ROOT / 'scripts' / 'serve_preview.py'), str(project)], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + try: + url = json.loads(server.stdout.readline())['url'] + with urlopen(url + 'state', timeout=5) as response: + state = json.load(response) + assert state['project'] == project.name + assert state['build'] == build['build_id'] + assert state['actions'][0]['id'] == 'wave' + assert state['review']['sprite']['frameNames'][sprite['clips']['wave'][1][0]] == 'wave-02' + assert state['review']['continuity']['frames']['wave-02']['supports']['feet']['built_y'] == 164 + assert any(item['name'] == 'character.png' for item in state['images']) + assert any(item['name'] == 'character.jpg' for item in state['images']) + for forbidden in ('character.json', '../review.json', 'plugin/'): + try: + urlopen(url + forbidden, timeout=5) + raise AssertionError('private/non-file route was exposed') + except HTTPError as error: + assert error.code == 404 + finally: + server.terminate() + server.wait(timeout=5) + + runtime = package / 'player.js' + runtime.write_text(runtime.read_text(encoding='utf-8') + '\n// Changed fixture\n', encoding='utf-8') + changed_package = run('finalize_mascot.py', config_path, should_pass=False) + assert 'plugin files changed' in changed_package.stderr + run('build_sprite.py', config_path) + rebuilt = json.loads((project / 'build-report.json').read_text()) + assert rebuilt['build_id'] != build['build_id'], 'runtime/asset changes must invalidate old build approval' + + draw_frame(frames / 'wave-02.png', 80) + run('qa_frames.py', config_path) + run('qa_frames.py', config_path, extra=('--approve', 'Synthetic fixture review for changed hand; no user artwork.')) + changed_source = run('finalize_mascot.py', config_path, should_pass=False) + assert 'source changed' in changed_source.stderr + run('build_sprite.py', config_path) + approve_fixture(project) + run('finalize_mascot.py', config_path) + + config['description'] = 'Updated fixture description.' + config['clips']['wave'][0][1] = .3 + config_path.write_text(json.dumps(config), encoding='utf-8') + run('qa_frames.py', config_path) + run('qa_frames.py', config_path, extra=('--approve', 'Synthetic fixture review after timing and metadata update.')) + changed_config = run('finalize_mascot.py', config_path, should_pass=False) + assert 'configuration changed' in changed_config.stderr + run('build_sprite.py', config_path) + approve_fixture(project) + run('finalize_mascot.py', config_path) + assert json.loads((package / 'canvastty.plugin.json').read_text(encoding='utf-8'))['description'] == config['description'] + + config['floor_y'] = 164 + config['cols'] = 100 + config['rows_per_sheet'] = 100 + config['ground_anchors'] = {'wave-02': 169} + config_path.write_text(json.dumps(config), encoding='utf-8') + run('build_sprite.py', config_path) + approve_fixture(project) + continuity = json.loads((project / 'continuity-report.json').read_text()) + assert continuity['frames']['wave-02']['supports']['feet']['built_y'] == 159 + assert continuity['frames']['wave-02']['supports']['feet']['source_y'] == 164 + assert any(flag['kind'] == 'support' for flag in continuity['flags']) + run('qa_frames.py', config_path) + run('qa_frames.py', config_path, extra=('--approve', 'Synthetic translated fixture.')) + refused = run('finalize_mascot.py', config_path, should_pass=False) + assert 'continuity flag needs' in refused.stderr + shifted = json.loads((package / 'frames.js').read_text(encoding='utf-8').removeprefix('window.SPRITE = ').removesuffix(';\n')) + index = shifted['clips']['wave'][1][0] + sheet_index, local = divmod(index, shifted['perSheet']) + with Image.open(package / shifted['sheets'][sheet_index]['file']) as atlas: + assert max(atlas.size) <= 4096 + x = local % shifted['cols'] * shifted['cell'][0] + y = local // shifted['cols'] * shifted['cell'][1] + assert atlas.getpixel((x + 60, y + 25)) == (235, 190, 180, 255), 'explicit anchor translates the whole frame' + config['ground_anchors'] = {'wave-02': 0} + config_path.write_text(json.dumps(config), encoding='utf-8') + clipped = run('build_sprite.py', config_path, should_pass=False) + assert 'clips visible art' in clipped.stderr + print('Portable mascot pipeline smoke test passed') + + +if __name__ == '__main__': + main() diff --git a/docs/README.md b/docs/README.md index cabf3fdbc..2d9bccb3f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,7 @@ CanvasTTY is a spatial Electron desktop for real local terminals and AI-agent CL | [Pixel terminal themes (Russian)](pixel-skin-packs.ru.md) | Ten-PNG ZIP layout, terminal apertures, app import, and local agent CLI | | [Create a pixel theme with an agent](pixel-skin-agent-start.md) | Zero-context agent brief, exact install ZIP, visual checks, and validation | | [Runtime plugins](plugins.md) | Manifest v1, permissions, HOME widgets, canvas apps, separate windows, player media/playlist APIs, SDK, and install flow | +| [Animated mascots](mascots.md) | PNG/JPEG intake, a Codex creation session, live previews, frame review, transparent cards, approved installation, and retry diagnostics | | [Metrics and telemetry](metrics-and-telemetry.md) | Subscription limits, session token usage, source priority, privacy, stale states, and tests | | [Terminal renderer transition check](terminal-renderer-transition.md) | Manual hide/restore check for the DOM/WebGL transition snapshot | | [Security policy](../SECURITY.md) | Supported release, vulnerability reporting, local data boundaries, plugins, media grants, browser storage, and audit logs | diff --git a/docs/mascots.md b/docs/mascots.md new file mode 100644 index 000000000..82aa97992 --- /dev/null +++ b/docs/mascots.md @@ -0,0 +1,88 @@ +# Animated mascots + +Settings > Mascots creates an animated canvas character from a PNG or JPEG image. CanvasTTY opens a new Codex CLI session with one image and the bundled workflow, then opens a local browser workbench for the generated drawings and animations. Creation is interactive: the user approves the character design, chooses actions, reviews the result, and separately approves installation. + +## Requirements + +- An installed, authenticated Codex CLI. CanvasTTY does not install it or provide an image-generation subscription. +- Image-generation/editing tools available to the Codex session, plus CanvasTTY's agent browser connector for live inspection. Missing generation or browser capability must be reported; file creation is not a substitute for visual review. +- Python 3.10+ with Pillow and NumPy. `CANVASTTY_MASCOT_PYTHON` can point to a Python executable in a virtual environment. Otherwise the host checks installed Python candidates, including the optional Codex desktop runtime. +- One PNG or JPG/JPEG, with or without a background, up to 20 MB and between 32 and 4096 pixels per dimension. Intake validates decoded image content, not only the filename. + +The creation dialog explains that its button starts Codex in YOLO/bypass with full file and command access. Existing provider-specific acknowledgement is retained. Starting creation also enables agent browser access when it is disabled. This workflow does not silently change the normal launch profile for other sessions. + +### Linux setup + +The mascot code uses Node path/process APIs, Python scripts and the existing Linux Codex launcher. It does not run a Windows executable or require a Windows profile. The pipeline is copied into Linux application resources by the shared packaging configuration. CanvasTTY's existing OS isolation and provider protections remain in effect; a requested bypass profile is not a claim that Linux containment is disabled. + +From a source checkout, prepare a Python environment without modifying the distribution's system Python: + +```sh +python3 -m venv "$HOME/.local/share/canvastty-mascot-venv" +"$HOME/.local/share/canvastty-mascot-venv/bin/python" -m pip install -r assets/mascot-pipeline/requirements.txt +export CANVASTTY_MASCOT_PYTHON="$HOME/.local/share/canvastty-mascot-venv/bin/python" +npm run dev +``` + +Install the distribution's Python/venv support if `python3 -m venv` is unavailable. There is no dependency on a particular distribution's package manager. Follow the repository's normal Node/Go/development requirements and install/authenticate Codex CLI separately. + +For a packaged Linux application, install Pillow and NumPy into the same environment and launch the application with `CANVASTTY_MASCOT_PYTHON` set. A desktop shortcut must inherit that variable if the interpreter cannot otherwise be found. AppImage/deb are the host application's existing release formats; this feature does not ship a separate Windows launcher or a new Linux installer. + +The existing Ubuntu `verify` CI job exercises application tests/type-check/build, including the new mascot host/launch/card regressions. The new Ubuntu `mascot-pipeline` job exercises PNG/JPEG intake, the browser preview server, atlas/review/draft/final contracts and real-art placement. Neither substitutes for a live generation session on the maintainer's distribution. + +## User workflow + +1. Open Settings > Mascots > Create mascot and choose or drop the source image. +2. Codex reads the bundled workflow. Choose whether to retain or revise the appearance. Both choices include a full-body 2D preparation pass and removal of any background; approve the prepared drawing before animation. +3. Choose a name and actions from suggested examples, or describe a custom action, pose, object interaction or scene. Keep the complete requested phase plan, even when only a draft is ready. +4. Watch the source drawings and current built player in the CanvasTTY browser. Review face/emotion changes, identity, material colors, hair silhouette, props, effects and motion. Body and object scale, support contact and neighboring frames are checked before and after atlas placement. +5. Review the identified build. Final approval and permission to install are separate decisions. An explicitly accepted draft can be installed with disclosed defects and deferred phases without pretending the complete animation is finished. +6. CanvasTTY verifies the handoff, registers the local plugin, and opens or updates its transparent canvas card. Select animations from its menu, drag the character, resize proportionally, or close it. The corner and close control appear on hover; touch retains a visible resize corner. Zooming out keeps the mascot rendering instead of replacing it with a generic summary card. + +PNG input bytes are preserved. JPEG input bytes are preserved separately, while the working PNG is normalized for EXIF orientation without a generative redraw. Preparation and generated drawings are saved separately; intake conversion alone does not satisfy character preparation. + +## Bundled workflow and examples + +The canonical English workflow is [`assets/mascot-pipeline/SKILL.md`](../assets/mascot-pipeline/SKILL.md). It contains the complete creation, prompting, measurement, visual review, draft/final approval and delivery instructions on one page. The directory can also be copied intact as a standalone Codex skill; keep its scripts, templates and examples beside `SKILL.md`. + +The portable bundle includes: + +- PNG/JPEG intake, frame QA, plugin scaffolding, atlas building, contact-sheet/playback previews, continuity measurements, a loopback workbench and finalization scripts. +- A transparent sprite player with localized action labels, menu interaction outside the drag region, separate entrance/main/exit clips and current-build identification. +- Neutral example sets with 152, 39 and 16 drawings, including accepted object interactions, facial beats and effects. Examples guide motion and review; they must not replace the new user's character identity. +- A placement regression made from the same unchanged source art: an unaligned reconstruction compared with accepted aligned output. + +Example atlases and the regression add approximately 46 MB to the source bundle before compression. They contain no personal project records, private paths or image metadata. The art serves as motion reference and test evidence; it is not a quality guarantee for future generation or a claim of ownership over depicted character designs or trademarks. + +## Local project and installation contract + +Projects live under `/mascots//`. Each project preserves its source, generated frames, `character.json`, full action plan, preview evidence, review records and built plugin. Packaged applications load the workflow from `resources/mascot-pipeline`; development loads `assets/mascot-pipeline`. The workbench binds to loopback and exposes only allowed image/player routes, not arbitrary project files. + +`finalize_mascot.py` checks current source/config/package hashes, mechanical QA, required phases, continuity evidence, all-step coverage and observation provenance. Filled review text alone cannot establish artistic quality. Actual source/playback inspection and the user's build-specific approval remain necessary. + +- Default finalization records `reviewed`, without requesting installation. +- `--install-approved` requests installation of a finally approved build. +- `--draft --install-approved` requests an explicitly accepted draft using separate `draft-review.json`, the preserved full plan, deferred phases and visible limitations. It must not claim final approval. + +Only the finalizer writes the handoff. The host re-verifies it before calling the registered local-mascot installer. It accepts a single static canvas contribution without executable services, hooks or permissions, checks project containment and package assets, and retains the previous registered package under project revisions when updating the same project's plugin. An unknown unregistered destination is preserved and reported rather than overwritten. Do not copy files into the host plugin directory or edit its registry as an installation shortcut. + +`ready_for_host` is a request, not proof that the runtime loaded the build. Distinguish the requested build, installed build, registration and observed runtime ID. A successful install alone does not prove drag/resize/menu/zoom behavior. + +## Recovering an installation failure + +Settings > Mascots shows the failure stage and short root cause, with **Open full log** and **Retry installation**. `installation-error.log` preserves the full command error, stdout and stderr/traceback. A corrected handoff is checked even after the project becomes `failed`; an unchanged rejected request is not retried every three seconds. Explicit retry also supports transient failures without restarting CanvasTTY or manually rewriting its project record. A failed update preserves the previous registered version. + +The typed host API exposes `mascots.list()`, `retry(projectId)` and `openLog(projectId)` to the trusted renderer. Plugins do not receive this privileged API. The host records installation state; the actual iframe's current runtime build must be observed separately. + +## Validation + +From the repository root, using a Python environment with Pillow and NumPy: + +```text +npm run typecheck +node --test tests/mascot-installation.test.mjs tests/plugin-bounds.test.mjs tests/plugin-manager.test.mjs tests/settings-normalizer.test.mjs tests/agent-runtime-provider-launch.test.mjs tests/terminal-launch.test.mjs +python -B assets/mascot-pipeline/tests/smoke_pipeline.py +python -B assets/mascot-pipeline/tests/placement_regression.py +``` + +CI runs the Python pipeline checks on Ubuntu and macOS separately from the application's existing checks. The Ubuntu application job also builds Linux installers, verifies the bundled mascot workflow and player, and smoke-tests the packaged AppImage. The macOS job verifies the same resources inside the packaged application before checking CLI resolution, terminal rendering and browser input. Synthetic tests verify contracts and source-to-atlas placement, not generative quality. Test the complete live creation and card interaction flow on each supported platform before release; a Windows preview run does not establish macOS or Linux UI acceptance. diff --git a/electron-builder.yml b/electron-builder.yml index 318448a58..3b9d2fb9d 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -71,6 +71,12 @@ electronFuses: enableNodeCliInspectArguments: false enableEmbeddedAsarIntegrityValidation: true extraResources: + - from: assets/mascot-pipeline + to: mascot-pipeline + filter: + - "**/*" + - "!**/__pycache__/**" + - "!**/*.pyc" - from: scripts/canvastty-control.mjs to: agent-control/canvastty-control.mjs - from: agent/orchestrator diff --git a/scripts/audit-secrets.mjs b/scripts/audit-secrets.mjs index 8e0bbe049..6323263f5 100644 --- a/scripts/audit-secrets.mjs +++ b/scripts/audit-secrets.mjs @@ -13,7 +13,9 @@ const IGNORED_ENTRY_NAMES = new Set([ "out", "dist", "release", - "artifacts" + "artifacts", + "__pycache__", + ".mascot-venv" ]); // Generated native build output, gitignored exactly like out/ and dist/. node-gyp // writes the builder's absolute home path into the generated project files and diff --git a/src/main/index.ts b/src/main/index.ts index e2fb63e29..474eb741c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -38,6 +38,7 @@ import { type ProviderCliRegistry } from "./services/providerCliRegistry"; import { PluginManager } from "./services/PluginManager"; +import { MascotManager } from "./services/MascotManager"; import { PluginServiceSupervisor } from "./services/PluginServiceSupervisor"; import { LaunchPipeline } from "./services/LaunchPipeline"; import { EnvironmentRegistry } from "./services/EnvironmentRegistry"; @@ -168,6 +169,7 @@ let terminalManager: TerminalManager | null = null; let agentControl: AgentControlGateway | null = null; let limitsService: LimitsService | null = null; let pluginManager: PluginManager | null = null; +let mascotManager: MascotManager | null = null; let pluginServices: PluginServiceSupervisor | null = null; let pluginSessions: PluginSessions | null = null; let pluginCards: PluginCards | null = null; @@ -772,6 +774,14 @@ async function initializeServices(ipc: IpcRegistrar): Promise { await applyAgentControlSetting(settings.get().agentControlEnabled); limitsService = new LimitsService(providerClis, app.getVersion()); await Promise.all([browserReady, storesLoaded]); + mascotManager = new MascotManager( + userDataPath, + app.isPackaged ? join(process.resourcesPath, "mascot-pipeline") : join(app.getAppPath(), "assets", "mascot-pipeline"), + pluginManager, + (projects) => { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send(IPC.mascotsChanged, projects); }, + (pluginId, contributionId) => requestPluginCanvas({ pluginId, contributionId }) + ); + await mascotManager.initialize(); pluginManager.registerTokenProvider(() => githubAuth!.getToken()); protocol.handle("canvastty-media", (request) => pluginMediaService!.protocolResponse(request)); materialService = new MaterialService({ @@ -786,6 +796,7 @@ async function initializeServices(ipc: IpcRegistrar): Promise { await materialService.load(); registerMaterialIpc(ipc, { materials: materialService, getMainWindow: () => mainWindow }); registerIpc(ipc, { + mascots: mascotManager, settings, recheckProviderClis: async () => { providerClis!.refresh(); @@ -1215,6 +1226,7 @@ void IPC.terminalData; async function shutdownServices(): Promise { diagnostics.record("info", "application", "shutdown.started"); + mascotManager?.dispose(); agentChatHistory?.dispose(); if (agentControl) await Promise.allSettled([agentControl.close()]); if (updateTimer) clearTimeout(updateTimer); diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index 72a6c87e5..43c9a5c53 100644 --- a/src/main/ipc/registerIpc.ts +++ b/src/main/ipc/registerIpc.ts @@ -32,6 +32,7 @@ import type { TerminalManager } from "../services/TerminalManager"; import type { AgentChatHistoryService } from "../services/AgentChatHistoryService"; import type { LimitsService } from "../services/LimitsService"; import type { PluginManager } from "../services/PluginManager"; +import type { MascotManager } from "../services/MascotManager"; import type { PluginServiceSupervisor } from "../services/PluginServiceSupervisor"; import type { PluginCards } from "../services/PluginCards"; import type { PluginMediaService } from "../services/PluginMediaService"; @@ -57,6 +58,7 @@ interface CriticalDependencies { } interface Dependencies { + mascots: MascotManager; settings: SettingsStore; recheckProviderClis(): Promise<{ availability: AgentCliAvailability; settings: AppSettings }>; terminals: TerminalManager; @@ -193,6 +195,7 @@ export function registerCriticalIpc(ipcMain: IpcRegistrar, { } export function registerIpc(ipcMain: IpcRegistrar, { + mascots, settings, recheckProviderClis, terminals, @@ -218,7 +221,33 @@ export function registerIpc(ipcMain: IpcRegistrar, { requestPluginCanvas, broadcastPluginStorageChange }: Dependencies): void { + ipcMain.handle(IPC.mascotsList, (event) => { + assertMainRenderer(event, getMainWindow); + return mascots.list(); + }); + ipcMain.handle(IPC.mascotsStart, (event, image: Uint8Array) => { + assertMainRenderer(event, getMainWindow); + return mascots.start(image); + }); + ipcMain.handle(IPC.mascotsLink, (event, projectId: string, sessionId: string) => { + assertMainRenderer(event, getMainWindow); + return mascots.link(projectId, sessionId); + }); + ipcMain.handle(IPC.mascotsFail, (event, projectId: string, message: string) => { + assertMainRenderer(event, getMainWindow); + if (typeof message !== "string") throw new Error("Mascot error is invalid."); + return mascots.fail(projectId, message); + }); const pluginBrowserOpenBroker = new PluginBrowserOpenBroker(getMainWindow); + ipcMain.handle(IPC.mascotsRetry, (event, projectId: string) => { + assertMainRenderer(event, getMainWindow); + return mascots.retry(projectId); + }); + ipcMain.handle(IPC.mascotsOpenLog, async (event, projectId: string) => { + assertMainRenderer(event, getMainWindow); + const error = await shell.openPath(await mascots.errorLog(projectId)); + if (error) throw new Error(error); + }); // A surface reaches only its own plugin's services: the caller's plugin id is bound by the // renderer frame host or by the identity-checked plugin window, never taken from plugin code. const requestPluginService = (pluginId: string, values: Record): Promise => { diff --git a/src/main/services/MascotManager.ts b/src/main/services/MascotManager.ts new file mode 100644 index 000000000..41033a65e --- /dev/null +++ b/src/main/services/MascotManager.ts @@ -0,0 +1,336 @@ +import { randomUUID } from "node:crypto"; +import { execFile, spawn, type ChildProcess } from "node:child_process"; +import { appendFile, mkdir, readFile, readdir, realpath, rename, stat, unlink, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { isAbsolute, join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { nativeImage } from "electron"; +import { isPathInside } from "../../agent-runtime/path-inside.mjs"; +import type { MascotLaunch, MascotProjectSummary } from "../../shared/contracts"; +import type { PluginManager } from "./PluginManager"; + +interface MascotRecord extends MascotProjectSummary { createdAt: number; installedBuildId?: string } +const MAX_IMAGE_BYTES = 20 * 1024 * 1024; +const PNG_SIGNATURE = Buffer.from("89504e470d0a1a0a", "hex"); +const execFileAsync = promisify(execFile); + +export class MascotManager { + private readonly root: string; + private readonly pipeline: string; + private readonly plugins: PluginManager; + private readonly changed: (projects: MascotProjectSummary[]) => void; + private readonly installed: (pluginId: string, contributionId: string) => void; + private readonly records = new Map(); + private timer: ReturnType | null = null; + private checking = false; + private readonly previews = new Set(); + private readonly rejectedResults = new Map(); + private readonly pendingRetries = new Set(); + + constructor(userDataPath: string, pipelinePath: string, plugins: PluginManager, + changed: (projects: MascotProjectSummary[]) => void, + installed: (pluginId: string, contributionId: string) => void) { + this.root = join(userDataPath, "mascots"); + this.pipeline = pipelinePath; + this.plugins = plugins; + this.changed = changed; + this.installed = installed; + } + + async initialize(): Promise { + await mkdir(this.root, { recursive: true }); + for (const entry of await readdir(this.root, { withFileTypes: true })) { + if (!entry.isDirectory() || !isProjectId(entry.name)) continue; + try { + const record = JSON.parse(await readFile(join(this.root, entry.name, "mascot-project.json"), "utf8")) as MascotRecord; + if (record.id === entry.name && isMascotRecord(record)) this.records.set(entry.name, record); + } catch { /* An incomplete project remains on disk for manual recovery. */ } + } + this.timer = setInterval(() => { void this.checkResults(); }, 3_000); + void this.checkResults(); + } + + dispose(): void { + if (this.timer) clearInterval(this.timer); + this.timer = null; + for (const child of this.previews) child.kill(); + this.previews.clear(); + } + + list(): MascotProjectSummary[] { + return [...this.records.values()].sort((a, b) => b.createdAt - a.createdAt) + .map(({ createdAt: _createdAt, ...record }) => ({ ...record, + registered: this.plugins.list().some((plugin) => plugin.manifest.id === record.pluginId && plugin.sourceUrl === `mascot:${record.id}`) + })); + } + + async start(value: Uint8Array): Promise { + await stat(join(this.pipeline, "SKILL.md")); + const template = await readFile(join(this.pipeline, "templates", "launch-prompt.txt"), "utf8"); + if (!template) throw new Error("Mascot launch prompt is missing."); + if (!(value instanceof Uint8Array) || value.byteLength < 32 || value.byteLength > MAX_IMAGE_BYTES) { + throw new Error("Choose a PNG or JPEG up to 20 MB."); + } + const bytes = Buffer.from(value); + const isPng = bytes.subarray(0, 8).equals(PNG_SIGNATURE); + const isJpeg = bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff; + if (!isPng && !isJpeg) throw new Error("The character image must be a PNG or JPEG."); + const image = nativeImage.createFromBuffer(bytes); + const size = image.getSize(); + if (image.isEmpty() || size.width < 32 || size.height < 32 || size.width > 4096 || size.height > 4096) { + throw new Error("The image dimensions must be between 32 and 4096 pixels."); + } + const bitmap = image.toBitmap(); + let visible = false; + for (let index = 3; index < bitmap.length; index += 4) { + if (bitmap[index] > 0) visible = true; + if (visible) break; + } + if (!visible) throw new Error("The image must contain visible character pixels."); + const python = await resolveMascotPython(); + + const id = randomUUID(); + const cwd = join(this.root, id); + await mkdir(this.root, { recursive: true }); + const uploadPath = join(this.root, `.upload-${id}.${isJpeg ? "jpg" : "png"}`); + await writeFile(uploadPath, bytes, { flag: "wx" }); + try { + await execFileAsync(python, [join(this.pipeline, "scripts", "prepare_input.py"), "--image", uploadPath, "--project", cwd], + { timeout: 15_000, maxBuffer: 4096, windowsHide: true }); + } finally { + await unlink(uploadPath); + } + const imagePath = join(cwd, "references", "character.png"); + const originalPath = join(cwd, "references", isJpeg ? "character.jpg" : "character.png"); + const previewUrl = await this.startPreview(python, cwd); + const record: MascotRecord = { id, name: "New mascot", status: "creating", createdAt: Date.now() }; + await this.save(record); + this.records.set(id, record); + this.changed(this.list()); + + const prompt = template + .replaceAll("[PROJECT_DIR]", cwd) + .replaceAll("[PIPELINE_DIR]", this.pipeline) + .replaceAll("[PREVIEW_URL]", previewUrl) + .trim() + `\n\nThe original upload is preserved at ${originalPath}. Intake has already prepared ${imagePath}; do not run prepare_input.py again. ` + + (isJpeg ? `The JPEG working copy has corrected EXIF orientation and PNG encoding; it still needs the skill's full-body image-generation preparation and background removal. ` : "") + + `Python 3.10+ with Pillow and NumPy was verified at ${python}. Use this exact executable for every pipeline script, not a bare python command. ` + + `Absolute script paths: qa_frames.py = ${join(this.pipeline, "scripts", "qa_frames.py")}; ` + + `scaffold_plugin.py = ${join(this.pipeline, "scripts", "scaffold_plugin.py")}; ` + + `build_sprite.py = ${join(this.pipeline, "scripts", "build_sprite.py")}; ` + + `preview_animation.py = ${join(this.pipeline, "scripts", "preview_animation.py")}; ` + + `finalize_mascot.py = ${join(this.pipeline, "scripts", "finalize_mascot.py")}. ` + + `Pass ${join(cwd, "character.json")} as the config argument and quote paths when invoking them in the shell.`; + return { project: this.list()[0]!, cwd, imagePath, prompt, previewUrl }; + } + + private startPreview(python: string, cwd: string): Promise { + return new Promise((resolveUrl, reject) => { + const child = spawn(python, [join(this.pipeline, "scripts", "serve_preview.py"), cwd, "--port", "0"], + { windowsHide: true, stdio: ["ignore", "pipe", "ignore"] }); + this.previews.add(child); + let output = ""; + let settled = false; + const fail = (): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + child.kill(); + reject(new Error("The mascot preview server could not start.")); + }; + const timer = setTimeout(fail, 10_000); + child.once("error", fail); + child.once("exit", () => { this.previews.delete(child); fail(); }); + child.stdout?.on("data", (chunk: Buffer) => { + if (settled) return; + output += chunk.toString("utf8"); + if (output.length > 4096) return fail(); + if (!output.includes("\n")) return; + try { + const value = JSON.parse(output.split("\n")[0]!) as { url?: string }; + if (!value.url || !/^http:\/\/127\.0\.0\.1:\d+\/$/.test(value.url)) return fail(); + settled = true; + clearTimeout(timer); + resolveUrl(value.url); + } catch { fail(); } + }); + }); + } + + async link(projectId: string, sessionId: string): Promise { + const record = this.require(projectId); + if (!/^[0-9a-f-]{36}$/i.test(sessionId)) throw new Error("Codex session ID is invalid."); + record.sessionId = sessionId; + await this.save(record); + this.changed(this.list()); + } + + async fail(projectId: string, message: string): Promise { + const record = this.require(projectId); + if (record.status === "ready") return; + await this.recordError(record, "creation", new Error(message)); + } + + async retry(projectId: string): Promise { + const record = this.require(projectId); + if (this.checking) { this.pendingRetries.add(projectId); return; } + this.rejectedResults.delete(projectId); + if (record.status === "failed") record.status = "creating"; + await this.save(record); + this.changed(this.list()); + // An active check owns the record; the next poll handles a retry queued during it. + await this.checkResults(); + } + + async errorLog(projectId: string): Promise { + this.require(projectId); + const project = await realpath(join(this.root, projectId)); + const path = await realpath(join(project, "installation-error.log")); + if (!isPathInside(project, path)) throw new Error("Mascot log path escapes its project."); + return path; + } + + private async recordError(record: MascotRecord, stage: string, cause: unknown): Promise { + const error = cause as { message?: string; stack?: string; stderr?: string; stdout?: string }; + const detail = [error?.stack ?? error?.message ?? String(cause), error?.stdout, error?.stderr].filter(Boolean).join("\n"); + record.errorLogPath = join(this.root, record.id, "installation-error.log"); + await appendFile(record.errorLogPath, `\n${new Date().toISOString()} stage=${stage} requested=${record.requestedBuildId ?? "unknown"}\n${detail}\n`); + const lines = (error?.stderr || error?.message || String(cause)).trim().split(/\r?\n/).filter(Boolean); + record.error = (lines.at(-1) ?? "Mascot installation failed.").slice(0, 500); + record.errorStage = stage; + if (record.status !== "ready") record.status = "failed"; + await this.save(record); + this.changed(this.list()); + } + + private require(id: string): MascotRecord { + const record = this.records.get(id); + if (!record) throw new Error("Mascot project does not exist."); + return record; + } + + private async save(record: MascotRecord): Promise { + const path = join(this.root, record.id, "mascot-project.json"); + const temporary = `${path}.tmp`; + await writeFile(temporary, JSON.stringify(record, null, 2)); + await rename(temporary, path); + } + + private async checkResults(): Promise { + if (this.checking) return; + this.checking = true; + try { + for (const record of this.records.values()) { + const path = join(this.root, record.id, "mascot-result.json"); + let raw: string; + try { raw = await readFile(path, "utf8"); } catch { continue; } + if (this.rejectedResults.get(record.id) === raw) continue; + let stage = "handoff"; + try { + const result = JSON.parse(raw) as Record; + if (result.status === "reviewed") continue; + if (result.installationApproved !== true || typeof result.buildId !== "string") continue; + if (record.installedBuildId === result.buildId) { + if (record.errorStage === "card load" && record.pluginId) { + stage = "card load"; + const plugin = this.plugins.list().find((item) => item.manifest.id === record.pluginId && item.sourceUrl === `mascot:${record.id}`); + if (!plugin) throw new Error("Installed mascot is not registered."); + this.installed(plugin.manifest.id, plugin.manifest.contributions[0]!.id); + delete record.error; + delete record.errorStage; + delete record.errorLogPath; + await this.save(record); + this.changed(this.list()); + } + continue; + } + record.requestedBuildId = result.buildId; + if (result.schemaVersion !== 1 || result.status !== "ready_for_host" + || typeof result.name !== "string" || typeof result.id !== "string" + || typeof result.pluginDirectory !== "string" || !Array.isArray(result.actions) || result.actions.length === 0 + || !result.actions.every((action) => typeof action === "string") + || !result.presentation || typeof result.presentation !== "object" + || (result.presentation as Record).transparentCard !== true + || (result.presentation as Record).resizable !== true) { + throw new Error("Mascot handoff is incomplete."); + } + const projectRoot = await realpath(join(this.root, record.id)); + stage = "package/review validation"; + const python = await resolveMascotPython(); + if (result.delivery !== undefined && result.delivery !== "draft" && result.delivery !== "final") throw new Error("Unknown mascot delivery mode."); + await execFileAsync(python, [join(this.pipeline, "scripts", "finalize_mascot.py"), join(projectRoot, "character.json"), "--verify-only", "--install-approved", ...(result.delivery === "draft" ? ["--draft"] : [])], { + timeout: 30_000, + maxBuffer: 1_048_576, + windowsHide: true + }); + const verified = JSON.parse(await readFile(path, "utf8")) as Record; + const build = JSON.parse(await readFile(join(projectRoot, "build-report.json"), "utf8")) as { build_id?: string }; + if (JSON.stringify(verified) !== JSON.stringify(result) || verified.buildId !== build.build_id || verified.installationApproved !== true) { + throw new Error("The approved mascot build changed before installation."); + } + if (verified.status !== "ready_for_host" || typeof verified.pluginDirectory !== "string" + || typeof verified.id !== "string" || typeof verified.name !== "string") { + throw new Error("Mascot finalization did not produce a valid handoff."); + } + if (!isAbsolute(verified.pluginDirectory)) throw new Error("Mascot plugin path must be absolute."); + const pluginRoot = await realpath(resolve(verified.pluginDirectory)); + if (!isPathInside(projectRoot, pluginRoot)) throw new Error("Mascot plugin path escapes its project."); + const config = JSON.parse(await readFile(join(projectRoot, "character.json"), "utf8")) as { id: string; name: string; plugin_dir: string }; + if (verified.id !== config.id || verified.name !== config.name || pluginRoot !== await realpath(resolve(projectRoot, config.plugin_dir))) throw new Error("Mascot handoff does not match the verified configuration."); + const review = JSON.parse(await readFile(join(projectRoot, result.delivery === "draft" ? "draft-review.json" : "review.json"), "utf8")) as { limitations?: string[] }; + const limits = result.delivery === "draft" ? review.limitations : []; + if (JSON.stringify(result.limitations ?? []) !== JSON.stringify(limits)) throw new Error("Mascot handoff limits differ from the accepted review."); + stage = "registration"; + const installed = await this.plugins.installLocalMascot(record.id, pluginRoot, verified.id); + record.status = "ready"; + record.name = verified.name.slice(0, 100); + record.pluginId = installed.manifest.id; + record.installedBuildId = String(verified.buildId); + record.delivery = result.delivery === "draft" ? "draft" : "final"; + record.limitations = Array.isArray(result.limitations) ? result.limitations.filter((item): item is string => typeof item === "string") : []; + delete record.error; + delete record.errorStage; + delete record.errorLogPath; + this.rejectedResults.delete(record.id); + await this.save(record); + this.changed(this.list()); + stage = "card load"; + this.installed(installed.manifest.id, installed.manifest.contributions[0]!.id); + } catch (cause) { + if (stage === "handoff" && cause instanceof SyntaxError) continue; + this.rejectedResults.set(record.id, raw); + await this.recordError(record, stage, cause); + } + } + } finally { + this.checking = false; + for (const id of this.pendingRetries) this.rejectedResults.delete(id); + this.pendingRetries.clear(); + } + } +} + +function isProjectId(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(value); } + +async function resolveMascotPython(): Promise { + const bundled = join(homedir(), ".cache", "codex-runtimes", "codex-primary-runtime", "dependencies", "python"); + const candidates: Array<[string, string[]]> = []; + if (process.env.CANVASTTY_MASCOT_PYTHON) candidates.push([process.env.CANVASTTY_MASCOT_PYTHON, []]); + if (process.platform === "win32") candidates.push([join(bundled, "python.exe"), []], ["python", []], ["py", ["-3"]]); + else candidates.push(["python3", []], ["python", []], [join(bundled, "bin", "python3"), []]); + for (const [command, args] of candidates) { + try { + const { stdout } = await execFileAsync(command, [...args, "-c", + "import sys, PIL, numpy; sys.exit(1) if sys.version_info < (3, 10) else print(sys.executable)"], + { timeout: 5_000, maxBuffer: 1024, windowsHide: true }); + const executable = stdout.trim(); + if (isAbsolute(executable)) return executable; + } catch { /* Try the next installed Python. */ } + } + throw new Error("Mascot creation needs Python 3.10+ with Pillow and NumPy. Install those packages or set CANVASTTY_MASCOT_PYTHON to a working Python executable."); +} + +function isMascotRecord(value: MascotRecord): boolean { + return typeof value.name === "string" && typeof value.createdAt === "number" + && (value.status === "creating" || value.status === "ready" || value.status === "failed"); +} diff --git a/src/main/services/PluginManager.ts b/src/main/services/PluginManager.ts index 927b553ab..0e86e7192 100644 --- a/src/main/services/PluginManager.ts +++ b/src/main/services/PluginManager.ts @@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto"; import { gunzipSync } from "node:zlib"; import { chmod, + cp, lstat, copyFile, mkdir, @@ -290,7 +291,7 @@ export class PluginManager { const active = activeManifest(manifest, selectedModules); this.plugins.set(pluginId, { manifest, - sourceUrl: normalizeGithubUrl(record.sourceUrl), + sourceUrl: isLocalMascotSource(record.sourceUrl) ? record.sourceUrl : normalizeGithubUrl(record.sourceUrl), enabled: record.enabled, installedAt: record.installedAt, selectedModules, @@ -405,6 +406,62 @@ export class PluginManager { return this.withPluginLock(preview.manifest.id, () => this.installPending(pending, modules, destination)); } + async installLocalMascot(projectId: string, packageRoot: string, expectedPluginId: string): Promise { + if (!/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(projectId)) throw new Error("Mascot project ID is invalid."); + const projectRoot = await realpath(join(dirname(this.pluginRoot), "mascots", projectId)); + const source = await realpath(packageRoot); + if (!isPathInside(projectRoot, source) || source === projectRoot) throw new Error("Mascot plugin escapes its project."); + await inspectPackage(source, 150 * 1024 * 1024); + const manifest = await readManifest(source); + if (manifest.id !== expectedPluginId) throw new Error("Mascot plugin ID differs from handoff."); + if (manifest.permissions.length !== 0 || manifest.services?.length || manifest.hooks?.length + || manifest.contributions.length !== 1 || manifest.contributions[0]?.kind !== "canvas-app") { + throw new Error("Mascot plugin must be a static canvas app without permissions."); + } + await assertManifestAssets(source, manifest); + return this.withPluginLock(manifest.id, async () => { + const previous = this.plugins.get(manifest.id); + if (previous && previous.sourceUrl !== `mascot:${projectId}`) throw new Error(`Plugin ${manifest.id} belongs to another source.`); + const staging = await mkdtemp(join(this.pluginRoot, ".mascot-")); + const destination = join(this.pluginRoot, manifest.id); + const backup = join(projectRoot, "revisions", `installed-${randomUUID()}`); + let backedUp = false; + let replaced = false; + try { + if (!previous) { + const existing = await lstat(destination).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + return null; + }); + if (existing) throw new Error(`Unregistered plugin directory exists: ${destination}. Preserve it and resolve ownership before retrying installation.`); + } + await cp(source, staging, { recursive: true, force: false }); + await inspectPackage(staging, 150 * 1024 * 1024); + if (previous) { + await mkdir(dirname(backup), { recursive: true }); + await rename(destination, backup); + backedUp = true; + } + await rename(staging, destination); + replaced = true; + const installed: InstalledPlugin = { + manifest, sourceUrl: `mascot:${projectId}`, enabled: true, installedAt: Date.now(), + selectedModules: [], enabledHooks: [], nativeCodeTrusted: false, decisionsMayAllow: false + }; + this.plugins.set(manifest.id, installed); + await this.persistRegistry(); + return structuredClone(installed); + } catch (error) { + if (previous) this.plugins.set(manifest.id, previous); else this.plugins.delete(manifest.id); + if (replaced) await rm(destination, { recursive: true, force: true }); + if (backedUp) await rename(backup, destination); + throw error; + } finally { + await rm(staging, { recursive: true, force: true }); + } + }); + } + private async installPending( { preview, packageRoot, directory }: PendingInstall, modules: string[], @@ -953,14 +1010,14 @@ export class PluginManager { async checkForUpdates(): Promise { const sources = [...this.plugins.values()] - .filter((plugin) => plugin.enabled && plugin.sourceUrl) + .filter((plugin) => plugin.enabled && plugin.sourceUrl && !isLocalMascotSource(plugin.sourceUrl)) .sort((left, right) => left.manifest.id.localeCompare(right.manifest.id)); // Batch: one GraphQL metadata round-trip for all manifests, then raw // fetches for present files — far fewer requests than one per plugin. const remoteVersions = await fetchRemoteManifestVersions(sources.map((plugin) => plugin.sourceUrl)); return this.withVersionsLock((versions) => { const installed = [...this.plugins.values()] - .filter((plugin) => plugin.enabled && plugin.sourceUrl) + .filter((plugin) => plugin.enabled && plugin.sourceUrl && !isLocalMascotSource(plugin.sourceUrl)) .sort((left, right) => left.manifest.id.localeCompare(right.manifest.id)); const updates: PluginUpdateStatus[] = []; for (const plugin of installed) { @@ -1975,10 +2032,10 @@ function validateContribution(value: unknown): PluginContribution { return { ...base, kind: "home-widget", defaultSize: validateGridSize(value.defaultSize) }; } if (value.kind === "canvas-app" || value.kind === "window") { - const defaultSize = validateWindowSize(value.defaultSize, "defaultSize", 320, 220); + const defaultSize = validateWindowSize(value.defaultSize, "defaultSize", value.kind === "canvas-app" ? 128 : 320, 220); const minSize = value.minSize === undefined ? null - : validateWindowSize(value.minSize, "minSize", 240, 140); + : validateWindowSize(value.minSize, "minSize", value.kind === "canvas-app" ? 128 : 240, 140); if (minSize && (minSize.width > defaultSize.width || minSize.height > defaultSize.height)) { throw new Error("Plugin contribution minSize must not exceed defaultSize."); } @@ -2008,7 +2065,7 @@ async function firstExistingFile(root: string, candidates: readonly string[]): P return null; } -async function inspectPackage(root: string): Promise { +async function inspectPackage(root: string, maxBytes = MAX_PACKAGE_BYTES): Promise { let entryCount = 0; let totalBytes = 0; const visit = async (directory: string): Promise => { @@ -2027,7 +2084,7 @@ async function inspectPackage(root: string): Promise { } if (!metadata.isFile()) throw new Error("Plugin packages may contain only regular files and directories."); totalBytes += metadata.size; - if (totalBytes > MAX_PACKAGE_BYTES) { + if (totalBytes > maxBytes) { throw new Error("Plugin package exceeds the 500 entry / 25 MB limit."); } } @@ -2035,6 +2092,10 @@ async function inspectPackage(root: string): Promise { await visit(root); } +function isLocalMascotSource(value: string): boolean { + return /^mascot:[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(value); +} + async function assertManifestAssets(root: string, manifest: PluginManifest): Promise { for (const contribution of manifest.contributions) { await containedFile(root, contribution.entry); diff --git a/src/main/services/SettingsStore.ts b/src/main/services/SettingsStore.ts index d8963b07e..a3ff77574 100644 --- a/src/main/services/SettingsStore.ts +++ b/src/main/services/SettingsStore.ts @@ -985,7 +985,7 @@ function normalizePluginCanvas(candidate: unknown, fallback: readonly PluginCanv title: source.title.trim().slice(0, 80), position: { x: source.position.x, y: source.position.y }, size: { - width: clamp(source.size.width, 240, 1_600), + width: clamp(source.size.width, 128, 1_600), height: clamp(source.size.height, 140, 1_100) } }); diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 157be2969..2b70689de 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -253,6 +253,7 @@ export class TerminalManager { private baseProtectionOn: () => boolean = () => false; // The model and effort of a card whose first launch runs before the card is registered (create, restore). private readonly startingModels = new Map(); + private readonly initialCodexInputs = new Map(); private quitting = false; private readonly quitReleases: Promise[] = []; // Every PTY started here whose exit has not been reported yet, closed cards included, with that exit. @@ -634,6 +635,18 @@ export class TerminalManager { throw new Error("Result capture requires a Codex or OpenCode session."); } assertDirectory(request.cwd); + if (request.initialPrompt !== undefined || request.initialImagePath !== undefined) { + if (request.provider !== "codex" || request.resumeThreadId !== undefined + || typeof request.initialPrompt !== "string" || !request.initialPrompt.trim() + || request.initialPrompt.length > 20_000 || typeof request.initialImagePath !== "string") { + throw new Error("A new Codex chat needs one initial prompt and image."); + } + const imagePath = realpathSync(request.initialImagePath); + if (!isPathInside(realpathSync(request.cwd), imagePath) || !statSync(imagePath).isFile()) { + throw new Error("Initial Codex image must be a file in the project folder."); + } + request = { ...request, initialImagePath: imagePath }; + } const role = request.role ?? "agent"; if (request.parentSessionId !== undefined && !this.sessions.has(request.parentSessionId)) { @@ -678,6 +691,9 @@ export class TerminalManager { // With launch options, an environment or a launch policy the plugins answer first; the card waits and launches when they do. const contributed = (Boolean(launchOptions) || Boolean(environmentChoice) || this.policyApplies(request.provider)) && !awaitMeasuredGrid; this.startingModels.set(id, modelChoice); + if (request.initialPrompt && request.initialImagePath) { + this.initialCodexInputs.set(id, { prompt: request.initialPrompt, imagePath: request.initialImagePath }); + } if (control.ownerPluginId !== undefined) this.startingOwners.set(id, control.ownerPluginId); let launched: ReturnType | { process: null; agentBrowser: null; agentRuntime: null; agentOrchestration: null; failure: null }; try { @@ -1143,6 +1159,7 @@ export class TerminalManager { } this.hiddenSinceOffset.delete(id); this.launchContexts.delete(id); + this.initialCodexInputs.delete(id); this.redaction.clear(`session:${id}`); this.releaseIsolation(id); session.launchToken += 1; @@ -1750,8 +1767,13 @@ export class TerminalManager { ...(agentRuntime?.decisions === true && this.baseProtectionOn() && !environmentWrapped ? { shellGuarded: true } : {}), ...(isolated ? { isolated: true } : {}), cwd, + ...(this.initialCodexInputs.get(id) ? { + initialPrompt: this.initialCodexInputs.get(id)!.prompt, + initialImagePath: this.initialCodexInputs.get(id)!.imagePath + } : {}), ...this.launchModelOf(id) }); + this.initialCodexInputs.delete(id); const session = this.sessions.get(id); if (session) setAutoDowngraded(session.metadata, profile === "auto" && contribution?.thirdPartyModel === true); // A plugin may add to the person's environment, never replace what the core sets for this launch. diff --git a/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts b/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts index 663875166..a945496fe 100644 --- a/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts +++ b/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts @@ -562,7 +562,8 @@ function decisionHookCommands( command: commandWithEnvironment( [gate.command, ...gate.args, "pretool"], { ...(gate.env ?? {}), ...budgetEnvironment(decisionBudgetMs), [DECISION_FAIL_CLOSED_ENV]: "1" }, - platform + platform, + provider === "codex" ? "powershell" : "cmd" ), // Qwen hook timeouts are milliseconds; Claude's and Codex's are seconds. timeout: provider === "qwen" ? hookSeconds * 1_000 : hookSeconds @@ -645,7 +646,7 @@ function codexHookArgs( ): string[] { validateHelper(helper); const grouped = groupProviderHookMappings([ - ...(coreHooksEnabled ? lifecycleCommands(CODEX_HOOKS, helper, platform) : []), + ...(coreHooksEnabled ? lifecycleCommands(CODEX_HOOKS, helper, platform, HOOK_TIMEOUT_SECONDS, "powershell") : []), ...pluginCommands ]); const events = Object.entries(grouped).flatMap(([event, mappings]) => { @@ -1057,12 +1058,13 @@ function lifecycleCommands( mappings: readonly HookMapping[], helper: RuntimeHookHelperLaunch, platform: NodeJS.Platform, - timeout = HOOK_TIMEOUT_SECONDS + timeout = HOOK_TIMEOUT_SECONDS, + windowsShell: "cmd" | "powershell" = "cmd" ): ProviderHookCommand[] { return mappings.map((mapping) => ({ event: mapping.event, ...(mapping.matcher ? { matcher: mapping.matcher } : {}), - command: hookCommand(helper, mapping, platform), + command: hookCommand(helper, mapping, platform, windowsShell), timeout })); } @@ -1113,10 +1115,11 @@ function overlaySignature( function hookCommand( helper: RuntimeHookHelperLaunch, mapping: HookMapping, - platform: NodeJS.Platform + platform: NodeJS.Platform, + windowsShell: "cmd" | "powershell" = "cmd" ): string { const args = [helper.command, ...helper.args, mapping.state, mapping.event]; - return commandWithEnvironment(args, helper.env ?? {}, platform); + return commandWithEnvironment(args, helper.env ?? {}, platform, windowsShell); } function pluginHookCommand( @@ -1137,16 +1140,22 @@ function pluginHookCommand( event, providerEvent ]; - return commandWithEnvironment(args, runner.env ?? {}, platform); + return commandWithEnvironment(args, runner.env ?? {}, platform, provider === "codex" ? "powershell" : "cmd"); } function commandWithEnvironment( args: string[], environment: Readonly>, - platform: NodeJS.Platform + platform: NodeJS.Platform, + windowsShell: "cmd" | "powershell" = "cmd" ): string { const entries = Object.entries(environment); if (platform === "win32") { + if (windowsShell === "powershell") { + const prefix = entries.map(([key, value]) => `$env:${key}=${powershellQuote(value)}`).join("; "); + const command = `& ${args.map(powershellQuote).join(" ")}; exit $LASTEXITCODE`; + return `${prefix ? `${prefix}; ` : ""}${command}`; + } const prefix = entries.map(([key, value]) => ( `set "${key}=${value.replaceAll("%", "%%").replaceAll('"', '\\"')}"` )).join(" && "); @@ -1161,6 +1170,10 @@ function shellQuote(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'`; } +function powershellQuote(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + function windowsCommand(args: string[]): string { return args.map((value) => `"${value.replaceAll("%", "%%").replaceAll('"', '\\"')}"`).join(" "); } diff --git a/src/main/services/terminalLaunch.ts b/src/main/services/terminalLaunch.ts index 20110f88c..c97d193a8 100644 --- a/src/main/services/terminalLaunch.ts +++ b/src/main/services/terminalLaunch.ts @@ -42,6 +42,8 @@ interface LaunchResolutionOptions { isolated?: boolean; /** The folder the CLI runs in (OpenCode's project configuration is read from it). */ cwd?: string; + initialPrompt?: string; + initialImagePath?: string; } const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -100,7 +102,9 @@ export function resolveTerminalLaunch( : agentBrowserArgs), ...providerModelArguments(provider, options.model), ...providerEffortArguments(provider, options.effort), - ...(options.resumePrevious ? resolveResumeArguments(provider, options.resumeThreadId) : []) + ...(options.resumePrevious ? resolveResumeArguments(provider, options.resumeThreadId) : []), + ...(provider === "codex" && options.initialPrompt && options.initialImagePath + ? [options.initialPrompt.replace(/[\r\n]+/g, " "), "--image", options.initialImagePath] : []) ]; const combinedEnvironment = { ...providerCli.environment, diff --git a/src/preload/index.ts b/src/preload/index.ts index 788211317..0e43f24e4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -60,6 +60,15 @@ ipcRenderer.on(IPC.windowOpenUpdates, () => { }); const api: CanvasTTYApi = { + mascots: { + list: () => ipcRenderer.invoke(IPC.mascotsList), + start: (image) => ipcRenderer.invoke(IPC.mascotsStart, image), + link: (projectId, sessionId) => ipcRenderer.invoke(IPC.mascotsLink, projectId, sessionId), + fail: (projectId, message) => ipcRenderer.invoke(IPC.mascotsFail, projectId, message), + retry: (projectId) => ipcRenderer.invoke(IPC.mascotsRetry, projectId), + openLog: (projectId) => ipcRenderer.invoke(IPC.mascotsOpenLog, projectId), + onChanged: (listener) => subscribe(IPC.mascotsChanged, listener) + }, diagnostics: { configuration: () => ipcRenderer.invoke(IPC.diagnosticsConfiguration), send: (description, attachment) => ipcRenderer.invoke(IPC.diagnosticsSend, description, attachment), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a494320a4..c858e7a88 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -14,6 +14,7 @@ import type { HomeGridSize, HomeWidgetPlacement, InstalledPlugin, + MascotProjectSummary, LaunchProfileId, MaterialsAddResult, LaunchRole, @@ -303,6 +304,20 @@ export function App(): React.JSX.Element { const [agentAvailability, setAgentAvailability] = useState(null); const [mediaData, setMediaData] = useState(null); const [plugins, setPlugins] = useState([]); + const [mascotProjects, setMascotProjects] = useState([]); + + useEffect(() => { + let active = true; + const unsubscribe = window.canvasTTY.mascots.onChanged((projects) => { + if (!active) return; + setMascotProjects(projects); + if (projects.some((project) => project.status === "ready")) { + void window.canvasTTY.plugins.list().then((installed) => { if (active) setPlugins(installed); }).catch(() => undefined); + } + }); + void window.canvasTTY.mascots.list().then((projects) => { if (active) setMascotProjects(projects); }).catch(() => undefined); + return () => { active = false; unsubscribe(); }; + }, []); const [browser, setBrowser] = useState(EMPTY_BROWSER_SNAPSHOT); // The camera lives in a store, not in state: a pan or zoom must not render the application tree. const [cameraStore] = useState(() => createCameraStore(homeCamera(DEFAULT_HOME_GRID_SIZE))); @@ -597,7 +612,8 @@ export function App(): React.JSX.Element { requestedCenter?: Point, role: LaunchRole = "agent", launchOptions?: Record, - environment?: SessionEnvironmentChoice + environment?: SessionEnvironmentChoice, + initial?: { initialPrompt: string; initialImagePath: string } ): Promise => { const currentSettings = settingsRef.current; const pixelSkin = isPixelSkinThemeId(currentSettings.terminalBorderSkin) @@ -626,7 +642,8 @@ export function App(): React.JSX.Element { try { const session = await window.canvasTTY.terminal.create({ provider, profile, cwd, position, role, ...(launchOptions ? { launchOptions } : {}), - ...(environment ? { environment } : {}) + ...(environment ? { environment } : {}), + ...(initial ?? {}) }); const sizedSession = pixelSkin ? { ...session, size: { ...cardSize } } : session; if (pixelSkin) window.canvasTTY.terminal.setBounds(session.id, { position, size: sizedSession.size }); @@ -1038,6 +1055,26 @@ export function App(): React.JSX.Element { setCamera(focusCamera(browserCanvas.position, browserCanvas.size)); }, [persistSettings, sessions.length, settings.homeGridSize, settings.locale, settings.pluginCanvas.length]); + const createMascot = useCallback(async (file: File): Promise => { + if (!agentAvailability?.codex) throw new Error(settings.locale === "ru" ? "Codex CLI недоступен." : "Codex CLI is unavailable."); + // The creation button explicitly describes its YOLO launch to the person. + await acknowledgeDanger("codex"); + if (!settingsRef.current.browserAgentAccess) await saveSettings({ browserAgentAccess: true }); + const launch = await window.canvasTTY.mascots.start(new Uint8Array(await file.arrayBuffer())); + try { + const session = await createSession("codex", "yolo", launch.cwd, undefined, "agent", undefined, undefined, + { initialPrompt: launch.prompt, initialImagePath: launch.imagePath }); + if (session.status === "failed") throw new Error(session.failureDetails ?? "Codex did not start."); + await window.canvasTTY.mascots.link(launch.project.id, session.id); + setSettingsOpen(false); + } catch (cause) { + await window.canvasTTY.mascots.fail(launch.project.id, cause instanceof Error ? cause.message : String(cause)); + throw cause; + } + try { await openBrowser(launch.previewUrl); } + catch (cause) { showToast(cause instanceof Error ? cause.message : String(cause)); } + }, [agentAvailability?.codex, acknowledgeDanger, createSession, openBrowser, saveSettings, settings.locale, showToast]); + useEffect(() => { return window.canvasTTY.plugins.onBrowserOpenRequested((request) => { void pluginBrowserOpenQueueRef.current.enqueue(() => openBrowser(request.url)).then( @@ -1390,15 +1427,34 @@ export function App(): React.JSX.Element { await openPluginCanvasContribution(plugin, contribution); }, [openPluginCanvasContribution, toggleHomeWidget]); - useEffect(() => window.canvasTTY.plugins.onOpenCanvas((request) => { - const plugin = plugins.find((candidate) => candidate.manifest.id === request.pluginId); - const contribution = plugin?.manifest.contributions.find((candidate) => candidate.id === request.contributionId); - if (!plugin || !contribution || contribution.kind !== "canvas-app" || !plugin.enabled) { - showToast(t(settings.locale, "pluginActionFailed")); + const openMascot = useCallback(async (project: MascotProjectSummary): Promise => { + if (project.status === "ready" && project.pluginId) { + const installed = await window.canvasTTY.plugins.list(); + setPlugins(installed); + const plugin = installed.find((item) => item.manifest.id === project.pluginId); + const contribution = plugin?.manifest.contributions.find((item) => item.kind === "canvas-app"); + if (!plugin || !contribution) throw new Error("Mascot plugin is unavailable."); + await openPluginCanvasContribution(plugin, contribution); return; } - void openPluginCanvasContribution(plugin, contribution, request.sourceCanvasInstanceId) - .catch((error) => showToast(error instanceof Error ? error.message : t(settings.locale, "pluginActionFailed"))); + const session = sessionsRef.current.find((item) => item.id === project.sessionId); + if (!session) throw new Error(settings.locale === "ru" ? "Чат Codex больше не открыт." : "The Codex chat is no longer open."); + setSettingsOpen(false); + setActiveSessionId(session.id); + isHomeCamera.current = false; + setCamera(focusCamera(session.position, session.size)); + }, [openPluginCanvasContribution, settings.locale]); + + useEffect(() => window.canvasTTY.plugins.onOpenCanvas((request) => { + void (async () => { + const installed = plugins.find((candidate) => candidate.manifest.id === request.pluginId) + ? plugins : await window.canvasTTY.plugins.list(); + if (installed !== plugins) setPlugins(installed); + const plugin = installed.find((candidate) => candidate.manifest.id === request.pluginId); + const contribution = plugin?.manifest.contributions.find((candidate) => candidate.id === request.contributionId); + if (!plugin || !contribution || contribution.kind !== "canvas-app" || !plugin.enabled) throw new Error(t(settings.locale, "pluginActionFailed")); + await openPluginCanvasContribution(plugin, contribution, request.sourceCanvasInstanceId); + })().catch((error) => showToast(error instanceof Error ? error.message : t(settings.locale, "pluginActionFailed"))); }), [openPluginCanvasContribution, plugins, settings.locale, showToast]); const startHomeEditor = useCallback((): void => { @@ -1666,6 +1722,11 @@ export function App(): React.JSX.Element { onToggleHomeWidget={toggleHomeWidget} onEditHome={startHomeEditor} onOpenBrowser={openBrowser} + onCreateMascot={createMascot} + mascotProjects={mascotProjects} + onOpenMascot={openMascot} + onRetryMascot={(projectId) => window.canvasTTY.mascots.retry(projectId)} + onOpenMascotLog={(projectId) => window.canvasTTY.mascots.openLog(projectId)} /> {closedGitRisks.length > 0 && ( diff --git a/src/renderer/src/features/plugins/PluginCanvasCard.tsx b/src/renderer/src/features/plugins/PluginCanvasCard.tsx index a85807bd2..57303aba5 100644 --- a/src/renderer/src/features/plugins/PluginCanvasCard.tsx +++ b/src/renderer/src/features/plugins/PluginCanvasCard.tsx @@ -15,7 +15,7 @@ import type { import { UiIcon } from "../../components/UiIcon"; import { t } from "../../lib/i18n"; import { snapMove, snapResize, type ResizeDirection } from "../workspace/snap"; -import { constrainPluginResize } from "./pluginBounds"; +import { constrainMascotResize, constrainPluginResize, fitMascotBounds } from "./pluginBounds"; import { PluginFrame } from "./PluginFrame"; import type { PluginCanvasWheelInput } from "./pluginInputBridge"; import { pluginCanvasWidgetId } from "../workspace/canvasWidgetFocus"; @@ -89,37 +89,36 @@ export function PluginCanvasCard({ hidden = false }: PluginCanvasCardProps): React.JSX.Element { const dragState = useRef(null); + const mascotDragState = useRef(null); const resizeState = useRef(null); - const initialBounds = constrainPluginResize( - { position: instance.position, size: instance.size }, - "se", - contribution.minSize - ); + const isMascot = plugin.sourceUrl.startsWith("mascot:"); + const mascotAspectRatio = contribution.defaultSize.width / contribution.defaultSize.height; + const initialBounds = isMascot + ? fitMascotBounds({ position: instance.position, size: instance.size }, mascotAspectRatio) + : constrainPluginResize({ position: instance.position, size: instance.size }, "se", contribution.minSize); const [position, setPosition] = useState(initialBounds.position); const [size, setSize] = useState(initialBounds.size); const liveBounds = useRef(initialBounds); // Renders when the summary scale changes, not on every camera move. const summaryScale = useCameraSelector(camera, (current) => summaryScaleForZoom(current.zoom)); - const summaryMode = summaryScale > 1; + const summaryMode = !isMascot && summaryScale > 1; // The plugin document is suspended whenever nobody can see it: its timers drop to one wake-up a second // and its animation frames wait, but the document (and all its state) stays loaded. const offscreen = useSurfaceOffscreen(camera, { position, size }); const windowHidden = useWindowHidden(); const lifecycle = surfaceLifecycle({ summary: summaryMode, hidden, offscreen, windowHidden }); - useEffect(() => { - const bounds = constrainPluginResize( - { position: instance.position, size: instance.size }, - "se", - contribution.minSize - ); + const bounds = isMascot + ? fitMascotBounds({ position: instance.position, size: instance.size }, mascotAspectRatio) + : constrainPluginResize({ position: instance.position, size: instance.size }, "se", contribution.minSize); liveBounds.current = bounds; setPosition(bounds.position); setSize(bounds.size); - if (bounds.size.width !== instance.size.width || bounds.size.height !== instance.size.height) { + if (bounds.size.width !== instance.size.width || bounds.size.height !== instance.size.height + || bounds.position.x !== instance.position.x || bounds.position.y !== instance.position.y) { onBoundsChange(instance.id, bounds); } - }, [contribution.minSize, instance.id, instance.position, instance.size, onBoundsChange]); + }, [contribution.minSize, instance.id, instance.position, instance.size, isMascot, mascotAspectRatio, onBoundsChange]); const startDrag = (event: React.PointerEvent): void => { if ((event.target as HTMLElement).closest("button")) return; @@ -199,8 +198,17 @@ export function PluginCanvasCard({ - (state.direction.includes("n") ? deltaY : 0) } }; - const constrained = constrainPluginResize(raw, state.direction, contribution.minSize); - applyBounds(snapEnabled ? snapResize(constrained, state.direction, state.snapTargets) : constrained); + const constrained = isMascot + ? constrainMascotResize(raw, state.direction, state.startBounds.size, mascotAspectRatio) + : constrainPluginResize(raw, state.direction, contribution.minSize); + const snapped = snapEnabled + ? snapResize(constrained, state.direction, state.snapTargets, isMascot + ? { min: { width: 128, height: 140 }, max: { width: 1_600, height: 1_100 } } + : undefined) + : constrained; + applyBounds(isMascot + ? constrainMascotResize(snapped, state.direction, constrained.size, mascotAspectRatio) + : snapped); }; const endResize = (event: React.PointerEvent): void => { @@ -217,9 +225,37 @@ export function PluginCanvasCard({ setSize(bounds.size); }; + const dragMascot = (event: { phase: "start" | "move" | "end"; x: number; y: number; pointerId: number }): void => { + if (event.phase === "start") { + onWidgetFocus(); + mascotDragState.current = { + pointerId: event.pointerId, + startClient: { x: event.x, y: event.y }, + startBounds: liveBounds.current, + snapTargets: snapEnabled ? getSnapTargets() : [] + }; + return; + } + const state = mascotDragState.current; + if (!state || state.pointerId !== event.pointerId) return; + if (event.phase === "end") { + mascotDragState.current = null; + onBoundsChange(instance.id, liveBounds.current); + return; + } + const rawPosition = { + x: state.startBounds.position.x + (event.x - state.startClient.x) / camera.get().zoom, + y: state.startBounds.position.y + (event.y - state.startClient.y) / camera.get().zoom + }; + applyBounds({ + position: snapEnabled ? snapMove(rawPosition, state.startBounds.size, state.snapTargets) : rawPosition, + size: state.startBounds.size + }); + }; + return (
} + {project.limitations?.map((item, index) => {item})} + + {(project.error || project.status === "failed") && } + + )} + } + {listError &&

{listError}

} + + + {open && createPortal(
{ + if (event.target === event.currentTarget && !busy) setOpen(false); + }}> +
+
+

{ru ? "Новый маскот" : "New mascot"}

+ +
+
+
+
+

{ru ? "Создайте маскота с помощью Codex" : "Create a mascot with Codex"}

+
    +
  1. {ru ? "Загрузите PNG или JPG/JPEG персонажа с фоном или без. Codex обработает фото, подготовит чёткий рисунок в полный рост на прозрачном фоне и покажет его для одобрения." : "Upload a character PNG or JPG/JPEG with or without a background. Codex processes the image and prepares a clear full-body drawing on transparency for your approval."}
  2. +
  3. {ru ? "В чате выберите: сохранить внешность или сначала изменить её. Затем задайте имя, позы и анимации. Можно добавлять предметы и сцены." : "In the chat, keep or revise the appearance. Then choose a name, poses, and animations. You can add objects and scenes."}
  4. +
  5. {ru ? "Кадры и анимации будут видны в браузере CanvasTTY во время работы. Готовый маскот появится на канвасе после вашего отдельного согласия на установку." : "Frames and animations appear in the CanvasTTY browser while you work. The finished mascot is installed on the canvas only after your separate approval."}
  6. +
+
+
+ {preview ? {ru : {ru ? "Здесь появится персонаж" : "Your character appears here"}} +
{file?.name ?? (ru ? "Исходное изображение" : "Source image")}
+
+
+ +

{ru + ? "Кнопка запускает Codex в режиме YOLO (обход): полный доступ к файлам и командам без запросов разрешения, с управлением браузером CanvasTTY. Оригинал сохраняется отдельно; внешний вид утверждается перед анимацией." + : "This button starts Codex in YOLO (bypass): full file and command access without permission prompts, with CanvasTTY browser control enabled. The original is preserved; appearance is approved before animation."}

+
+
+ {error ?? (file ? file.name : (ru ? "Изображение не выбрано" : "No image selected"))} + +
+
+
, document.body)} + ; +} diff --git a/src/renderer/src/features/settings/SettingsPanel.tsx b/src/renderer/src/features/settings/SettingsPanel.tsx index cdd3c55e8..db4619189 100644 --- a/src/renderer/src/features/settings/SettingsPanel.tsx +++ b/src/renderer/src/features/settings/SettingsPanel.tsx @@ -24,6 +24,7 @@ import type { HomeAccentPresetId, InstalledPlugin, LimitProviderId, + MascotProjectSummary, LocaleId, KeyboardPreset, MaterialStorageUsage, @@ -118,8 +119,9 @@ import { isolationAvailable, resolveDefaultLaunchProfile } from "../../../../shared/autoMode"; +import { MascotsSettings } from "./MascotsSettings"; -type SettingsSection = "general" | "keyboardShortcuts" | "appearance" | "agents" | "controls" | "externalIntegrations" | "browser" | "plugins" | "updates" | "about"; +type SettingsSection = "general" | "keyboardShortcuts" | "appearance" | "agents" | "controls" | "externalIntegrations" | "browser" | "plugins" | "mascots" | "updates" | "about"; const SHORTCUT_LABELS = { home: "homeShortcut", renameWindow: "renameWindow", toggleFullscreen: "toggleFullscreen", @@ -143,6 +145,7 @@ const SETTINGS_SECTIONS: ReadonlyArray<{ { id: "externalIntegrations", icon: "blocks" }, { id: "browser", icon: "browser" }, { id: "plugins", icon: "blocks" }, + { id: "mascots", icon: "image-plus" }, { id: "updates", icon: "download" }, { id: "about", icon: "info" } ]; @@ -195,6 +198,11 @@ interface SettingsPanelProps { onToggleHomeWidget(widgetId: string, size: PluginGridSize): Promise; onEditHome(): void; onOpenBrowser(url?: string): Promise; + onCreateMascot(file: File): Promise; + mascotProjects: MascotProjectSummary[]; + onOpenMascot(project: MascotProjectSummary): Promise; + onRetryMascot(projectId: string): Promise; + onOpenMascotLog(projectId: string): Promise; } export function SettingsPanel({ @@ -226,6 +234,11 @@ export function SettingsPanel({ onToggleHomeWidget, onEditHome, onOpenBrowser, + onCreateMascot, + mascotProjects, + onOpenMascot, + onRetryMascot, + onOpenMascotLog, }: SettingsPanelProps): React.JSX.Element { const locale = settings.locale; const appearance = resolveAppearanceSettings(settings); @@ -1358,6 +1371,10 @@ export function SettingsPanel({ /> )} + {section === "mascots" && ( + + )} + {section === "updates" && ( )} diff --git a/src/renderer/src/lib/i18n.ts b/src/renderer/src/lib/i18n.ts index 00047166b..361e514bb 100644 --- a/src/renderer/src/lib/i18n.ts +++ b/src/renderer/src/lib/i18n.ts @@ -189,6 +189,7 @@ const ru = { controls: "Управление", externalIntegrations: "Внешние интеграции", plugins: "Плагины", + mascots: "Маскоты", installPlugin: "Установить плагин", githubUrl: "Ссылка на GitHub-репозиторий", inspectPlugin: "Проверить", @@ -1140,6 +1141,7 @@ const en: Record = { controls: "Controls", externalIntegrations: "External integrations", plugins: "Plugins", + mascots: "Mascots", installPlugin: "Install plugin", githubUrl: "GitHub repository URL", inspectPlugin: "Inspect", diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 05ff2573b..532c319a9 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -10,6 +10,7 @@ import "./styles/terminalSkins.css"; import "./styles/ornateTerminalSkins.css"; import "./styles/pixelTerminalSkins.css"; import "./styles/pixelSkinPackCreator.css"; +import "./styles/mascots.css"; import "./styles/appSkins.css"; import "./styles/patterns.css"; diff --git a/src/renderer/src/styles/mascots.css b/src/renderer/src/styles/mascots.css new file mode 100644 index 000000000..85eeaae75 --- /dev/null +++ b/src/renderer/src/styles/mascots.css @@ -0,0 +1,33 @@ +.settings-tabs { overflow-y: auto; scrollbar-width: thin; } +.mascot-settings { padding-top: 1.1em; } +.mascot-settings > .setting-group__description { margin-bottom: 1.3em; } +.mascot-settings__list { display: grid; gap: 9px; margin-bottom: 16px; } +.mascot-settings__item { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px; border: 1px solid color-mix(in srgb, var(--text) 17%, transparent); border-radius: 6px; background: var(--surface-soft); } +.mascot-settings__item span { min-width: 0; display: grid; gap: 4px; } +.mascot-settings__item strong, .mascot-settings__item small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.mascot-settings__item small { color: var(--text-muted-on-dark); } +.mascot-settings__item button { flex: none; padding: 8px 12px; border-radius: 4px; color: var(--text); background: color-mix(in srgb, var(--primary) 22%, var(--surface-soft)); cursor: pointer; } +.mascot-settings__item button:disabled { opacity: .48; cursor: not-allowed; } +.mascot-settings .pixel-pack-create { width: 100%; } +.mascot-dialog { max-height: min(650px, calc(100vh - 32px)); } +.mascot-dialog__guide { grid-template-columns: minmax(0, 1fr) 220px; } +.mascot-dialog__preview { min-width: 0; } +.mascot-dialog__preview img, .mascot-dialog__preview > span { width: 100%; height: 190px; display: grid; place-content: center; gap: 10px; object-fit: contain; background: #141419; border-radius: 4px; } +.mascot-dialog__preview > span { justify-items: center; color: var(--text-muted-on-dark); font-size: 11px; text-align: center; } +.mascot-dialog__preview figcaption { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.mascot-dialog__upload { position: relative; min-height: 96px; margin-bottom: 12px; display: grid; place-content: center; justify-items: center; gap: 8px; padding: 12px; border: 1px dashed color-mix(in srgb, var(--primary) 65%, transparent); border-radius: 6px; background: var(--surface-soft); cursor: pointer; text-align: center; } +.mascot-dialog__upload:hover, .mascot-dialog__upload:focus-within { border-style: solid; } +.mascot-dialog__upload input { position: absolute; width: 1px; height: 1px; opacity: 0; } +@media (max-width: 650px) { .mascot-dialog__guide { grid-template-columns: 1fr; } .mascot-dialog__preview { max-width: 220px; } } +.plugin-canvas-card--mascot { overflow: visible; border: 0; border-radius: 0; background: transparent; box-shadow: none; } +.plugin-canvas-card--mascot.plugin-canvas-card--selected { box-shadow: none; } +.plugin-canvas-card--mascot .plugin-canvas-card__header { position: absolute; z-index: 2; top: 8px; right: 8px; width: 34px; height: 34px; padding: 0; background: transparent; cursor: default; } +.plugin-canvas-card--mascot .plugin-canvas-card__header > span { display: none; } +.plugin-canvas-card--mascot .plugin-canvas-card__header button { width: 34px; height: 34px; opacity: 0; background: var(--surface-soft); } +.plugin-canvas-card--mascot:hover .plugin-canvas-card__header button, .plugin-canvas-card--mascot .plugin-canvas-card__header button:focus-visible { opacity: 1; } +.plugin-canvas-card--mascot .plugin-canvas-card__frame { height: 100%; background: transparent; } +.plugin-canvas-card--mascot .plugin-canvas-card__summary { inset: 0; height: 100%; background: var(--surface-soft); } +.plugin-canvas-card--mascot .terminal-card__resize-handle--se { z-index: 21; width: 24px; height: 24px; border-radius: 7px 0 0; background: #27242ecc; opacity: 0; transition: opacity .15s ease; } +.plugin-canvas-card--mascot .terminal-card__resize-handle--se::after { content: ""; position: absolute; right: 6px; bottom: 6px; width: 9px; height: 9px; border-right: 2px solid #f5f2f7; border-bottom: 2px solid #f5f2f7; pointer-events: none; } +.plugin-canvas-card--mascot:hover .terminal-card__resize-handle--se { opacity: 1; } +@media (hover: none) { .plugin-canvas-card--mascot .terminal-card__resize-handle--se { opacity: 1; } } diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 6a02a2246..3ee2b4082 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -531,6 +531,33 @@ export interface CreateSessionRequest { model?: string; /** The CLI's reasoning effort for this launch; only the levels that CLI takes. */ effort?: ReasoningEffort; + /** One first turn for a new Codex chat. The image must already exist inside cwd. */ + initialPrompt?: string; + initialImagePath?: string; +} + +export interface MascotProjectSummary { + id: string; + name: string; + status: "creating" | "ready" | "failed"; + sessionId?: string; + pluginId?: string; + error?: string; + errorStage?: string; + errorLogPath?: string; + requestedBuildId?: string; + installedBuildId?: string; + registered?: boolean; + delivery?: "draft" | "final"; + limitations?: string[]; +} + +export interface MascotLaunch { + project: MascotProjectSummary; + cwd: string; + imagePath: string; + prompt: string; + previewUrl: string; } /** Every non-terminal agent that CanvasTTY can install and resolve. */ @@ -1665,6 +1692,15 @@ export interface DiagnosticRendererError { } export interface CanvasTTYApi { + mascots: { + list(): Promise; + start(image: Uint8Array): Promise; + link(projectId: string, sessionId: string): Promise; + fail(projectId: string, message: string): Promise; + retry(projectId: string): Promise; + openLog(projectId: string): Promise; + onChanged(listener: (projects: MascotProjectSummary[]) => void): () => void; + }; diagnostics: { configuration(): Promise; send(description: string, attachment?: DiagnosticAttachment): Promise; @@ -1873,6 +1909,13 @@ export interface CanvasTTYApi { } export const IPC = { + mascotsList: "mascots:list", + mascotsStart: "mascots:start", + mascotsLink: "mascots:link", + mascotsFail: "mascots:fail", + mascotsRetry: "mascots:retry", + mascotsOpenLog: "mascots:open-log", + mascotsChanged: "mascots:changed", diagnosticsConfiguration: "diagnostics:configuration", diagnosticsSend: "diagnostics:send", diagnosticsRendererError: "diagnostics:renderer-error", diff --git a/tests/agent-runtime-provider-launch.test.mjs b/tests/agent-runtime-provider-launch.test.mjs index 5b7f992d0..c56a310b2 100644 --- a/tests/agent-runtime-provider-launch.test.mjs +++ b/tests/agent-runtime-provider-launch.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -63,6 +64,50 @@ test("Claude and Codex receive automatic lifecycle hooks without prompt or respo ); }); +test("Codex Windows hook preserves PowerShell environment, stdin, and exit code", { skip: process.platform !== "win32" }, () => { + const script = "let input='';process.stdin.setEncoding('utf8');process.stdin.on('data',chunk=>input+=chunk);process.stdin.on('end',()=>{process.stdout.write(JSON.stringify({input,env:process.env.ELECTRON_RUN_AS_NODE,args:process.argv.slice(1)}));process.exit(7)})"; + const args = codexLifecycleArgs({ command: process.execPath, args: ["-e", script], env: { ELECTRON_RUN_AS_NODE: "1" } }, "win32"); + const stop = args.find((value) => value.startsWith("hooks.Stop=")); + const encoded = stop?.match(/command=("(?:\\.|[^"\\])*"),timeout=/u)?.[1]; + assert.ok(encoded); + const command = JSON.parse(encoded); + assert.match(command, /^\$env:ELECTRON_RUN_AS_NODE='1'; & /u); + const result = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], { + input: "hook stdin", + encoding: "utf8" + }); + assert.ifError(result.error); + assert.equal(result.status, 7, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), { input: "hook stdin", env: "1", args: ["idle", "Stop"] }); +}); + +test("Windows Codex plugin and decision hooks use PowerShell without changing Claude hooks", async (t) => { + const root = await fixture(t); + const adapters = new ProviderRuntimeLaunchAdapters({ + ...runtimeOptionsFor(root), + platform: "win32", + helper: { command: process.execPath, args: [join(root, "helper.mjs")], env: { ELECTRON_RUN_AS_NODE: "1" } }, + permissionGate: { command: process.execPath, args: [join(root, "gate.mjs")], env: { ELECTRON_RUN_AS_NODE: "1" } }, + pluginHooks: { + runner: { command: process.execPath, args: [join(root, "runner.mjs")], env: { ELECTRON_RUN_AS_NODE: "1" } }, + registryPath: join(root, "registry.json"), + list: (provider) => provider === "codex" ? [{ key: "example:hook", events: ["prompt-submit"] }] : [] + } + }); + const codex = adapters.prepare("codex", "session-codex", true, true); + const commands = [...codex.args.join("\n").matchAll(/command=("(?:\\.|[^"\\])*"),timeout=/gu)].map((match) => JSON.parse(match[1])); + assert.ok(commands.some((command) => command.includes("helper.mjs"))); + assert.ok(commands.some((command) => command.includes("runner.mjs"))); + assert.ok(commands.some((command) => command.includes("gate.mjs"))); + for (const command of commands) assert.match(command, /^\$env:ELECTRON_RUN_AS_NODE='1'; /u); + + const claude = adapters.prepare("claude", "session-claude", true, true); + const settings = JSON.parse(claude.args[1]); + assert.match(settings.hooks.Stop[0].hooks[0].command, /^set "ELECTRON_RUN_AS_NODE=1" && /u); + codex.releaseConfiguration(); + claude.releaseConfiguration(); +}); + test("helper process flags stay scoped to hook commands instead of the agent PTY", async (t) => { const root = await fixture(t); const launch = adaptersFor(root).prepare("codex", "session-codex"); diff --git a/tests/canvas-stacking.test.mjs b/tests/canvas-stacking.test.mjs index 91e90ab53..9aac13602 100644 --- a/tests/canvas-stacking.test.mjs +++ b/tests/canvas-stacking.test.mjs @@ -142,6 +142,8 @@ test("snap targets are built only when a card's drag starts, from the layout of assert.doesNotMatch(source, /allWindowBounds\.filter\(/u, "no per-card filter in render"); for (const card of ["terminal/TerminalCard", "plugins/PluginCanvasCard", "browser/BrowserCard", "notes/StickyNoteCard"]) { const text = await readFile(new URL(`../src/renderer/src/features/${card}.tsx`, import.meta.url), "utf8"); - assert.equal((text.match(/snapTargets: snapEnabled \? getSnapTargets\(\) : \[\]/gu) ?? []).length, 2, `${card} takes its targets at drag and resize start`); + const starts = card === "plugins/PluginCanvasCard" ? 3 : 2; + assert.equal((text.match(/snapTargets: snapEnabled \? getSnapTargets\(\) : \[\]/gu) ?? []).length, starts, + `${card} takes its targets only when header/mascot drag or resize starts`); } }); diff --git a/tests/mascot-installation.test.mjs b/tests/mascot-installation.test.mjs new file mode 100644 index 000000000..e2bf4d42e --- /dev/null +++ b/tests/mascot-installation.test.mjs @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp, mkdir, readFile, writeFile, rm } from "node:fs/promises"; +import * as fs from "node:fs/promises"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import { promisify } from "node:util"; +import vm from "node:vm"; +import { transformSync } from "esbuild"; + +test("retries corrected and explicit mascot handoffs without restart, retains diagnostics and registered state", async () => { + const root = await mkdtemp(path.join(tmpdir(), "mascot-retry-")); + const id = "11111111-1111-4111-8111-111111111111"; + const project = path.join(root, "mascots", id); + const plugin = path.join(project, "plugin"); + let attempts = 0; + let reject = true; + let cardFailure = false; + let duringVerification; + const execFile = () => {}; + execFile[promisify.custom] = async (_command, args) => { + if (args.includes("-c")) return { stdout: path.join(root, "python.exe") }; + attempts++; + if (duringVerification) await duringVerification(); + if (reject) throw Object.assign(new Error("Command failed: " + "long/path/".repeat(200)), { + stderr: "Traceback\nValueError: current build lacks visual evidence\n", stdout: "validation output" + }); + return { stdout: "verified" }; + }; + const code = transformSync(await readFile(new URL("../src/main/services/MascotManager.ts", import.meta.url), "utf8"), { + loader: "ts", format: "cjs", target: "es2022" + }).code; + const exports = {}; + const module = { exports }; + vm.runInNewContext(code, { exports, module, process, Buffer, console, setInterval, clearInterval, setTimeout, clearTimeout, + require: (name) => { + if (name === "node:fs/promises") return fs; + if (name === "node:path") return path; + if (name === "node:util") return { promisify }; + if (name === "node:os") return { homedir: () => root }; + if (name === "node:crypto") return {}; + if (name === "node:child_process") return { execFile }; + if (name === "electron") return {}; + if (name.endsWith("path-inside.mjs")) return { isPathInside: (parent, child) => !path.relative(parent, child).startsWith("..") }; + throw new Error(`Unexpected import: ${name}`); + } + }); + const registry = []; + const manager = new module.exports.MascotManager(root, root, { + list: () => registry, + installLocalMascot: async () => { + const installed = { sourceUrl: `mascot:${id}`, manifest: { id: "fixture", contributions: [{ id: "character" }] } }; + registry.splice(0, registry.length, installed); + return installed; + } + }, () => {}, () => { if (cardFailure) throw new Error("Card unavailable"); }); + try { + await mkdir(plugin, { recursive: true }); + manager.records.set(id, { id, name: "Fixture", createdAt: 1, status: "creating" }); + await writeFile(path.join(project, "character.json"), JSON.stringify({ id: "fixture", name: "Fixture", plugin_dir: "plugin", deck: ["wave"] })); + await writeFile(path.join(project, "review.json"), JSON.stringify({ limitations: [] })); + const result = { schemaVersion: 1, status: "ready_for_host", installationApproved: true, buildId: "first", + id: "fixture", name: "Fixture", pluginDirectory: plugin, actions: ["wave"], presentation: { transparentCard: true, resizable: true } }; + const save = async () => { + await writeFile(path.join(project, "mascot-result.json"), JSON.stringify(result)); + await writeFile(path.join(project, "build-report.json"), JSON.stringify({ build_id: result.buildId })); + }; + await save(); + await manager.checkResults(); + assert.equal(manager.list()[0].status, "failed"); + assert.match(manager.list()[0].error, /current build lacks visual evidence/); + assert.match(await readFile(manager.list()[0].errorLogPath, "utf8"), /long\/path\/.*validation output/s); + await manager.checkResults(); + assert.equal(attempts, 1, "unchanged rejection must not be retried automatically"); + result.buildId = "corrected"; + reject = false; + await save(); + await manager.checkResults(); + assert.equal(manager.list()[0].installedBuildId, "corrected"); + assert.equal(manager.list()[0].registered, true); + result.buildId = "update"; + reject = true; + await save(); + await manager.checkResults(); + assert.equal(manager.list()[0].status, "ready", "failed update preserves registered version"); + assert.equal(manager.list()[0].installedBuildId, "corrected"); + reject = false; + cardFailure = true; + await manager.retry(id); + assert.equal(manager.list()[0].installedBuildId, "update"); + assert.equal(manager.list()[0].errorStage, "card load"); + cardFailure = false; + await manager.retry(id); + assert.equal(manager.list()[0].error, undefined); + result.buildId = "queued"; + reject = true; + duringVerification = () => manager.retry(id); + await save(); + await manager.checkResults(); + duringVerification = undefined; + reject = false; + await manager.checkResults(); + assert.equal(manager.list()[0].installedBuildId, "queued", "retry during a check remains queued for the next poll"); + } finally { + manager.dispose(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/plugin-bounds.test.mjs b/tests/plugin-bounds.test.mjs index d700d7f97..4f6c44800 100644 --- a/tests/plugin-bounds.test.mjs +++ b/tests/plugin-bounds.test.mjs @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { constrainPluginResize } from "../src/renderer/src/features/plugins/pluginBounds.ts"; +import { constrainMascotResize, constrainPluginResize, fitMascotBounds } from "../src/renderer/src/features/plugins/pluginBounds.ts"; +import { snapResize } from "../src/renderer/src/features/workspace/snap.ts"; test("uses the platform minimum for older plugin manifests", () => { assert.deepEqual(constrainPluginResize({ @@ -21,3 +22,33 @@ test("respects a contribution-specific minimum and preserves north-west edges", size: { width: 280, height: 160 } }); }); + +test("fits a saved wide mascot card and keeps its proportions while resizing", () => { + const ratio = 320 / 520; + const fitted = fitMascotBounds({ position: { x: 100, y: 50 }, size: { width: 420, height: 260 } }, ratio); + assert.deepEqual(fitted, { position: { x: 230, y: 50 }, size: { width: 160, height: 260 } }); + assert.deepEqual(constrainMascotResize({ + position: fitted.position, + size: { width: 220, height: 260 } + }, "se", fitted.size, ratio), { + position: fitted.position, + size: { width: 220, height: 358 } + }); + assert.deepEqual(constrainMascotResize({ + position: fitted.position, + size: { width: 160, height: 180 } + }, "se", fitted.size, ratio), { + position: fitted.position, + size: { width: 128, height: 208 } + }); +}); + +test("snapping does not restore the regular card minimum for a mascot", () => { + const ratio = 0.5; + const requested = constrainMascotResize({ position: { x: 0, y: 0 }, size: { width: 180, height: 360 } }, "se", { width: 260, height: 520 }, ratio); + const snapped = snapResize(requested, "se", [], { + min: { width: 128, height: 140 }, + max: { width: 1_600, height: 1_100 } + }); + assert.deepEqual(constrainMascotResize(snapped, "se", requested.size, ratio).size, { width: 180, height: 360 }); +}); diff --git a/tests/plugin-frame-visibility.test.mjs b/tests/plugin-frame-visibility.test.mjs index b80ffc7ad..f43cd92a7 100644 --- a/tests/plugin-frame-visibility.test.mjs +++ b/tests/plugin-frame-visibility.test.mjs @@ -214,7 +214,7 @@ test("the card suspends its frame while HOME editing, zoomed to a summary, off-s let camera = { x: 0, y: 0, zoom: 1 }; const cameraStore = { get: () => camera, set() {}, subscribe: () => () => undefined }; const contribution = { id: "probe", kind: "canvas-app", title: "Probe", entry: "apps/probe.html", defaultSize: { width: 680, height: 440 } }; - const plugin = { manifest: { id: "com.example.probe", name: "Probe", permissions: [], contributions: [contribution] }, selectedModules: [], enabled: true }; + const plugin = { manifest: { id: "com.example.probe", name: "Probe", permissions: [], contributions: [contribution] }, sourceUrl: "https://github.com/example/probe", selectedModules: [], enabled: true }; const props = (overrides = {}) => ({ instance: { id: "p1", pluginId: plugin.manifest.id, contributionId: "probe", title: "Probe", position: { x: 100, y: 100 }, size: { width: 680, height: 440 } }, plugin, contribution, locale: "en", palette: "default", camera: cameraStore, stackIndex: 1, snapEnabled: false, @@ -237,6 +237,8 @@ test("the card suspends its frame while HOME editing, zoomed to a summary, off-s camera = { x: 0, y: 0, zoom: 0.3 }; assert.deepEqual(render(), { suspended: true, lifecycle: "suspended" }, "summary zoom"); + assert.deepEqual(render({ plugin: { ...plugin, sourceUrl: "mascot:fixture-project" } }), + { suspended: false, lifecycle: "visible" }, "a mascot remains visible at summary zoom"); camera = { x: -5000, y: 0, zoom: 1 }; assert.deepEqual(render(), { suspended: false, lifecycle: "visible" }, "a pan across is not a suspension yet"); diff --git a/tests/plugin-manager.test.mjs b/tests/plugin-manager.test.mjs index 9c91b93f3..6595174b3 100644 --- a/tests/plugin-manager.test.mjs +++ b/tests/plugin-manager.test.mjs @@ -72,6 +72,46 @@ const manifest = { settingsContribution: "notes" }; +test("updates only the same local mascot project and preserves its previous package", async () => { + const root = await mkdtemp(join(tmpdir(), "mascot-update-")); + const projectId = "11111111-1111-4111-8111-111111111111"; + const project = join(root, "mascots", projectId); + const source = join(project, "plugin"); + const manager = new PluginManager(root); + try { + await mkdir(source, { recursive: true }); + const mascot = { apiVersion: 1, id: "sample-mascot", name: "Sample Mascot", version: "1.0.0", + description: "Fixture mascot", permissions: [], contributions: [{ id: "character", kind: "canvas-app", + title: "Character", entry: "index.html", defaultSize: { width: 260, height: 520 } }] }; + await writeFile(join(source, "canvastty.plugin.json"), JSON.stringify(mascot)); + await writeFile(join(source, "index.html"), "

first build

"); + await manager.load(); + const destination = join(root, "plugins", mascot.id); + await mkdir(destination, { recursive: true }); + await writeFile(join(destination, "owner.txt"), "Unknown owner's files"); + await assert.rejects(manager.installLocalMascot(projectId, source, mascot.id), /Unregistered plugin directory/); + assert.equal(await readFile(join(destination, "owner.txt"), "utf8"), "Unknown owner's files"); + assert.equal(manager.list().length, 0); + await rm(destination, { recursive: true }); + const first = await manager.installLocalMascot(projectId, source, mascot.id); + await writeFile(join(source, "index.html"), "

second build

"); + await manager.installLocalMascot(projectId, source, mascot.id); + assert.equal(await readFile(join(root, "plugins", mascot.id, "index.html"), "utf8"), "

second build

"); + const revisions = await readdir(join(project, "revisions")); + assert.equal(revisions.length, 1); + assert.equal(await readFile(join(project, "revisions", revisions[0], "index.html"), "utf8"), "

first build

"); + assert.equal(manager.list()[0].sourceUrl, first.sourceUrl); + const otherId = "22222222-2222-4222-8222-222222222222"; + const other = join(root, "mascots", otherId, "plugin"); + await cp(source, other, { recursive: true }); + await assert.rejects(manager.installLocalMascot(otherId, other, mascot.id), /another source/); + assert.equal(await readFile(join(root, "plugins", mascot.id, "index.html"), "utf8"), "

second build

"); + } finally { + await manager.dispose(); + await rm(root, { recursive: true, force: true }); + } +}); + const hookOnlyManifest = { apiVersion: 1, id: "com.example.lifecycle-audit", @@ -109,6 +149,22 @@ test("validates all supported contribution shapes and permissions", () => { assert.deepEqual(validatePluginManifest(manifest), manifest); }); +test("allows narrow canvas mascots without shrinking standalone plugin windows", () => { + const portrait = { + ...manifest, + contributions: manifest.contributions.map((contribution) => contribution.id === "notes" + ? { ...contribution, defaultSize: { width: 260, height: 520 }, minSize: { width: 128, height: 140 } } + : contribution) + }; + assert.deepEqual(validatePluginManifest(portrait), portrait); + assert.throws(() => validatePluginManifest({ + ...manifest, + contributions: manifest.contributions.map((contribution) => contribution.id === "focus" + ? { ...contribution, defaultSize: { width: 260, height: 520 } } + : contribution) + }), /outside the supported bounds/); +}); + test("validates hook-only plugins and rejects ambiguous executable hook declarations", () => { assert.deepEqual(validatePluginManifest(hookOnlyManifest), hookOnlyManifest); assert.throws(() => validatePluginManifest({ diff --git a/tests/settings-normalizer.test.mjs b/tests/settings-normalizer.test.mjs index ca5f8f520..69dc4b49a 100644 --- a/tests/settings-normalizer.test.mjs +++ b/tests/settings-normalizer.test.mjs @@ -900,7 +900,7 @@ test("normalizes the optional built-in browser canvas bounds", () => { }, fallback).browserCanvas?.size, { width: 560, height: 1_100 }); }); -test("preserves plugin canvas bounds down to the manifest minimum floor", () => { +test("preserves narrow plugin canvas bounds for mascot cards", () => { const normalized = normalizeSettings({ pluginCanvas: [{ id: "instance-1", @@ -911,7 +911,7 @@ test("preserves plugin canvas bounds down to the manifest minimum floor", () => size: { width: 120, height: 90 } }] }, fallback); - assert.deepEqual(normalized.pluginCanvas[0]?.size, { width: 240, height: 140 }); + assert.deepEqual(normalized.pluginCanvas[0]?.size, { width: 128, height: 140 }); }); test("normalizes resizable Home boundaries within generous safety limits", () => { diff --git a/tests/terminal-launch.test.mjs b/tests/terminal-launch.test.mjs index 33e4a2a34..69453cbed 100644 --- a/tests/terminal-launch.test.mjs +++ b/tests/terminal-launch.test.mjs @@ -14,6 +14,20 @@ function available(provider, executable, options = {}) { }; } +test("a mascot starts one new Codex turn with one image without changing other providers", () => { + const initial = { initialPrompt: "Read the pipeline.\nKeep the character identity.", initialImagePath: "/project with spaces/character.png" }; + const launch = resolveTerminalLaunch("codex", "yolo", [], { + platform: "linux", environment: {}, providerCli: available("codex", "/official/codex"), ...initial + }); + assert.deepEqual(launch.args.slice(-3), ["Read the pipeline. Keep the character identity.", "--image", initial.initialImagePath]); + assert.equal(launch.args.filter((value) => value === "--image").length, 1); + assert.ok(!launch.args.includes("resume")); + const sibling = resolveTerminalLaunch("claude", "normal", [], { + platform: "linux", environment: {}, providerCli: available("claude", "/official/claude"), ...initial + }); + assert.ok(!sibling.args.includes("--image")); +}); + test("Codex QA changes only the interactive launch and keeps the registry backend", () => { const environment = { CANVASTTY_CODEX_TUI_QA: "/qa/codex-tui",