diff --git a/.devcontainer/entry.fish b/.devcontainer/entry.fish index c279d102cd..55bf9aeeab 100644 --- a/.devcontainer/entry.fish +++ b/.devcontainer/entry.fish @@ -1,982 +1,1018 @@ -#!/usr/bin/env fish - -# Log file for aesthetic function to tail during wait -set -g ENTRY_LOG /tmp/entry-fish.log -set -g ENTRY_LOG_DIR /home/me/.entry-logs -if test -d /workspaces/aesthetic-computer/.devcontainer - set -g ENTRY_LOG_DIR /workspaces/aesthetic-computer/.devcontainer/entry-logs -end -set -l entry_ts (date +%Y%m%d_%H%M%S) -set -g ENTRY_LOG_REAL $ENTRY_LOG_DIR/entry-$entry_ts.log - -mkdir -p $ENTRY_LOG_DIR -echo "" > $ENTRY_LOG_REAL -ln -sf $ENTRY_LOG_REAL $ENTRY_LOG -ln -sf $ENTRY_LOG_REAL $ENTRY_LOG_DIR/latest.log -echo "" > $ENTRY_LOG - -# Aggressive logging function - writes immediately with timestamp and syncs to disk -function log - set -l timestamp (date '+%H:%M:%S.%3N') - set -l msg "[$timestamp] $argv" - builtin echo $msg - builtin echo $msg >> $ENTRY_LOG_REAL 2>/dev/null - # Force sync to disk immediately so we don't lose logs on crash - sync 2>/dev/null -end - -# Log with specific level prefix -function log_info - log "ℹ️ $argv" -end - -function log_ok - log "✅ $argv" -end - -function log_warn - log "⚠️ $argv" -end - -function log_error - log "❌ $argv" -end - -function log_step - log "🔹 $argv" -end - -# Simple wrapper: append each echo to log file too (keep for backward compat) -# We override echo within this script -function echo - builtin echo $argv - builtin echo "[" (date '+%H:%M:%S') "] $argv" >> $ENTRY_LOG_REAL 2>/dev/null -end - -# Run a command and capture output to the entry log. -function log_cmd - set -l cmd_str (string join ' ' -- $argv) - log_step "Running: $cmd_str" - command $argv >> $ENTRY_LOG_REAL 2>&1 - set -l cmd_status $status - if test $cmd_status -ne 0 - log_error "Command failed ($cmd_status): $cmd_str" - else - log_ok "Command succeeded: $cmd_str" - end - sync 2>/dev/null - return $cmd_status -end - -# Run a command with a timeout if available. -function log_cmd_timeout - set -l seconds $argv[1] - set -l cmd $argv[2..-1] - log_step "Running with "$seconds"s timeout: "(string join ' ' -- $cmd) - if type -q timeout - log_cmd timeout $seconds $cmd - else - log_cmd $cmd - end - return $status -end - -# Start CDP tunnel without blocking startup if SSH auth is missing. -function start_cdp_tunnel - set -l ssh_args -f -N -o StrictHostKeyChecking=no -o BatchMode=yes -o ExitOnForwardFailure=yes -o ConnectTimeout=5 -o ConnectionAttempts=1 -L 9333:127.0.0.1:9333 me@host.docker.internal - log_cmd_timeout 8 ssh $ssh_args - return $status -end - -log "════════════════════════════════════════════════════════════════" -log "🚀 ENTRY.FISH STARTING" -log "════════════════════════════════════════════════════════════════" -log_info "Timestamp: "(date -Iseconds) -log_info "Log file: $ENTRY_LOG_REAL" -log_info "Log dir: $ENTRY_LOG_DIR" -log_info "Hostname: "(cat /etc/hostname 2>/dev/null; or echo "unknown") -log_info "User: "(whoami) -log_info "PWD: "(pwd) - -# 🔍 Start Devcontainer Status Server EARLY (so VS Code can see boot progress) -# This allows the Welcome Panel to show the boot process in real-time -log_step "EARLY START: Devcontainer Status Server" -if test -f /workspaces/aesthetic-computer/artery/devcontainer-status.mjs - # Kill any existing instance first - pkill -f "devcontainer-status" 2>/dev/null - sleep 0.1 - nohup node /workspaces/aesthetic-computer/artery/devcontainer-status.mjs --server >/tmp/devcontainer-status.log 2>&1 & - disown - log_ok "Status Server started early on http://127.0.0.1:7890" -else - log_warn "devcontainer-status.mjs not found, will try later" -end - -log_step "PHASE 1: Environment sync" - -# --- Sync secrets from vault to devcontainer env --- -# The vault stores the canonical secrets; copy them to devcontainer on startup -set -l vault_env ~/aesthetic-computer-vault/.devcontainer/envs/devcontainer.env -set -l devcontainer_env ~/aesthetic-computer/.devcontainer/envs/devcontainer.env -if test -f $vault_env - if test -f $devcontainer_env - # Merge: update devcontainer.env with any keys from vault (vault wins) - for line in (cat $vault_env) - set -l key (string split -m1 '=' -- $line)[1] - if test -n "$key" - # Remove old key if exists, then append new - sed -i "/^$key=/d" $devcontainer_env 2>/dev/null - echo $line >> $devcontainer_env - end - end - log_ok "Synced secrets from vault to devcontainer.env" - else - # No devcontainer.env, just copy from vault - cp $vault_env $devcontainer_env - log_ok "Copied secrets from vault to devcontainer.env" - end -else - log_warn "Vault env not found at $vault_env" -end - -# --- Setup session-server SSH key from vault --- -set -l ss_key_src ~/aesthetic-computer-vault/session-server/session_server -set -l ss_key_dst ~/.ssh/session_server -if test -f $ss_key_src; and not test -f $ss_key_dst - # Ensure .ssh directory exists with correct ownership and permissions - sudo mkdir -p ~/.ssh - sudo chown me:me ~/.ssh - sudo chmod 700 ~/.ssh - - cp $ss_key_src $ss_key_dst - cp "$ss_key_src.pub" "$ss_key_dst.pub" - chmod 600 $ss_key_dst - chmod 644 "$ss_key_dst.pub" - - # Create config file if it doesn't exist with correct ownership - if not test -f ~/.ssh/config - touch ~/.ssh/config - chmod 600 ~/.ssh/config - end - - # Add SSH config entry if missing - if not grep -q "Host session-server" ~/.ssh/config 2>/dev/null - echo "" >> ~/.ssh/config - echo "Host session-server" >> ~/.ssh/config - echo " HostName 157.245.134.225" >> ~/.ssh/config - echo " User root" >> ~/.ssh/config - echo " IdentityFile ~/.ssh/session_server" >> ~/.ssh/config - echo " IdentitiesOnly yes" >> ~/.ssh/config - echo " StrictHostKeyChecking accept-new" >> ~/.ssh/config - end - log_ok "Session-server SSH key installed" -else if not test -f $ss_key_src - log_warn "Session-server SSH key not found in vault" -end - -log_step "PHASE 2: Checking for fast reload path" - -# Fast path for VS Code "Reload Window" - if .waiter exists and key processes running, skip setup -if test -f /home/me/.waiter - log_info "Detected reload (found .waiter file)" - - # Check if key processes are already running - set -l dockerd_running (pgrep -x dockerd 2>/dev/null) - set -l emacs_running (pgrep -f "emacs.*daemon" 2>/dev/null) - - if test -n "$dockerd_running" - echo "✅ Docker daemon already running (PID: $dockerd_running)" - - # Check if emacs daemon is running AND responsive - set -l emacs_config /home/me/aesthetic-computer/dotfiles/dot_config/emacs.el - if test -n "$emacs_running" - if emacsclient -e t >/dev/null 2>&1 - echo "✅ Emacs daemon running and responsive (PID: $emacs_running)" - else - echo "⚠️ Emacs daemon zombie detected (PID: $emacs_running) - restarting..." - pkill -9 -f "emacs.*daemon" 2>/dev/null - pkill -9 emacs 2>/dev/null - sleep 1 - emacs -q --daemon -l $emacs_config 2>&1 - sleep 1 - if emacsclient -e t >/dev/null 2>&1 - echo "✅ Emacs daemon restarted successfully" - else - echo "❌ Failed to restart emacs daemon" - end - end - else - echo "⚠️ Emacs daemon not running - starting..." - emacs -q --daemon -l $emacs_config 2>&1 - sleep 1 - end - - # Ensure CDP tunnel is up (for artery-tui to control VS Code on host) - set -l cdp_tunnel (pgrep -f "ssh.*9333.*host.docker.internal" 2>/dev/null) - if test -n "$cdp_tunnel" - echo "✅ CDP tunnel already running (PID: $cdp_tunnel)" - else - echo "🩸 Starting CDP tunnel..." - start_cdp_tunnel - if test $status -eq 0 - echo "✅ CDP tunnel established (localhost:9333 → host:9333)" - else - echo "⚠️ CDP tunnel failed (artery may not control VS Code)" - end - end - - echo "⚡ Skipping full setup - container already configured" - echo "✅ Ready! (fast reload path)" - exit 0 - else - echo "⚠️ Docker daemon not found, running full setup..." - end -end - -# Fix fish path if needed (Fedora installs to /usr/sbin instead of /usr/bin) -if not test -f /usr/bin/fish; and test -f /usr/sbin/fish - sudo ln -sf /usr/sbin/fish /usr/bin/fish - echo "🐟 Created fish symlink: /usr/bin/fish -> /usr/sbin/fish" -end - -# Function to ensure fish config directory has correct permissions -function ensure_fish_config_permissions - set -l fish_config_dir /home/me/.config/fish - set -l fish_data_dir /home/me/.local/share/fish - - echo "🔧 Fixing permissions for fish directories..." - - # Fix the parent .config directory first (most important) - if test -d /home/me/.config - sudo chown -R me:me /home/me/.config 2>/dev/null - sudo chmod -R 755 /home/me/.config 2>/dev/null - echo "✅ Fixed permissions for /home/me/.config" - end - - if test -d $fish_config_dir - # Fix ownership and permissions for fish config directory - be VERY aggressive - sudo chown -R me:me $fish_config_dir 2>/dev/null - sudo chmod -R 755 $fish_config_dir 2>/dev/null - sudo chmod 644 $fish_config_dir/*.fish 2>/dev/null - # Also fix individual subdirectories explicitly - sudo chown -R me:me $fish_config_dir/conf.d 2>/dev/null - sudo chown -R me:me $fish_config_dir/functions 2>/dev/null - sudo chown -R me:me $fish_config_dir/completions 2>/dev/null - echo "✅ Fixed permissions for $fish_config_dir" - end - - if test -d $fish_data_dir - # Fix ownership and permissions for fish data directory - sudo chown -R me:me $fish_data_dir 2>/dev/null - sudo chmod -R 755 $fish_data_dir 2>/dev/null - echo "✅ Fixed permissions for $fish_data_dir" - end - - # Fix /home/me/.local directory too - if test -d /home/me/.local - sudo chown -R me:me /home/me/.local 2>/dev/null - sudo chmod -R 755 /home/me/.local 2>/dev/null - echo "✅ Fixed permissions for /home/me/.local" - end - - # Disable fish's universal variable file daemon (fishd) which causes permission issues - # We'll use fish_variables instead which is simpler and doesn't create temp files - set -U fish_greeting "" # Suppress greeting while we're at it - - echo "✨ All permissions fixed!" -end - -# Function to ensure correct Docker socket permissions -function ensure_docker_socket_permissions - set -l DOCKER_SOCKET /var/run/docker.sock - if test -S $DOCKER_SOCKET - set -l DOCKER_GID (stat -c '%g' $DOCKER_SOCKET) - set -l CURRENT_DOCKER_GROUP_INFO (getent group $DOCKER_GID) - - if test -z "$CURRENT_DOCKER_GROUP_INFO" # No group with this GID exists - if getent group docker > /dev/null - echo "Modifying existing docker group to GID $DOCKER_GID..." - sudo groupmod -g $DOCKER_GID docker - else - echo "Creating new docker group with GID $DOCKER_GID..." - sudo groupadd -g $DOCKER_GID docker - end - else # A group with this GID already exists, check if it's named 'docker' - set -l GROUP_NAME (echo $CURRENT_DOCKER_GROUP_INFO | cut -d: -f1) - if test "$GROUP_NAME" != "docker" - echo "Warning: Group with GID $DOCKER_GID exists but is named '$GROUP_NAME', not 'docker'. Manual intervention might be needed." - # Optionally, you could decide to add 'me' to this group anyway, - # or try to rename it if it's safe, or delete and recreate 'docker' group. - # For now, we'll proceed assuming 'docker' is the target group name. - if getent group docker > /dev/null - echo "Modifying existing docker group to GID $DOCKER_GID..." - sudo groupmod -g $DOCKER_GID docker - else - echo "Creating new docker group with GID $DOCKER_GID..." - sudo groupadd -g $DOCKER_GID docker - end - else - echo "Docker group '$GROUP_NAME' with GID $DOCKER_GID already exists." - end - end - - # Ensure 'me' user is in the 'docker' group - if not groups me | string match -q -r '\\bdocker\\b' - echo "Adding user 'me' to docker group..." - sudo usermod -aG docker me - # Changes to group membership may require a new login session or `newgrp docker` - # to take effect immediately in the current shell. - # For a script, subsequent commands in this same script might not see the new group immediately. - # However, for processes spawned after this script, it should be fine. - else - echo "User 'me' is already in docker group." - end - else - echo "Docker socket $DOCKER_SOCKET not found." - end -end - -function install_and_trust_certificate - set -l certificate_path $argv[1] - set -l certificate_key_path $argv[2] - set -l certificate_dir /etc/pki/ca-trust/source/anchors/ - - if test -z "$certificate_path"; or test -z "$certificate_key_path" - return - end - - if test -f $certificate_path - sudo cp $certificate_path $certificate_dir - end - - if test -f $certificate_key_path - sudo cp $certificate_key_path $certificate_dir - end - - sudo update-ca-trust -end - -function ensure_ssl_dev_certs - set -l ssl_dir /home/me/aesthetic-computer/ssl-dev - set -l nanos_ssl_dir /home/me/aesthetic-computer/nanos/ssl - - if not test -d $ssl_dir - return - end - - if not type -q mkcert - echo "⚠️ mkcert not available; skipping automatic certificate generation." - return - end - - set -l previous_dir (pwd) - cd $ssl_dir - - set -l have_cert 1 - if not test -f localhost.pem; or not test -f localhost-key.pem - set have_cert 0 - end - - if test $have_cert -eq 0 - echo "🔐 Generating mkcert certificates for the dev container..." - if not test -d /home/me/.local/share/mkcert - echo "📥 Installing mkcert local CA..." - log_cmd_timeout 60 mkcert -install - if test $status -ne 0 - echo "⚠️ mkcert CA installation failed. See entry.fish log for details." - end - if not type -q certutil - echo "📦 Installing nss-tools for certificate trust..." - log_cmd_timeout 300 sudo dnf install -y nss-tools - log_cmd_timeout 60 mkcert -install - end - end - - log_cmd_timeout 120 env nogreet=true fish ./ssl-install.fish - if test $status -ne 0 - echo "⚠️ Automatic certificate generation failed using ssl-install.fish." - cd $previous_dir - return - end - else - log_cmd_timeout 120 env nogreet=true fish ./ssl-install.fish --install-only - end - - mkdir -p $nanos_ssl_dir - cp localhost.pem $nanos_ssl_dir/localhost.pem - cp localhost-key.pem $nanos_ssl_dir/localhost-key.pem - - sudo chown me:me -R $ssl_dir - sudo chown me:me -R $nanos_ssl_dir - - install_and_trust_certificate $ssl_dir/localhost.pem $ssl_dir/localhost-key.pem - - cd $previous_dir -end - -log_step "PHASE 3: Docker socket permissions" -# Call the function to set up Docker socket permissions -ensure_docker_socket_permissions - -log_step "PHASE 4: Starting daemons" -# Start Docker daemon in the background if not already running -if not pgrep -x dockerd >/dev/null - log_info "Starting Docker daemon..." - sudo dockerd >/tmp/dockerd.log 2>&1 & -else - log_ok "Docker daemon already running." -end - -# Start Ollama daemon in the background if available and not already running -if type -q ollama - if not pgrep -x ollama >/dev/null - log_info "Starting Ollama daemon..." - ollama serve >/tmp/ollama.log 2>&1 & - else - log_ok "Ollama daemon already running." - end -else - log_warn "Ollama not found; skipping." -end - -log_step "PHASE 4b: Cockpit web interface" -# Start Cockpit web server (no TLS for local dev, port 9090) -# Terminal + system overview work without systemd; service/journal features need systemd -set -l cockpit_ws_bin "" -for p in /usr/lib/cockpit/cockpit-ws /usr/libexec/cockpit-ws - if test -x $p - set cockpit_ws_bin $p - break - end -end - -if test -n "$cockpit_ws_bin" - if not pgrep -f "cockpit-ws" >/dev/null - # Set a dev password so PAM auth works (use vault COCKPIT_PASSWORD if set) - set -l cockpit_pass (test -n "$COCKPIT_PASSWORD"; and echo $COCKPIT_PASSWORD; or echo "aesthetic") - echo "me:$cockpit_pass" | sudo chpasswd 2>/dev/null - sudo $cockpit_ws_bin --no-tls --port=9090 >/tmp/cockpit-ws.log 2>&1 & - disown - log_ok "Cockpit started → http://localhost:9090 (login: me / $cockpit_pass)" - else - log_ok "Cockpit already running" - end -else - log_warn "Cockpit not installed — skipping (rebuild container to add it)" -end - -log_step "PHASE 5: Environment setup" -# Ensure the envs directory exists and is accessible (fallback if mount fails) -if not test -d /home/me/envs - log_info "Creating missing /home/me/envs directory and linking to .devcontainer/envs" - mkdir -p /home/me/envs - ln -sf /workspaces/aesthetic-computer/.devcontainer/envs/* /home/me/envs/ -end - -if test -d /home/me/envs - source /home/me/envs/load_envs.fish - load_envs # Load devcontainer envs conditionally. - echo "🔧 Environment variables loaded from devcontainer.env" -end - -# Set environment variables to prevent ETXTBSY errors and disable telemetry -set -gx NETLIFY_CLI_TELEMETRY_DISABLED 1 -set -gx NODE_DISABLE_COMPILE_CACHE 1 - -set -gx TERM xterm-256color - -# Create xdg-open wrapper to open URLs on Windows host from dev container -if not test -f /usr/local/bin/xdg-open - echo "🌐 Creating xdg-open wrapper for host browser..." - printf '#!/bin/bash\n"\$BROWSER" "\$@"\n' | sudo tee /usr/local/bin/xdg-open >/dev/null - sudo chmod +x /usr/local/bin/xdg-open -end - -# Start dbus-run-session for the keryring and execute the command passed to the script -# dbus-run-session -- sh -c "eval \$(echo \$DBUS_SESSION_BUS_ADDRESS); exec $argv" - -# Send a welcome message! -toilet "Aesthetic Computer" -f future | lolcat -x -r - -# Go to the user's directory. -cd /home/me - -log_step "PHASE 6: SSL certificates" -ensure_ssl_dev_certs - -log_step "PHASE 7: GitHub authentication" -# Login to Github - use GH_TOKEN from vault if available -set -l gh_authenticated 1 -if not gh auth status >/dev/null 2>&1 - if test -n "$GH_TOKEN" - log_info "Authenticating to GitHub using GH_TOKEN..." - echo $GH_TOKEN | gh auth login --with-token - if gh auth status >/dev/null 2>&1 - log_ok "GitHub authentication successful" - else - log_error "GitHub authentication failed" - log_warn "Continuing without GitHub auth; vault/git operations may be limited." - set gh_authenticated 0 - end - else - log_warn "Not logged into GitHub and no GH_TOKEN available." - log_warn "Continuing startup without GitHub auth." - set gh_authenticated 0 - end -else - log_ok "Already authenticated to GitHub" -end - -log_step "PHASE 8: Git configuration" -if test -n "$GIT_USER_EMAIL" - git config --global user.email $GIT_USER_EMAIL - log_info "Set git user email to: $GIT_USER_EMAIL" -end - -if test -n "$GIT_USER_NAME" - git config --global user.name $GIT_USER_NAME - log_info "Set git user name to: $GIT_USER_NAME" -end - -# Add aesthetic-computer as the "safe" directory. -git config --global --add safe.directory /home/me/aesthetic-computer - -# Set rebase as default for pull operations. -git config --global pull.rebase true - -# Disable GPG signing to prevent commit issues in dev environment -git config --global commit.gpgsign false - -# Make sure git is setup and authorized for making commits via `gh`. -if test $gh_authenticated -eq 1 - gh auth setup-git -else - log_warn "Skipping 'gh auth setup-git' because GitHub auth is unavailable." -end - -# Ensure GPG signing is disabled at both global and local levels (GitHub CLI might re-enable it) -git config --global commit.gpgsign false -cd /home/me/aesthetic-computer -git config --local commit.gpgsign false -git config --local user.signingkey "" -echo "🔓 GPG signing disabled for commits" - -# Disable Netlify CLI telemetry to prevent ETXTBSY errors -echo "Disabling Netlify CLI telemetry..." -cd /home/me/aesthetic-computer/system && netlify --telemetry-disable 2>/dev/null || echo "Netlify telemetry disable completed" - -# Add aesthetic.local and sotce.local to /etc/hosts if they don't already exist -if not grep -q "aesthetic.local" /etc/hosts - echo "127.0.0.1 aesthetic.local" | sudo tee -a /etc/hosts -end - -if not grep -q "sotce.local" /etc/hosts - echo "127.0.0.1 sotce.local" | sudo tee -a /etc/hosts -end - -log_step "PHASE 9: Vault setup" -# Apply the 'vault' credentials to the mounted aesthetic-computer volume, and make sure it exists. -if test -d /home/me/aesthetic-computer - if not test -d /home/me/aesthetic-computer/aesthetic-computer-vault - log_info "Cloning vault repository..." - gh repo clone whistlegraph/aesthetic-computer-vault /home/me/aesthetic-computer/aesthetic-computer-vault - cd /home/me/aesthetic-computer/aesthetic-computer-vault - log_info "Running devault.fish..." - sudo fish devault.fish - - # Load environment variables after initial vault setup - if test -d /home/me/envs - source /home/me/envs/load_envs.fish - load_envs # Load envs after initial vault setup - log_ok "Environment variables loaded after initial vault setup" - end - else - cd /home/me/aesthetic-computer/aesthetic-computer-vault - log_info "Pulling latest vault..." - git pull - sudo fish devault.fish - log_ok "Vault mounted." - end - - # Reload environment variables after vault is mounted - if test -d /home/me/envs - source /home/me/envs/load_envs.fish - load_envs # Reload envs after vault mount - log_ok "Environment variables reloaded after vault mount" - end - - # Setup SSH keys from vault (only if not already set up) - set -l vault_ssh /home/me/aesthetic-computer/aesthetic-computer-vault/home/.ssh - if test -d $vault_ssh - # Only copy if id_rsa doesn't exist yet (first time setup) - if not test -f /home/me/.ssh/id_rsa - # Ensure .ssh directory exists with correct ownership and permissions - sudo mkdir -p /home/me/.ssh - sudo chown -R me:me /home/me/.ssh 2>/dev/null - sudo chmod 700 /home/me/.ssh 2>/dev/null - - cp -f $vault_ssh/* /home/me/.ssh/ 2>/dev/null - chmod 700 /home/me/.ssh 2>/dev/null - chmod 600 /home/me/.ssh/id_rsa 2>/dev/null - chmod 644 /home/me/.ssh/id_rsa.pub 2>/dev/null - chmod 600 /home/me/.ssh/config 2>/dev/null - chmod 600 /home/me/.ssh/aesthetic_pds 2>/dev/null - chmod 644 /home/me/.ssh/aesthetic_pds.pub 2>/dev/null - log_ok "SSH keys restored from vault" - else - log_info "SSH keys already exist, skipping vault copy" - end - end - - # Setup Copilot CLI config from vault (if volume is empty but vault has backup) - set -l vault_copilot /home/me/aesthetic-computer/aesthetic-computer-vault/home/.copilot - if test -d $vault_copilot - # Only restore if volume mount is empty (no config.json) - if not test -f /home/me/.copilot/config.json - mkdir -p /home/me/.copilot - cp -f $vault_copilot/config.json /home/me/.copilot/ 2>/dev/null - log_ok "Copilot CLI config restored from vault" - else - log_info "Copilot CLI config already exists (using volume data)" - end - end -else - log_error "Vault unmounted! /home/me/aesthetic-computer not found" -end - -log_step "PHASE 10: Fixing permissions" -# Fix all permissions AFTER vault setup (vault copies files as root with sudo) -log_info "Fixing permissions after vault setup..." - -# First, ensure the home directory itself is owned by me (critical for .emacs-logs etc) -sudo chown me:me /home/me 2>/dev/null -log_ok "Fixed ownership of /home/me" - -# Create .emacs-logs directory if it doesn't exist -mkdir -p /home/me/.emacs-logs 2>/dev/null -log_ok "Ensured /home/me/.emacs-logs exists" - -# Fix fish config permissions (vault may have overwritten with root-owned files) -log_info "Fixing fish config permissions..." -ensure_fish_config_permissions - -# Fix SSH directory permissions (use sudo to fix ownership if needed) -if test -d /home/me/.ssh - if not test -O /home/me/.ssh - # SSH dir owned by root (probably from vault setup), fix ownership - sudo chown -R me:me /home/me/.ssh 2>/dev/null - log_ok "Fixed SSH directory ownership" - end - chmod 700 /home/me/.ssh 2>/dev/null - chmod 600 /home/me/.ssh/* 2>/dev/null - chmod 644 /home/me/.ssh/*.pub 2>/dev/null - log_ok "Fixed permissions for /home/me/.ssh" -end - -# Fix Copilot CLI directory permissions (volume mount may have root ownership) -if test -d /home/me/.copilot - sudo chown -R me:me /home/me/.copilot 2>/dev/null - sudo chmod 700 /home/me/.copilot 2>/dev/null - # Ensure pkg directory exists and is writable for package extraction - mkdir -p /home/me/.copilot/pkg 2>/dev/null - chmod 755 /home/me/.copilot/pkg 2>/dev/null - sudo chmod 600 /home/me/.copilot/config.json 2>/dev/null - echo "✅ Fixed permissions for /home/me/.copilot (Copilot CLI config)" -end - -# Fix Claude/Codex config directory permissions after bind mounts are attached. -# On Linux hosts, these mounts keep host ownership, so UID remapping is the main -# fix; this container-side pass normalizes modes when ownership is already writable. -for dir in /home/me/.claude /home/me/.codex - if test -d $dir - sudo chown -R me:me $dir 2>/dev/null - chmod 700 $dir 2>/dev/null - find $dir -type d -exec chmod 700 {} + 2>/dev/null - find $dir -type f -exec chmod 600 {} + 2>/dev/null - echo "✅ Fixed permissions for $dir" - end -end - -if test -f /home/me/.claude.json - sudo chown me:me /home/me/.claude.json 2>/dev/null - chmod 600 /home/me/.claude.json 2>/dev/null - echo "✅ Fixed permissions for /home/me/.claude.json" -end - -# Fix VS Code extension SecretStorage persistence paths. -for dir in /home/me/.config/Code/User/globalStorage /home/me/.config/Code/User/workspaceStorage - if test -d $dir - sudo chown -R me:me $dir 2>/dev/null - find $dir -type d -exec chmod 700 {} + 2>/dev/null - find $dir -type f -exec chmod 600 {} + 2>/dev/null - echo "✅ Fixed permissions for $dir" - end -end - -if test -d /home/me/.local/share/keyrings - sudo chown -R me:me /home/me/.local/share/keyrings 2>/dev/null - chmod 700 /home/me/.local/share/keyrings 2>/dev/null - find /home/me/.local/share/keyrings -type f -exec chmod 600 {} + 2>/dev/null - echo "✅ Fixed permissions for /home/me/.local/share/keyrings" -end - -# --- GPG agent: cache passphrase for the entire container lifetime --- -mkdir -p /home/me/.gnupg 2>/dev/null -chmod 700 /home/me/.gnupg -printf "allow-loopback-pinentry\npinentry-program /usr/sbin/pinentry-curses\ndefault-cache-ttl 999999\nmax-cache-ttl 999999\n" > /home/me/.gnupg/gpg-agent.conf -gpgconf --reload gpg-agent 2>/dev/null -log_ok "GPG agent configured (passphrase cached for container lifetime)" - -cd /home/me/aesthetic-computer/aesthetic-computer-vault -git pull - -# Function to check and install npm dependencies in a directory -set -g NODE_DEPS_CHECK_SCRIPT /workspaces/aesthetic-computer/.devcontainer/scripts/check-node-deps.mjs - -function verify_npm_versions - set -l dir $argv[1] - set -l dir_name (basename $dir) - - if not test -f $NODE_DEPS_CHECK_SCRIPT - return - end - - if not test -f $dir/package.json - return - end - - if not test -d $dir/node_modules - return - end - - log_cmd node $NODE_DEPS_CHECK_SCRIPT $dir - set -l check_status $status - - if test $check_status -ne 0 - echo "♻️ Resyncing dependencies in $dir_name to match package.json versions..." - cd $dir - log_cmd npm ci --no-fund --no-audit - if test $status -ne 0 - echo "⚠️ npm ci failed in $dir_name, falling back to npm install" - log_cmd npm install --no-fund --no-audit - end - end -end - -# Function to check and fix esbuild architecture mismatches -function fix_esbuild_architecture - set -l dir $argv[1] - set -l dir_name (basename $dir) - - # Check if this directory has esbuild installed - if test -d $dir/node_modules/@esbuild - set -l arch (uname -m) - set -l expected_esbuild - - # Determine expected esbuild platform package - if test "$arch" = "x86_64" - set expected_esbuild "linux-x64" - else if test "$arch" = "aarch64" - set expected_esbuild "linux-arm64" - else - return 0 # Unknown architecture, skip - end - - # Check what's actually installed - set -l installed_arch (ls $dir/node_modules/@esbuild 2>/dev/null | grep -E "^linux-") - - if test -n "$installed_arch"; and test "$installed_arch" != "$expected_esbuild" - echo "🔧 Wrong esbuild architecture in $dir_name: $installed_arch (need $expected_esbuild)" - echo " Reinstalling dependencies to fix..." - cd $dir - rm -rf node_modules package-lock.json - log_cmd npm install --no-fund --no-audit - if test $status -eq 0 - echo "✅ Fixed esbuild architecture in $dir_name" - else - echo "⚠️ Failed to fix esbuild in $dir_name" - end - end - end -end - -function install_npm_deps - set -l dir $argv[1] - set -l dir_name (basename $dir) - - if test -f $dir/package.json - if not test -d $dir/node_modules || not count $dir/node_modules/* >/dev/null 2>/dev/null - echo "📦 Installing dependencies in $dir_name..." - cd $dir - log_cmd npm ci --no-fund --no-audit - if test $status -ne 0 - echo "⚠️ Failed to install dependencies in $dir_name, trying npm install..." - log_cmd npm install --no-fund --no-audit - end - else - echo "✅ $dir_name already has node_modules" - # Check for architecture mismatches even if node_modules exists - fix_esbuild_architecture $dir - end - verify_npm_versions $dir - end -end - -log_step "PHASE 11: NPM dependencies" -# Check and install dependencies in key directories -cd /home/me/aesthetic-computer - -# Install root dependencies first -if not test -d /home/me/aesthetic-computer/node_modules || not count /home/me/aesthetic-computer/node_modules/* >/dev/null - log_info "Installing root dependencies..." - log_cmd npm install -g npm@latest --no-fund --no-audit - log_cmd npm ci --no-fund --no-audit -else - log_ok "Root directory already has node_modules" -end - -log_info "Verifying npm versions..." -verify_npm_versions /home/me/aesthetic-computer - -# Install dependencies in critical subdirectories (archive intentionally excluded) -set -l critical_dirs system session-server vscode-extension nanos daemon utilities shared - -log_info "Installing dependencies in critical directories..." -for dir in $critical_dirs - if test -d /home/me/aesthetic-computer/$dir - log_info "Processing $dir..." - install_npm_deps /home/me/aesthetic-computer/$dir - end -end - -# Run the comprehensive install script for any remaining directories -log_info "Running comprehensive install script for remaining directories..." -pushd /home/me/aesthetic-computer -log_cmd npm run install:everything-else -popd - -log_step "PHASE 12: Final setup" -# Make sure this user owns the emacs directory. -sudo chown -R me:me ~/.emacs.d - -# Link the prompts directory to VSCode User config -mkdir -p ~/.config/Code/User -rm -rf ~/.config/Code/User/prompts -ln -s /home/me/aesthetic-computer/prompts ~/.config/Code/User/prompts -log_ok "Prompts directory linked to VSCode User config" - -# Link the modes directory to .github for VSCode chatmodes -rm -rf /home/me/aesthetic-computer/.github -ln -s /home/me/aesthetic-computer/modes /home/me/aesthetic-computer/.github -log_ok "Modes directory linked to .github for VSCode chatmodes" - -# Source the devcontainer config to load AC development functions -source /workspaces/aesthetic-computer/.devcontainer/config.fish -log_ok "AC development functions loaded (ac-pack, ac-unpack, etc.)" - -# Copy feed secrets from vault to environment (if vault exists) -if test -f /workspaces/aesthetic-computer/aesthetic-computer-vault/feed/.env - log_info "Loading feed system environment variables from vault..." - # Copy to devcontainer envs directory for persistence - cp /workspaces/aesthetic-computer/aesthetic-computer-vault/feed/.env /home/me/envs/feed.env - # Source it globally for the session - for line in (cat /workspaces/aesthetic-computer/aesthetic-computer-vault/feed/.env | grep -v '^#' | grep '=') - set -gx (string split '=' $line) - end - log_ok "Feed environment variables loaded" -else - echo "⚠️ Feed vault not found - skipping feed environment setup" -end - -# Set port 8888 to public in Codespaces (if applicable) -if test -f /workspaces/aesthetic-computer/.devcontainer/scripts/set-port-public.fish - log_cmd fish /workspaces/aesthetic-computer/.devcontainer/scripts/set-port-public.fish -end - -# Create .waiter file to signal container is ready (moved from postAttachCommand to avoid terminal popup in Codespaces) -sudo touch /home/me/.waiter -sudo chmod 777 /home/me/.waiter -sudo chmod +w /home/me -echo "✅ Container ready signal created (.waiter)" - -# NOTE: Emacs daemon is NOT pre-started here. The `aesthetic` function -# (called by the VS Code "💻 Aesthetic" task) unconditionally kills any -# running daemon and starts fresh via `ensure-emacs-daemon-ready`. -# Pre-starting here caused a race condition: entry.fish would start a -# daemon, then the VS Code task would immediately kill it and start -# another, wasting resources and causing load spikes that made the -# second daemon unresponsive to the crash monitor. -log_step "PHASE 12b: Emacs daemon (deferred to VS Code task)" -log_info "Emacs daemon start deferred - will be started by 'aesthetic' function" - -# 🩸 Setup SSH tunnel for CDP (Chrome DevTools Protocol) to host -# This allows artery-tui to control VS Code on the host machine -if test -n "$HOST_IP"; or test (uname -s) != "Linux" - # Kill any existing CDP tunnels - pkill -f "ssh.*9333.*host.docker.internal" 2>/dev/null - - # Create tunnel in background (port 9333 to avoid conflicts with svchost.exe on 9222) - start_cdp_tunnel - if test $status -eq 0 - echo "🩸 CDP tunnel established (localhost:9333 → host:9333)" - else - echo "⚠️ CDP tunnel failed (artery may not work)" - end -end - -# 🧊 Start TURN server for WebRTC relay (required for UDP in Docker) -if which turnserver >/dev/null 2>&1 - pkill -f "turnserver" 2>/dev/null - sleep 0.5 - # Get host LAN IP from vault/machines.json or use default - set -l HOST_LAN_IP "192.168.1.127" - set -l HOST_LAN_IP_SOURCE "default" - if test -f /workspaces/aesthetic-computer/aesthetic-computer-vault/machines.json - set -l detected_ip (cat /workspaces/aesthetic-computer/aesthetic-computer-vault/machines.json 2>/dev/null | jq -r '.["aesthetic-mac"].lan_ip // empty' 2>/dev/null) - if test -n "$detected_ip" - set HOST_LAN_IP $detected_ip - set HOST_LAN_IP_SOURCE "vault" - end - end - log_info "TURN host IP: $HOST_LAN_IP (source: $HOST_LAN_IP_SOURCE)" - echo $HOST_LAN_IP > /tmp/host-lan-ip - turnserver -c /workspaces/aesthetic-computer/.devcontainer/turnserver.conf --external-ip=$HOST_LAN_IP >> $ENTRY_LOG_REAL 2>&1 & - disown - log_ok "TURN server started (localhost:3478, external-ip: $HOST_LAN_IP)" -else - log_warn "coturn not installed, UDP may not work across networks" -end - -# echo "Initializing 📋 Clipboard Service" | lolcat -x -r - -# ❤️‍🔥 -# TODO: This may not be the best method because cdocker aesthetic needs to be -# running in the user environment that has the clipboard. - -# Run the isomorphic_copy clipboard on the host. -# Make sure the host allows ssh access from its own private key... `ssh-copy-id -i ~/.ssh/id_rsa.pub $USER@localhost` -# ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null $HOST_USER@172.17.0.1 - -# ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null $HOST_USER@172.17.0.1 "cdocker aesthetic" - -# 🔍 Ensure Devcontainer Status Server is still running (it was started early) -log_info "Checking Devcontainer Status Server..." -if pgrep -f "devcontainer-status" >/dev/null - log_ok "Status Server still running on http://127.0.0.1:7890" -else if test -f /workspaces/aesthetic-computer/artery/devcontainer-status.mjs - log_warn "Status Server died, restarting..." - nohup node /workspaces/aesthetic-computer/artery/devcontainer-status.mjs --server >/tmp/devcontainer-status.log 2>&1 & - disown - log_ok "Status Server restarted on http://127.0.0.1:7890" -end - -log "════════════════════════════════════════════════════════════════" -log "🎉 ENTRY.FISH COMPLETED SUCCESSFULLY" -log "════════════════════════════════════════════════════════════════" -log_info "End time: "(date -Iseconds) -log_info "Log file: $ENTRY_LOG_REAL" - -# Auto-launch is handled by the VS Code task (💻 Aesthetic) which runs on folder open -# The task calls aesthetic-launch.sh which properly launches the emacs client -# Background launch from entry.fish doesn't work because emacsclient -nw needs an interactive terminal -log_info "Container setup complete - VS Code task will launch aesthetic platform" +#!/usr/bin/env fish + +# Log file for aesthetic function to tail during wait +set -g ENTRY_LOG /tmp/entry-fish.log +set -g ENTRY_LOG_DIR /home/me/.entry-logs +if test -d /workspaces/aesthetic-computer/.devcontainer + set -g ENTRY_LOG_DIR /workspaces/aesthetic-computer/.devcontainer/entry-logs +end +set -l entry_ts (date +%Y%m%d_%H%M%S) +set -g ENTRY_LOG_REAL $ENTRY_LOG_DIR/entry-$entry_ts.log + +mkdir -p $ENTRY_LOG_DIR +echo "" > $ENTRY_LOG_REAL +ln -sf $ENTRY_LOG_REAL $ENTRY_LOG +ln -sf $ENTRY_LOG_REAL $ENTRY_LOG_DIR/latest.log +echo "" > $ENTRY_LOG + +# Aggressive logging function - writes immediately with timestamp and syncs to disk +function log + set -l timestamp (date '+%H:%M:%S.%3N') + set -l msg "[$timestamp] $argv" + builtin echo $msg + builtin echo $msg >> $ENTRY_LOG_REAL 2>/dev/null + # Force sync to disk immediately so we don't lose logs on crash + sync 2>/dev/null +end + +# Log with specific level prefix +function log_info + log "ℹ️ $argv" +end + +function log_ok + log "✅ $argv" +end + +function log_warn + log "⚠️ $argv" +end + +function log_error + log "❌ $argv" +end + +function log_step + log "🔹 $argv" +end + +# Simple wrapper: append each echo to log file too (keep for backward compat) +# We override echo within this script +function echo + builtin echo $argv + builtin echo "[" (date '+%H:%M:%S') "] $argv" >> $ENTRY_LOG_REAL 2>/dev/null +end + +# Run a command and capture output to the entry log. +function log_cmd + set -l cmd_str (string join ' ' -- $argv) + log_step "Running: $cmd_str" + command $argv >> $ENTRY_LOG_REAL 2>&1 + set -l cmd_status $status + if test $cmd_status -ne 0 + log_error "Command failed ($cmd_status): $cmd_str" + else + log_ok "Command succeeded: $cmd_str" + end + sync 2>/dev/null + return $cmd_status +end + +# Run a command with a timeout if available. +function log_cmd_timeout + set -l seconds $argv[1] + set -l cmd $argv[2..-1] + log_step "Running with "$seconds"s timeout: "(string join ' ' -- $cmd) + if type -q timeout + log_cmd timeout $seconds $cmd + else + log_cmd $cmd + end + return $status +end + +# Start CDP tunnel without blocking startup if SSH auth is missing. +function start_cdp_tunnel + set -l ssh_args -f -N -o StrictHostKeyChecking=no -o BatchMode=yes -o ExitOnForwardFailure=yes -o ConnectTimeout=5 -o ConnectionAttempts=1 -L 9333:127.0.0.1:9333 me@host.docker.internal + log_cmd_timeout 8 ssh $ssh_args + return $status +end + +log "════════════════════════════════════════════════════════════════" +log "🚀 ENTRY.FISH STARTING" +log "════════════════════════════════════════════════════════════════" +log_info "Timestamp: "(date -Iseconds) +log_info "Log file: $ENTRY_LOG_REAL" +log_info "Log dir: $ENTRY_LOG_DIR" +log_info "Hostname: "(cat /etc/hostname 2>/dev/null; or echo "unknown") +log_info "User: "(whoami) +log_info "PWD: "(pwd) + +# 🔍 Start Devcontainer Status Server EARLY (so VS Code can see boot progress) +# This allows the Welcome Panel to show the boot process in real-time +log_step "EARLY START: Devcontainer Status Server" +if test -f /workspaces/aesthetic-computer/artery/devcontainer-status.mjs + # Kill any existing instance first + pkill -f "devcontainer-status" 2>/dev/null + sleep 0.1 + nohup node /workspaces/aesthetic-computer/artery/devcontainer-status.mjs --server >/tmp/devcontainer-status.log 2>&1 & + disown + log_ok "Status Server started early on http://127.0.0.1:7890" +else + log_warn "devcontainer-status.mjs not found, will try later" +end + +log_step "PHASE 1: Environment sync" + +# --- Sync secrets from vault to devcontainer env --- +# The vault stores the canonical secrets; copy them to devcontainer on startup +set -l vault_env ~/aesthetic-computer-vault/.devcontainer/envs/devcontainer.env +set -l devcontainer_env ~/aesthetic-computer/.devcontainer/envs/devcontainer.env +if test -f $vault_env + if test -f $devcontainer_env + # Merge: update devcontainer.env with any keys from vault (vault wins) + for line in (cat $vault_env) + set -l key (string split -m1 '=' -- $line)[1] + if test -n "$key" + # Remove old key if exists, then append new + sed -i "/^$key=/d" $devcontainer_env 2>/dev/null + echo $line >> $devcontainer_env + end + end + log_ok "Synced secrets from vault to devcontainer.env" + else + # No devcontainer.env, just copy from vault + cp $vault_env $devcontainer_env + log_ok "Copied secrets from vault to devcontainer.env" + end +else + log_warn "Vault env not found at $vault_env" +end + +# --- Setup session-server SSH key from vault --- +set -l ss_key_src ~/aesthetic-computer-vault/session-server/session_server +set -l ss_key_dst ~/.ssh/session_server +if test -f $ss_key_src; and not test -f $ss_key_dst + # Ensure .ssh directory exists with correct ownership and permissions + sudo mkdir -p ~/.ssh + sudo chown me:me ~/.ssh + sudo chmod 700 ~/.ssh + + cp $ss_key_src $ss_key_dst + cp "$ss_key_src.pub" "$ss_key_dst.pub" + chmod 600 $ss_key_dst + chmod 644 "$ss_key_dst.pub" + + # Create config file if it doesn't exist with correct ownership + if not test -f ~/.ssh/config + touch ~/.ssh/config + chmod 600 ~/.ssh/config + end + + # Add SSH config entry if missing + if not grep -q "Host session-server" ~/.ssh/config 2>/dev/null + echo "" >> ~/.ssh/config + echo "Host session-server" >> ~/.ssh/config + echo " HostName 157.245.134.225" >> ~/.ssh/config + echo " User root" >> ~/.ssh/config + echo " IdentityFile ~/.ssh/session_server" >> ~/.ssh/config + echo " IdentitiesOnly yes" >> ~/.ssh/config + echo " StrictHostKeyChecking accept-new" >> ~/.ssh/config + end + log_ok "Session-server SSH key installed" +else if not test -f $ss_key_src + log_warn "Session-server SSH key not found in vault" +end + +log_step "PHASE 2: Checking for fast reload path" + +# Fast path for VS Code "Reload Window" - if .waiter exists and key processes running, skip setup +if test -f /home/me/.waiter + log_info "Detected reload (found .waiter file)" + + # Check if key processes are already running + set -l dockerd_running (pgrep -x dockerd 2>/dev/null) + set -l emacs_running (pgrep -f "emacs.*daemon" 2>/dev/null) + + if test -n "$dockerd_running" + echo "✅ Docker daemon already running (PID: $dockerd_running)" + + # Check if emacs daemon is running AND responsive + set -l emacs_config /home/me/aesthetic-computer/dotfiles/dot_config/emacs.el + if test -n "$emacs_running" + if emacsclient -e t >/dev/null 2>&1 + echo "✅ Emacs daemon running and responsive (PID: $emacs_running)" + else + echo "⚠️ Emacs daemon zombie detected (PID: $emacs_running) - restarting..." + pkill -9 -f "emacs.*daemon" 2>/dev/null + pkill -9 emacs 2>/dev/null + sleep 1 + emacs -q --daemon -l $emacs_config 2>&1 + sleep 1 + if emacsclient -e t >/dev/null 2>&1 + echo "✅ Emacs daemon restarted successfully" + else + echo "❌ Failed to restart emacs daemon" + end + end + else + echo "⚠️ Emacs daemon not running - starting..." + emacs -q --daemon -l $emacs_config 2>&1 + sleep 1 + end + + # Ensure CDP tunnel is up (for artery-tui to control VS Code on host) + set -l cdp_tunnel (pgrep -f "ssh.*9333.*host.docker.internal" 2>/dev/null) + if test -n "$cdp_tunnel" + echo "✅ CDP tunnel already running (PID: $cdp_tunnel)" + else + echo "🩸 Starting CDP tunnel..." + start_cdp_tunnel + if test $status -eq 0 + echo "✅ CDP tunnel established (localhost:9333 → host:9333)" + else + echo "⚠️ CDP tunnel failed (artery may not control VS Code)" + end + end + + echo "⚡ Skipping full setup - container already configured" + echo "✅ Ready! (fast reload path)" + exit 0 + else + echo "⚠️ Docker daemon not found, running full setup..." + end +end + +# Fix fish path if needed (Fedora installs to /usr/sbin instead of /usr/bin) +if not test -f /usr/bin/fish; and test -f /usr/sbin/fish + sudo ln -sf /usr/sbin/fish /usr/bin/fish + echo "🐟 Created fish symlink: /usr/bin/fish -> /usr/sbin/fish" +end + +# Function to ensure fish config directory has correct permissions +function ensure_fish_config_permissions + set -l fish_config_dir /home/me/.config/fish + set -l fish_data_dir /home/me/.local/share/fish + + echo "🔧 Fixing permissions for fish directories..." + + # Fix the parent .config directory first (most important) + if test -d /home/me/.config + sudo chown -R me:me /home/me/.config 2>/dev/null + sudo chmod -R 755 /home/me/.config 2>/dev/null + echo "✅ Fixed permissions for /home/me/.config" + end + + if test -d $fish_config_dir + # Fix ownership and permissions for fish config directory - be VERY aggressive + sudo chown -R me:me $fish_config_dir 2>/dev/null + sudo chmod -R 755 $fish_config_dir 2>/dev/null + sudo chmod 644 $fish_config_dir/*.fish 2>/dev/null + # Also fix individual subdirectories explicitly + sudo chown -R me:me $fish_config_dir/conf.d 2>/dev/null + sudo chown -R me:me $fish_config_dir/functions 2>/dev/null + sudo chown -R me:me $fish_config_dir/completions 2>/dev/null + echo "✅ Fixed permissions for $fish_config_dir" + end + + if test -d $fish_data_dir + # Fix ownership and permissions for fish data directory + sudo chown -R me:me $fish_data_dir 2>/dev/null + sudo chmod -R 755 $fish_data_dir 2>/dev/null + echo "✅ Fixed permissions for $fish_data_dir" + end + + # Fix /home/me/.local directory too + if test -d /home/me/.local + sudo chown -R me:me /home/me/.local 2>/dev/null + sudo chmod -R 755 /home/me/.local 2>/dev/null + echo "✅ Fixed permissions for /home/me/.local" + end + + # Disable fish's universal variable file daemon (fishd) which causes permission issues + # We'll use fish_variables instead which is simpler and doesn't create temp files + set -U fish_greeting "" # Suppress greeting while we're at it + + echo "✨ All permissions fixed!" +end + +# Function to ensure correct Docker socket permissions +function ensure_docker_socket_permissions + set -l DOCKER_SOCKET /var/run/docker.sock + if test -S $DOCKER_SOCKET + set -l DOCKER_GID (stat -c '%g' $DOCKER_SOCKET) + set -l CURRENT_DOCKER_GROUP_INFO (getent group $DOCKER_GID) + + if test -z "$CURRENT_DOCKER_GROUP_INFO" # No group with this GID exists + if getent group docker > /dev/null + echo "Modifying existing docker group to GID $DOCKER_GID..." + sudo groupmod -g $DOCKER_GID docker + else + echo "Creating new docker group with GID $DOCKER_GID..." + sudo groupadd -g $DOCKER_GID docker + end + else # A group with this GID already exists, check if it's named 'docker' + set -l GROUP_NAME (echo $CURRENT_DOCKER_GROUP_INFO | cut -d: -f1) + if test "$GROUP_NAME" != "docker" + echo "Warning: Group with GID $DOCKER_GID exists but is named '$GROUP_NAME', not 'docker'. Manual intervention might be needed." + # Optionally, you could decide to add 'me' to this group anyway, + # or try to rename it if it's safe, or delete and recreate 'docker' group. + # For now, we'll proceed assuming 'docker' is the target group name. + if getent group docker > /dev/null + echo "Modifying existing docker group to GID $DOCKER_GID..." + sudo groupmod -g $DOCKER_GID docker + else + echo "Creating new docker group with GID $DOCKER_GID..." + sudo groupadd -g $DOCKER_GID docker + end + else + echo "Docker group '$GROUP_NAME' with GID $DOCKER_GID already exists." + end + end + + # Ensure 'me' user is in the 'docker' group + if not groups me | string match -q -r '\\bdocker\\b' + echo "Adding user 'me' to docker group..." + sudo usermod -aG docker me + # Changes to group membership may require a new login session or `newgrp docker` + # to take effect immediately in the current shell. + # For a script, subsequent commands in this same script might not see the new group immediately. + # However, for processes spawned after this script, it should be fine. + else + echo "User 'me' is already in docker group." + end + else + echo "Docker socket $DOCKER_SOCKET not found." + end +end + +function install_and_trust_certificate + set -l certificate_path $argv[1] + set -l certificate_key_path $argv[2] + set -l certificate_dir /etc/pki/ca-trust/source/anchors/ + + if test -z "$certificate_path"; or test -z "$certificate_key_path" + return + end + + if test -f $certificate_path + sudo cp $certificate_path $certificate_dir + end + + if test -f $certificate_key_path + sudo cp $certificate_key_path $certificate_dir + end + + sudo update-ca-trust +end + +function ensure_ssl_dev_certs + set -l ssl_dir /home/me/aesthetic-computer/ssl-dev + set -l nanos_ssl_dir /home/me/aesthetic-computer/nanos/ssl + + if not test -d $ssl_dir + return + end + + if not type -q mkcert + echo "⚠️ mkcert not available; skipping automatic certificate generation." + return + end + + set -l previous_dir (pwd) + cd $ssl_dir + + set -l have_cert 1 + if not test -f localhost.pem; or not test -f localhost-key.pem + set have_cert 0 + end + + if test $have_cert -eq 0 + echo "🔐 Generating mkcert certificates for the dev container..." + if not test -d /home/me/.local/share/mkcert + echo "📥 Installing mkcert local CA..." + log_cmd_timeout 60 mkcert -install + if test $status -ne 0 + echo "⚠️ mkcert CA installation failed. See entry.fish log for details." + end + if not type -q certutil + echo "📦 Installing nss-tools for certificate trust..." + log_cmd_timeout 300 sudo dnf install -y nss-tools + log_cmd_timeout 60 mkcert -install + end + end + + log_cmd_timeout 120 env nogreet=true fish ./ssl-install.fish + if test $status -ne 0 + echo "⚠️ Automatic certificate generation failed using ssl-install.fish." + cd $previous_dir + return + end + else + log_cmd_timeout 120 env nogreet=true fish ./ssl-install.fish --install-only + end + + mkdir -p $nanos_ssl_dir + cp localhost.pem $nanos_ssl_dir/localhost.pem + cp localhost-key.pem $nanos_ssl_dir/localhost-key.pem + + sudo chown me:me -R $ssl_dir + sudo chown me:me -R $nanos_ssl_dir + + install_and_trust_certificate $ssl_dir/localhost.pem $ssl_dir/localhost-key.pem + + cd $previous_dir +end + +log_step "PHASE 3: Docker socket permissions" +# Call the function to set up Docker socket permissions +ensure_docker_socket_permissions + +log_step "PHASE 4: Starting daemons" +# Start Docker daemon in the background if not already running +if not pgrep -x dockerd >/dev/null + log_info "Starting Docker daemon..." + sudo dockerd >/tmp/dockerd.log 2>&1 & +else + log_ok "Docker daemon already running." +end + +# Start Ollama daemon in the background if available and not already running +if type -q ollama + if not pgrep -x ollama >/dev/null + log_info "Starting Ollama daemon..." + ollama serve >/tmp/ollama.log 2>&1 & + else + log_ok "Ollama daemon already running." + end +else + log_warn "Ollama not found; skipping." +end + +log_step "PHASE 4b: Cockpit web interface" +# Start Cockpit web server (no TLS for local dev, port 9090) +# Terminal + system overview work without systemd; service/journal features need systemd +set -l cockpit_ws_bin "" +for p in /usr/lib/cockpit/cockpit-ws /usr/libexec/cockpit-ws + if test -x $p + set cockpit_ws_bin $p + break + end +end + +if test -n "$cockpit_ws_bin" + if not pgrep -f "cockpit-ws" >/dev/null + # Set a dev password so PAM auth works (use vault COCKPIT_PASSWORD if set) + set -l cockpit_pass (test -n "$COCKPIT_PASSWORD"; and echo $COCKPIT_PASSWORD; or echo "aesthetic") + echo "me:$cockpit_pass" | sudo chpasswd 2>/dev/null + sudo $cockpit_ws_bin --no-tls --port=9090 >/tmp/cockpit-ws.log 2>&1 & + disown + log_ok "Cockpit started → http://localhost:9090 (login: me / $cockpit_pass)" + else + log_ok "Cockpit already running" + end +else + log_warn "Cockpit not installed — skipping (rebuild container to add it)" +end + +log_step "PHASE 5: Environment setup" +# Ensure the envs directory exists and is accessible (fallback if mount fails) +if not test -d /home/me/envs + log_info "Creating missing /home/me/envs directory and linking to .devcontainer/envs" + mkdir -p /home/me/envs + ln -sf /workspaces/aesthetic-computer/.devcontainer/envs/* /home/me/envs/ +end + +if test -d /home/me/envs + source /home/me/envs/load_envs.fish + load_envs # Load devcontainer envs conditionally. + echo "🔧 Environment variables loaded from devcontainer.env" +end + +# Set environment variables to prevent ETXTBSY errors and disable telemetry +set -gx NETLIFY_CLI_TELEMETRY_DISABLED 1 +set -gx NODE_DISABLE_COMPILE_CACHE 1 + +set -gx TERM xterm-256color + +# Create xdg-open wrapper to open URLs on Windows host from dev container +if not test -f /usr/local/bin/xdg-open + echo "🌐 Creating xdg-open wrapper for host browser..." + printf '#!/bin/bash\n"\$BROWSER" "\$@"\n' | sudo tee /usr/local/bin/xdg-open >/dev/null + sudo chmod +x /usr/local/bin/xdg-open +end + +# Start dbus-run-session for the keryring and execute the command passed to the script +# dbus-run-session -- sh -c "eval \$(echo \$DBUS_SESSION_BUS_ADDRESS); exec $argv" + +# Send a welcome message! +toilet "Aesthetic Computer" -f future | lolcat -x -r + +# Go to the user's directory. +cd /home/me + +log_step "PHASE 6: SSL certificates" +ensure_ssl_dev_certs + +log_step "PHASE 7: GitHub authentication" +# Login to Github - use GH_TOKEN from vault if available +set -l gh_authenticated 1 +if not gh auth status >/dev/null 2>&1 + if test -n "$GH_TOKEN" + log_info "Authenticating to GitHub using GH_TOKEN..." + echo $GH_TOKEN | gh auth login --with-token + if gh auth status >/dev/null 2>&1 + log_ok "GitHub authentication successful" + else + log_error "GitHub authentication failed" + log_warn "Continuing without GitHub auth; vault/git operations may be limited." + set gh_authenticated 0 + end + else + log_warn "Not logged into GitHub and no GH_TOKEN available." + log_warn "Continuing startup without GitHub auth." + set gh_authenticated 0 + end +else + log_ok "Already authenticated to GitHub" +end + +log_step "PHASE 8: Git configuration" +if test -n "$GIT_USER_EMAIL" + git config --global user.email $GIT_USER_EMAIL + log_info "Set git user email to: $GIT_USER_EMAIL" +end + +if test -n "$GIT_USER_NAME" + git config --global user.name $GIT_USER_NAME + log_info "Set git user name to: $GIT_USER_NAME" +end + +# Add aesthetic-computer as the "safe" directory. +git config --global --add safe.directory /home/me/aesthetic-computer + +# Set rebase as default for pull operations. +git config --global pull.rebase true + +# Disable GPG signing to prevent commit issues in dev environment +git config --global commit.gpgsign false + +# Make sure git is setup and authorized for making commits via `gh`. +if test $gh_authenticated -eq 1 + gh auth setup-git +else + log_warn "Skipping 'gh auth setup-git' because GitHub auth is unavailable." +end + +# Ensure GPG signing is disabled at both global and local levels (GitHub CLI might re-enable it) +git config --global commit.gpgsign false +cd /home/me/aesthetic-computer +git config --local commit.gpgsign false +git config --local user.signingkey "" +echo "🔓 GPG signing disabled for commits" + +# Disable Netlify CLI telemetry to prevent ETXTBSY errors +echo "Disabling Netlify CLI telemetry..." +cd /home/me/aesthetic-computer/system && netlify --telemetry-disable 2>/dev/null || echo "Netlify telemetry disable completed" + +# Add aesthetic.local and sotce.local to /etc/hosts if they don't already exist +if not grep -q "aesthetic.local" /etc/hosts + echo "127.0.0.1 aesthetic.local" | sudo tee -a /etc/hosts +end + +if not grep -q "sotce.local" /etc/hosts + echo "127.0.0.1 sotce.local" | sudo tee -a /etc/hosts +end + +log_step "PHASE 9: Vault setup" +# Apply the 'vault' credentials to the mounted aesthetic-computer volume, and make sure it exists. +if test -d /home/me/aesthetic-computer + if not test -d /home/me/aesthetic-computer/aesthetic-computer-vault + log_info "Cloning vault repository..." + gh repo clone whistlegraph/aesthetic-computer-vault /home/me/aesthetic-computer/aesthetic-computer-vault + cd /home/me/aesthetic-computer/aesthetic-computer-vault + log_info "Running devault.fish..." + sudo fish devault.fish + + # Load environment variables after initial vault setup + if test -d /home/me/envs + source /home/me/envs/load_envs.fish + load_envs # Load envs after initial vault setup + log_ok "Environment variables loaded after initial vault setup" + end + else + cd /home/me/aesthetic-computer/aesthetic-computer-vault + log_info "Pulling latest vault..." + git pull + sudo fish devault.fish + log_ok "Vault mounted." + end + + # Reload environment variables after vault is mounted + if test -d /home/me/envs + source /home/me/envs/load_envs.fish + load_envs # Reload envs after vault mount + log_ok "Environment variables reloaded after vault mount" + end + + # Setup SSH keys from vault (only if not already set up) + set -l vault_ssh /home/me/aesthetic-computer/aesthetic-computer-vault/home/.ssh + if test -d $vault_ssh + # Only copy if id_rsa doesn't exist yet (first time setup) + if not test -f /home/me/.ssh/id_rsa + # Ensure .ssh directory exists with correct ownership and permissions + sudo mkdir -p /home/me/.ssh + sudo chown -R me:me /home/me/.ssh 2>/dev/null + sudo chmod 700 /home/me/.ssh 2>/dev/null + + cp -f $vault_ssh/* /home/me/.ssh/ 2>/dev/null + chmod 700 /home/me/.ssh 2>/dev/null + chmod 600 /home/me/.ssh/id_rsa 2>/dev/null + chmod 644 /home/me/.ssh/id_rsa.pub 2>/dev/null + chmod 600 /home/me/.ssh/config 2>/dev/null + chmod 600 /home/me/.ssh/aesthetic_pds 2>/dev/null + chmod 644 /home/me/.ssh/aesthetic_pds.pub 2>/dev/null + log_ok "SSH keys restored from vault" + else + log_info "SSH keys already exist, skipping vault copy" + end + + # Always restore ~/.ssh/config from vault (even if keys already exist) + # so that 'Host aesthetic' / 'Host windows-host' / etc. edits to the + # tracked config propagate to running containers on subsequent starts. + if test -f $vault_ssh/config + cp -f $vault_ssh/config /home/me/.ssh/config 2>/dev/null + chmod 600 /home/me/.ssh/config 2>/dev/null + end + end + + # Ensure SSH bridge to Windows host (ac-ssh-bridge) is running on the + # Fedora host's Docker daemon. The bridge is a --network=host alpine/socat + # container that listens on :2222 on the Fedora side and forwards to + # 172.19.64.1:22 (WSL2 NAT gateway to Windows sshd). This lets the + # devcontainer reach Windows via 'ssh aesthetic' (see ~/.ssh/config). + # See memory/ssh_bridge_to_windows.md for the full topology writeup. + if test -S /var/run/docker.sock + if sudo -n docker inspect -f '{{.State.Running}}' ac-ssh-bridge 2>/dev/null | string match -q true + log_info "SSH bridge to Windows (ac-ssh-bridge) already running" + else + # Probe for the Windows host via the default gateway of a host-networked + # helper container (that's Fedora's default route, which is the WSL2 gateway + # = the Windows host from Fedora's namespace). Skip bridge setup if we + # can't figure it out or if docker is unavailable. + set -l win_ip (sudo -n docker run --rm --network=host alpine sh -c 'ip route | awk "/default/ {print \$3}"' 2>/dev/null) + if test -n "$win_ip" + sudo -n docker rm -f ac-ssh-bridge 2>/dev/null + if sudo -n docker run -d --name ac-ssh-bridge --restart unless-stopped --network=host alpine/socat "TCP-LISTEN:2222,fork,reuseaddr" "TCP:$win_ip:22" >/dev/null 2>&1 + log_ok "SSH bridge to Windows started (ac-ssh-bridge -> $win_ip:22)" + else + log_info "SSH bridge to Windows not started (docker run failed — Windows not reachable or non-standard topology)" + end + else + log_info "SSH bridge to Windows not started (could not detect Windows host IP)" + end + end + end + + # Setup Copilot CLI config from vault (if volume is empty but vault has backup) + set -l vault_copilot /home/me/aesthetic-computer/aesthetic-computer-vault/home/.copilot + if test -d $vault_copilot + # Only restore if volume mount is empty (no config.json) + if not test -f /home/me/.copilot/config.json + mkdir -p /home/me/.copilot + cp -f $vault_copilot/config.json /home/me/.copilot/ 2>/dev/null + log_ok "Copilot CLI config restored from vault" + else + log_info "Copilot CLI config already exists (using volume data)" + end + end +else + log_error "Vault unmounted! /home/me/aesthetic-computer not found" +end + +log_step "PHASE 10: Fixing permissions" +# Fix all permissions AFTER vault setup (vault copies files as root with sudo) +log_info "Fixing permissions after vault setup..." + +# First, ensure the home directory itself is owned by me (critical for .emacs-logs etc) +sudo chown me:me /home/me 2>/dev/null +log_ok "Fixed ownership of /home/me" + +# Create .emacs-logs directory if it doesn't exist +mkdir -p /home/me/.emacs-logs 2>/dev/null +log_ok "Ensured /home/me/.emacs-logs exists" + +# Fix fish config permissions (vault may have overwritten with root-owned files) +log_info "Fixing fish config permissions..." +ensure_fish_config_permissions + +# Fix SSH directory permissions (use sudo to fix ownership if needed) +if test -d /home/me/.ssh + if not test -O /home/me/.ssh + # SSH dir owned by root (probably from vault setup), fix ownership + sudo chown -R me:me /home/me/.ssh 2>/dev/null + log_ok "Fixed SSH directory ownership" + end + chmod 700 /home/me/.ssh 2>/dev/null + chmod 600 /home/me/.ssh/* 2>/dev/null + chmod 644 /home/me/.ssh/*.pub 2>/dev/null + log_ok "Fixed permissions for /home/me/.ssh" +end + +# Fix Copilot CLI directory permissions (volume mount may have root ownership) +if test -d /home/me/.copilot + sudo chown -R me:me /home/me/.copilot 2>/dev/null + sudo chmod 700 /home/me/.copilot 2>/dev/null + # Ensure pkg directory exists and is writable for package extraction + mkdir -p /home/me/.copilot/pkg 2>/dev/null + chmod 755 /home/me/.copilot/pkg 2>/dev/null + sudo chmod 600 /home/me/.copilot/config.json 2>/dev/null + echo "✅ Fixed permissions for /home/me/.copilot (Copilot CLI config)" +end + +# Fix Claude/Codex config directory permissions after bind mounts are attached. +# On Linux hosts, these mounts keep host ownership, so UID remapping is the main +# fix; this container-side pass normalizes modes when ownership is already writable. +for dir in /home/me/.claude /home/me/.codex + if test -d $dir + sudo chown -R me:me $dir 2>/dev/null + chmod 700 $dir 2>/dev/null + find $dir -type d -exec chmod 700 {} + 2>/dev/null + find $dir -type f -exec chmod 600 {} + 2>/dev/null + echo "✅ Fixed permissions for $dir" + end +end + +if test -f /home/me/.claude.json + sudo chown me:me /home/me/.claude.json 2>/dev/null + chmod 600 /home/me/.claude.json 2>/dev/null + echo "✅ Fixed permissions for /home/me/.claude.json" +end + +# Fix VS Code extension SecretStorage persistence paths. +for dir in /home/me/.config/Code/User/globalStorage /home/me/.config/Code/User/workspaceStorage + if test -d $dir + sudo chown -R me:me $dir 2>/dev/null + find $dir -type d -exec chmod 700 {} + 2>/dev/null + find $dir -type f -exec chmod 600 {} + 2>/dev/null + echo "✅ Fixed permissions for $dir" + end +end + +if test -d /home/me/.local/share/keyrings + sudo chown -R me:me /home/me/.local/share/keyrings 2>/dev/null + chmod 700 /home/me/.local/share/keyrings 2>/dev/null + find /home/me/.local/share/keyrings -type f -exec chmod 600 {} + 2>/dev/null + echo "✅ Fixed permissions for /home/me/.local/share/keyrings" +end + +# --- GPG agent: cache passphrase for the entire container lifetime --- +mkdir -p /home/me/.gnupg 2>/dev/null +chmod 700 /home/me/.gnupg +printf "allow-loopback-pinentry\npinentry-program /usr/sbin/pinentry-curses\ndefault-cache-ttl 999999\nmax-cache-ttl 999999\n" > /home/me/.gnupg/gpg-agent.conf +gpgconf --reload gpg-agent 2>/dev/null +log_ok "GPG agent configured (passphrase cached for container lifetime)" + +cd /home/me/aesthetic-computer/aesthetic-computer-vault +git pull + +# Function to check and install npm dependencies in a directory +set -g NODE_DEPS_CHECK_SCRIPT /workspaces/aesthetic-computer/.devcontainer/scripts/check-node-deps.mjs + +function verify_npm_versions + set -l dir $argv[1] + set -l dir_name (basename $dir) + + if not test -f $NODE_DEPS_CHECK_SCRIPT + return + end + + if not test -f $dir/package.json + return + end + + if not test -d $dir/node_modules + return + end + + log_cmd node $NODE_DEPS_CHECK_SCRIPT $dir + set -l check_status $status + + if test $check_status -ne 0 + echo "♻️ Resyncing dependencies in $dir_name to match package.json versions..." + cd $dir + log_cmd npm ci --no-fund --no-audit + if test $status -ne 0 + echo "⚠️ npm ci failed in $dir_name, falling back to npm install" + log_cmd npm install --no-fund --no-audit + end + end +end + +# Function to check and fix esbuild architecture mismatches +function fix_esbuild_architecture + set -l dir $argv[1] + set -l dir_name (basename $dir) + + # Check if this directory has esbuild installed + if test -d $dir/node_modules/@esbuild + set -l arch (uname -m) + set -l expected_esbuild + + # Determine expected esbuild platform package + if test "$arch" = "x86_64" + set expected_esbuild "linux-x64" + else if test "$arch" = "aarch64" + set expected_esbuild "linux-arm64" + else + return 0 # Unknown architecture, skip + end + + # Check what's actually installed + set -l installed_arch (ls $dir/node_modules/@esbuild 2>/dev/null | grep -E "^linux-") + + if test -n "$installed_arch"; and test "$installed_arch" != "$expected_esbuild" + echo "🔧 Wrong esbuild architecture in $dir_name: $installed_arch (need $expected_esbuild)" + echo " Reinstalling dependencies to fix..." + cd $dir + rm -rf node_modules package-lock.json + log_cmd npm install --no-fund --no-audit + if test $status -eq 0 + echo "✅ Fixed esbuild architecture in $dir_name" + else + echo "⚠️ Failed to fix esbuild in $dir_name" + end + end + end +end + +function install_npm_deps + set -l dir $argv[1] + set -l dir_name (basename $dir) + + if test -f $dir/package.json + if not test -d $dir/node_modules || not count $dir/node_modules/* >/dev/null 2>/dev/null + echo "📦 Installing dependencies in $dir_name..." + cd $dir + log_cmd npm ci --no-fund --no-audit + if test $status -ne 0 + echo "⚠️ Failed to install dependencies in $dir_name, trying npm install..." + log_cmd npm install --no-fund --no-audit + end + else + echo "✅ $dir_name already has node_modules" + # Check for architecture mismatches even if node_modules exists + fix_esbuild_architecture $dir + end + verify_npm_versions $dir + end +end + +log_step "PHASE 11: NPM dependencies" +# Check and install dependencies in key directories +cd /home/me/aesthetic-computer + +# Install root dependencies first +if not test -d /home/me/aesthetic-computer/node_modules || not count /home/me/aesthetic-computer/node_modules/* >/dev/null + log_info "Installing root dependencies..." + log_cmd npm install -g npm@latest --no-fund --no-audit + log_cmd npm ci --no-fund --no-audit +else + log_ok "Root directory already has node_modules" +end + +log_info "Verifying npm versions..." +verify_npm_versions /home/me/aesthetic-computer + +# Install dependencies in critical subdirectories (archive intentionally excluded) +set -l critical_dirs system session-server vscode-extension nanos daemon utilities shared + +log_info "Installing dependencies in critical directories..." +for dir in $critical_dirs + if test -d /home/me/aesthetic-computer/$dir + log_info "Processing $dir..." + install_npm_deps /home/me/aesthetic-computer/$dir + end +end + +# Run the comprehensive install script for any remaining directories +log_info "Running comprehensive install script for remaining directories..." +pushd /home/me/aesthetic-computer +log_cmd npm run install:everything-else +popd + +log_step "PHASE 12: Final setup" +# Make sure this user owns the emacs directory. +sudo chown -R me:me ~/.emacs.d + +# Link the prompts directory to VSCode User config +mkdir -p ~/.config/Code/User +rm -rf ~/.config/Code/User/prompts +ln -s /home/me/aesthetic-computer/prompts ~/.config/Code/User/prompts +log_ok "Prompts directory linked to VSCode User config" + +# Link the modes directory to .github for VSCode chatmodes +rm -rf /home/me/aesthetic-computer/.github +ln -s /home/me/aesthetic-computer/modes /home/me/aesthetic-computer/.github +log_ok "Modes directory linked to .github for VSCode chatmodes" + +# Source the devcontainer config to load AC development functions +source /workspaces/aesthetic-computer/.devcontainer/config.fish +log_ok "AC development functions loaded (ac-pack, ac-unpack, etc.)" + +# Copy feed secrets from vault to environment (if vault exists) +if test -f /workspaces/aesthetic-computer/aesthetic-computer-vault/feed/.env + log_info "Loading feed system environment variables from vault..." + # Copy to devcontainer envs directory for persistence + cp /workspaces/aesthetic-computer/aesthetic-computer-vault/feed/.env /home/me/envs/feed.env + # Source it globally for the session + for line in (cat /workspaces/aesthetic-computer/aesthetic-computer-vault/feed/.env | grep -v '^#' | grep '=') + set -gx (string split '=' $line) + end + log_ok "Feed environment variables loaded" +else + echo "⚠️ Feed vault not found - skipping feed environment setup" +end + +# Set port 8888 to public in Codespaces (if applicable) +if test -f /workspaces/aesthetic-computer/.devcontainer/scripts/set-port-public.fish + log_cmd fish /workspaces/aesthetic-computer/.devcontainer/scripts/set-port-public.fish +end + +# Create .waiter file to signal container is ready (moved from postAttachCommand to avoid terminal popup in Codespaces) +sudo touch /home/me/.waiter +sudo chmod 777 /home/me/.waiter +sudo chmod +w /home/me +echo "✅ Container ready signal created (.waiter)" + +# NOTE: Emacs daemon is NOT pre-started here. The `aesthetic` function +# (called by the VS Code "💻 Aesthetic" task) unconditionally kills any +# running daemon and starts fresh via `ensure-emacs-daemon-ready`. +# Pre-starting here caused a race condition: entry.fish would start a +# daemon, then the VS Code task would immediately kill it and start +# another, wasting resources and causing load spikes that made the +# second daemon unresponsive to the crash monitor. +log_step "PHASE 12b: Emacs daemon (deferred to VS Code task)" +log_info "Emacs daemon start deferred - will be started by 'aesthetic' function" + +# 🩸 Setup SSH tunnel for CDP (Chrome DevTools Protocol) to host +# This allows artery-tui to control VS Code on the host machine +if test -n "$HOST_IP"; or test (uname -s) != "Linux" + # Kill any existing CDP tunnels + pkill -f "ssh.*9333.*host.docker.internal" 2>/dev/null + + # Create tunnel in background (port 9333 to avoid conflicts with svchost.exe on 9222) + start_cdp_tunnel + if test $status -eq 0 + echo "🩸 CDP tunnel established (localhost:9333 → host:9333)" + else + echo "⚠️ CDP tunnel failed (artery may not work)" + end +end + +# 🧊 Start TURN server for WebRTC relay (required for UDP in Docker) +if which turnserver >/dev/null 2>&1 + pkill -f "turnserver" 2>/dev/null + sleep 0.5 + # Get host LAN IP from vault/machines.json or use default + set -l HOST_LAN_IP "192.168.1.127" + set -l HOST_LAN_IP_SOURCE "default" + if test -f /workspaces/aesthetic-computer/aesthetic-computer-vault/machines.json + set -l detected_ip (cat /workspaces/aesthetic-computer/aesthetic-computer-vault/machines.json 2>/dev/null | jq -r '.["aesthetic-mac"].lan_ip // empty' 2>/dev/null) + if test -n "$detected_ip" + set HOST_LAN_IP $detected_ip + set HOST_LAN_IP_SOURCE "vault" + end + end + log_info "TURN host IP: $HOST_LAN_IP (source: $HOST_LAN_IP_SOURCE)" + echo $HOST_LAN_IP > /tmp/host-lan-ip + turnserver -c /workspaces/aesthetic-computer/.devcontainer/turnserver.conf --external-ip=$HOST_LAN_IP >> $ENTRY_LOG_REAL 2>&1 & + disown + log_ok "TURN server started (localhost:3478, external-ip: $HOST_LAN_IP)" +else + log_warn "coturn not installed, UDP may not work across networks" +end + +# echo "Initializing 📋 Clipboard Service" | lolcat -x -r + +# ❤️‍🔥 +# TODO: This may not be the best method because cdocker aesthetic needs to be +# running in the user environment that has the clipboard. + +# Run the isomorphic_copy clipboard on the host. +# Make sure the host allows ssh access from its own private key... `ssh-copy-id -i ~/.ssh/id_rsa.pub $USER@localhost` +# ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null $HOST_USER@172.17.0.1 + +# ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null $HOST_USER@172.17.0.1 "cdocker aesthetic" + +# 🔍 Ensure Devcontainer Status Server is still running (it was started early) +log_info "Checking Devcontainer Status Server..." +if pgrep -f "devcontainer-status" >/dev/null + log_ok "Status Server still running on http://127.0.0.1:7890" +else if test -f /workspaces/aesthetic-computer/artery/devcontainer-status.mjs + log_warn "Status Server died, restarting..." + nohup node /workspaces/aesthetic-computer/artery/devcontainer-status.mjs --server >/tmp/devcontainer-status.log 2>&1 & + disown + log_ok "Status Server restarted on http://127.0.0.1:7890" +end + +log "════════════════════════════════════════════════════════════════" +log "🎉 ENTRY.FISH COMPLETED SUCCESSFULLY" +log "════════════════════════════════════════════════════════════════" +log_info "End time: "(date -Iseconds) +log_info "Log file: $ENTRY_LOG_REAL" + +# Auto-launch is handled by the VS Code task (💻 Aesthetic) which runs on folder open +# The task calls aesthetic-launch.sh which properly launches the emacs client +# Background launch from entry.fish doesn't work because emacsclient -nw needs an interactive terminal +log_info "Container setup complete - VS Code task will launch aesthetic platform" diff --git a/.devcontainer/scripts/setup-ssh-to-windows.fish b/.devcontainer/scripts/setup-ssh-to-windows.fish index a238fdbf95..eee7974355 100755 --- a/.devcontainer/scripts/setup-ssh-to-windows.fish +++ b/.devcontainer/scripts/setup-ssh-to-windows.fish @@ -1,88 +1,110 @@ #!/usr/bin/env fish -# Setup SSH connection from devcontainer to Windows host +# Setup / verify SSH connection from this devcontainer to the Windows host +# (jeffrey's Windows 11 tower, computer name 'Aesthetic'). +# +# This script is IDEMPOTENT and SAFE to re-run. The heavy lifting lives in +# entry.fish (the bridge container is ensured automatically on every container +# start). This script is for manual diagnostics, recovering after a broken +# state, or first-time setup on a new host. +# +# Topology: +# devcontainer (172.17.0.2) +# --> 172.17.0.1:2222 (socat container 'ac-ssh-bridge' on Fedora's Docker +# in --network=host mode) +# --> 172.19.64.1:22 (WSL2 NAT gateway, from Fedora WSL distro's view) +# --> Windows OpenSSH (binds 0.0.0.0:22, key authorized at +# C:\ProgramData\ssh\administrators_authorized_keys) +# +# For the full writeup see: +# ~/.claude/projects/-workspaces-aesthetic-computer/memory/ssh_bridge_to_windows.md -echo "🪟 Setting up SSH to Windows host..." +echo "🪟 Setup / verify SSH to Windows host" echo "" -# Detect Windows host IP (usually the Docker gateway) -set WINDOWS_IP (ip route | grep default | awk '{print $3}') -echo "Windows host IP: $WINDOWS_IP" +# ----------------------------------------------------------------------------- +# Step 1: Verify vault id_rsa is in place +# ----------------------------------------------------------------------------- +if not test -f ~/.ssh/id_rsa + echo "❌ ~/.ssh/id_rsa not found" + echo " Run devault.fish or let entry.fish restore it from the vault:" + echo " cd /workspaces/aesthetic-computer/aesthetic-computer-vault && fish devault.fish" + exit 1 +end +echo "✅ ~/.ssh/id_rsa present" -# Check if SSH keys exist -if not test -f ~/.ssh/id_ed25519 - and not test -f ~/.ssh/id_rsa +# ----------------------------------------------------------------------------- +# Step 2: Verify ~/.ssh/config has a 'Host aesthetic' (or 'windows-host') block +# ----------------------------------------------------------------------------- +if not grep -qE '^Host .*(aesthetic|windows-host)' ~/.ssh/config 2>/dev/null + echo "❌ No 'Host aesthetic' or 'Host windows-host' block in ~/.ssh/config" + echo " The vault's home/.ssh/config should contain it. Run devault.fish" + echo " to restore it, or add the block manually. Expected content:" echo "" - echo "📝 No SSH key found. Generating one..." - ssh-keygen -t ed25519 -C "devcontainer@aesthetic-computer" -f ~/.ssh/id_ed25519 -N "" - echo "✅ SSH key generated!" + echo " Host aesthetic aesthetic-windows jeffrey-windows windows-host" + echo " HostName 172.17.0.1" + echo " Port 2222" + echo " User me" + echo " IdentityFile ~/.ssh/id_rsa" + echo " IdentitiesOnly yes" + exit 1 end +echo "✅ ~/.ssh/config has Host block for aesthetic/windows-host" -# Determine which key to use -set SSH_KEY_FILE "" -if test -f ~/.ssh/id_ed25519.pub - set SSH_KEY_FILE ~/.ssh/id_ed25519.pub -else if test -f ~/.ssh/id_rsa.pub - set SSH_KEY_FILE ~/.ssh/id_rsa.pub +# ----------------------------------------------------------------------------- +# Step 3: Verify / (re)start the socat bridge container on the Fedora host +# ----------------------------------------------------------------------------- +if not test -S /var/run/docker.sock + echo "❌ No /var/run/docker.sock — cannot manage the bridge from inside the devcontainer" + echo " The devcontainer needs the Docker socket mounted to start the bridge." + exit 1 end -echo "" -echo "📋 Your public key:" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -cat $SSH_KEY_FILE -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - -echo "" -echo "📝 Next steps on Windows (run PowerShell as Administrator):" -echo "" -echo "1. Run the setup script:" -echo " powershell -ExecutionPolicy Bypass -File (wslpath -w /workspaces/aesthetic-computer/.devcontainer/scripts/setup-windows-ssh.ps1)" -echo "" -echo "2. Or manually:" -echo " - Make sure OpenSSH Server is installed and running" -echo " - Create C:\\ProgramData\\ssh\\administrators_authorized_keys" -echo " - Add the public key above to that file" -echo " - Set permissions (see setup-windows-ssh.ps1)" -echo "" - -# Create SSH config entry -set SSH_CONFIG ~/.ssh/config -echo "" -echo "🔧 Adding SSH config for Windows host..." - -# Check if entry already exists -if grep -q "Host windows-host" $SSH_CONFIG 2>/dev/null - echo "⚠️ SSH config entry already exists, skipping..." +set bridge_state (sudo -n docker inspect -f '{{.State.Running}}' ac-ssh-bridge 2>/dev/null) +if test "$bridge_state" = "true" + echo "✅ ac-ssh-bridge container is running" else - # Create config file if it doesn't exist - touch $SSH_CONFIG - chmod 600 $SSH_CONFIG + echo "🔧 ac-ssh-bridge not running, starting it..." - # Add Windows host entry - echo "" >> $SSH_CONFIG - echo "# Windows Docker Host" >> $SSH_CONFIG - echo "Host windows-host" >> $SSH_CONFIG - echo " HostName $WINDOWS_IP" >> $SSH_CONFIG - echo " User $USER" >> $SSH_CONFIG - if test -f ~/.ssh/id_ed25519 - echo " IdentityFile ~/.ssh/id_ed25519" >> $SSH_CONFIG - else - echo " IdentityFile ~/.ssh/id_rsa" >> $SSH_CONFIG + # Detect the Windows host IP by running a helper in --network=host mode. + # Its default route IS the Fedora host's default route, which in WSL2 NAT + # mode points at the Windows host. + set win_ip (sudo -n docker run --rm --network=host alpine sh -c 'ip route | awk "/default/ {print \$3}"' 2>/dev/null) + if test -z "$win_ip" + echo "❌ Could not detect Windows host IP via helper container" + exit 1 end - echo " IdentitiesOnly yes" >> $SSH_CONFIG - echo " StrictHostKeyChecking no" >> $SSH_CONFIG + echo " Detected Windows host at $win_ip (from Fedora namespace)" - echo "✅ SSH config updated!" + sudo -n docker rm -f ac-ssh-bridge >/dev/null 2>&1 + if sudo -n docker run -d --name ac-ssh-bridge --restart unless-stopped --network=host \ + alpine/socat "TCP-LISTEN:2222,fork,reuseaddr" "TCP:$win_ip:22" >/dev/null + echo "✅ ac-ssh-bridge started (forwarding :2222 -> $win_ip:22)" + else + echo "❌ Failed to start ac-ssh-bridge container" + exit 1 + end end +# ----------------------------------------------------------------------------- +# Step 4: Test the end-to-end connection +# ----------------------------------------------------------------------------- echo "" -echo "🧪 Testing connection (this will fail until you complete Windows setup)..." -ssh -o ConnectTimeout=5 windows-host "echo 'Connected successfully!'" 2>&1 -or echo "❌ Connection failed - complete Windows setup steps above first" - -echo "" -echo "💡 After setup, you can connect with:" -echo " ssh windows-host" -echo " ssh windows-host 'dir C:\\'" -echo "" -echo "🧠 For Emacs MCP integration, you can use emacs_send_keys to execute:" -echo " ssh windows-host 'powershell.exe -Command \"\"'" +echo "🧪 Testing ssh aesthetic ..." +if ssh -o BatchMode=yes -o ConnectTimeout=5 aesthetic 'powershell -Command "$env:COMPUTERNAME"' 2>/dev/null + echo "✅ SSH to Windows host works" + echo "" + echo "You can now run e.g.:" + echo " ssh aesthetic 'powershell -Command \"Get-Process\"'" + echo " scp -P 2222 myfile.txt me@172.17.0.1:'C:/Users/me/Desktop/'" +else + echo "❌ SSH to Windows host failed" + echo "" + echo "Debug checklist:" + echo " 1. Is Windows OpenSSH running? ssh aesthetic -v (look at handshake)" + echo " 2. Is the key installed on Windows?" + echo " C:\\ProgramData\\ssh\\administrators_authorized_keys should contain ~/.ssh/id_rsa.pub" + echo " 3. Is Windows Firewall blocking? Set-NetFirewallRule -DisplayName 'OpenSSH SSH Server (sshd)' -Profile Any" + echo " 4. Is the bridge forwarding the right IP?" + echo " sudo docker logs ac-ssh-bridge" + exit 1 +end diff --git a/false.work/patches/serialcom-ue5.6.patch b/false.work/patches/serialcom-ue5.6.patch new file mode 100644 index 0000000000..1fd14f3df9 --- /dev/null +++ b/false.work/patches/serialcom-ue5.6.patch @@ -0,0 +1,73 @@ +Subject: Port videofeedback/Unreal_Engine_SerialCOM_Plugin to UE 5.6.1 +Upstream: https://github.com/videofeedback/Unreal_Engine_SerialCOM_Plugin +Upstream commit: main @ 2024-06-21 (last push) +Target engine: Unreal Engine 5.6.1 (++UE5+Release-5.6, CL 44394996) +Verified: BuildPlugin + full project build (Editor/Game/Dev/Shipping), VS 2022 BuildTools 14.38, Win10 SDK 10.0.22621, 2026-04-10 + +Fixes applied: + + 1. SERIALCOM.uplugin — "EngineVersion": "5.1.0" -> "5.6.0" + UE 5.6 refuses to load the plugin citing version mismatch unless this is bumped. + Source is API-compatible with 5.6, so no code change is needed just to load it; + the bump is purely to satisfy the editor's plugin version check. + + 2. SerialCom.cpp line 276 — Chars.Pop(false) -> Chars.Pop(EAllowShrinking::No) + TArray::Pop(bool bAllowShrinking) was deprecated in UE 5.5 and the bool overload + was removed in UE 5.6. The replacement enum is defined in Containers/ContainersFwd.h + and has been available since 5.5 for forward compatibility. + + 3. SerialCom.cpp line 423 — if (!m_hIDComDev) false; -> if (!m_hIDComDev) return false; + Pre-existing bug in upstream: the `return` keyword was missing so the early-exit + guard was a no-op (the boolean literal was just discarded). Not a build break under + UE 5.6, but a clear bug that was worth fixing while we were in the file. + +Apply from inside a clean clone of the upstream repo: + + git clone https://github.com/videofeedback/Unreal_Engine_SerialCOM_Plugin.git + cd Unreal_Engine_SerialCOM_Plugin/SerialCOM + patch -p1 < /path/to/this/file + +Or as a Claude shortcut from the aesthetic-computer devcontainer: + + cd /tmp && rm -rf serialcom && \ + git clone --depth=1 https://github.com/videofeedback/Unreal_Engine_SerialCOM_Plugin.git serialcom && \ + cd serialcom/SerialCOM && \ + patch -p1 < /workspaces/aesthetic-computer/false.work/patches/serialcom-ue5.6.patch + +After applying, either drop SerialCOM/ into /Plugins/, or package standalone: + + "C:\Program Files\Epic Games\UE_5.6\Engine\Build\BatchFiles\RunUAT.bat" BuildPlugin \ + -Plugin="/SerialCOM/SERIALCOM.uplugin" \ + -Package="" -Rocket -TargetPlatforms=Win64 + +--- SerialCOM-src/SerialCOM/SERIALCOM.uplugin ++++ SerialCOM/SERIALCOM.uplugin +@@ -10,7 +10,7 @@ + "DocsURL": "https://github.com/videofeedback/Unreal_Engine_SerialCOM_Plugin", + "MarketplaceURL": "", + "SupportURL": "https://discord.ramiroslab.com/", +- "EngineVersion": "5.1.0", ++ "EngineVersion": "5.6.0", + "CanContainContent": true, + "IsBetaVersion": true, + "Installed": true, +--- SerialCOM-src/SerialCOM/Source/SERIALCOM/Private/SerialCom.cpp ++++ SerialCOM/Source/SERIALCOM/Private/SerialCom.cpp +@@ -273,7 +273,7 @@ + // when Terminator is \n, we know we're expecting lines from Arduino. But those + // are ended in \r\n. That means that if we found the line Terminator (\n), our previous + // character could be \r. If it is, we remove that from the array. +- if (Chars.Num() > 0 && Terminator == '\n' && Chars.Top() == '\r') Chars.Pop(false); ++ if (Chars.Num() > 0 && Terminator == '\n' && Chars.Top() == '\r') Chars.Pop(EAllowShrinking::No); + + Chars.Add(0x0); + break; +@@ -420,7 +420,7 @@ + + bool USerialCom::WriteBytes(TArray Buffer) + { +- if (!m_hIDComDev) false; ++ if (!m_hIDComDev) return false; + + bool bWriteStat; + unsigned long dwBytesWritten; diff --git a/false.work/unreal-builder/scripts/register-serialcom-plugin.fish b/false.work/unreal-builder/scripts/register-serialcom-plugin.fish new file mode 100755 index 0000000000..382421b3d9 --- /dev/null +++ b/false.work/unreal-builder/scripts/register-serialcom-plugin.fish @@ -0,0 +1,90 @@ +#!/usr/bin/env fish +# Register the patched SerialCOM plugin with the builds.false.work database. +# Posts metadata + CDN URLs to /.netlify/functions/register-plugin so the plugin +# shows up under the "🔌 Plugins" tab on https://builds.false.work/. +# +# Prerequisite: the artifacts must already be uploaded to +# s3://assets-aesthetic-computer/plugins/ (use upload-plugin-to-spaces.fish). +# +# Usage: +# fish register-serialcom-plugin.fish [--production | --local] +# +# Default target is production (https://aesthetic.computer). Use --local to +# hit http://localhost:8888 for dev testing. + +set -l script_dir (dirname (status -f)) +set -l repo_root (realpath "$script_dir/../../..") +set -l vault_dir "$repo_root/aesthetic-computer-vault/false.work" +set -l api_key_file "$vault_dir/builds-api-key.txt" + +if not test -f "$api_key_file" + echo "❌ $api_key_file not found — decrypt the vault first:" + echo " cd $repo_root/aesthetic-computer-vault && fish vault-tool.fish unlock" + exit 1 +end + +set -l api_key (cat "$api_key_file" | string trim) + +set -l base_url "https://aesthetic.computer" +if contains -- --local $argv + set base_url "http://localhost:8888" +end + +set -l endpoint "$base_url/.netlify/functions/register-plugin" +set -l cdn_base "https://assets-aesthetic-computer.sfo3.cdn.digitaloceanspaces.com/plugins" + +# File sizes on CDN (approx, for display in the UI) +set -l size_src 0.10 +set -l size_pkg 0.15 +set -l size_tp 0.19 +set -l size_patch 0.003 + +echo "Registering SerialCOM plugin at $endpoint" + +set -l payload (printf '{ + "name": "SerialCOM", + "displayName": "Communication Serial Port (Serial COM)", + "version": "4.5.1.1.1-ue56", + "engineVersion": "5.6.1", + "platforms": ["Win64"], + "category": "Input", + "description": "Serial/COM port communication for UE 5.6.1 — fork of videofeedback/Unreal_Engine_SerialCOM_Plugin (Ramiro Montes De Oca) with two UE 5.6 compat patches: EngineVersion bump in the .uplugin, and TArray::Pop(bool) -> Pop(EAllowShrinking::No). Win64 only; wraps Win32 CreateFile/ReadFile/WriteFile + DCB. Blueprint-callable (Open Port, Read/Write String/Bytes, Flush, etc.). Verified via RunUAT BuildPlugin and full project build on 2026-04-11.", + "upstream": "https://github.com/videofeedback/Unreal_Engine_SerialCOM_Plugin", + "artifacts": [ + { + "kind": "source", + "label": "Source (patched)", + "filename": "serialcom-ue56-src.tar.gz", + "sizeMB": %s, + "downloadUrl": "%s/serialcom-ue56-src.tar.gz" + }, + { + "kind": "packaged-win64", + "label": "Prebuilt Win64", + "filename": "serialcom-ue56-packaged-win64.tar.gz", + "sizeMB": %s, + "downloadUrl": "%s/serialcom-ue56-packaged-win64.tar.gz" + }, + { + "kind": "test-project", + "label": "Blank test project", + "filename": "serialcom-ue56-testproject.zip", + "sizeMB": %s, + "downloadUrl": "%s/serialcom-ue56-testproject.zip" + }, + { + "kind": "patch", + "label": "Diff vs upstream", + "filename": "serialcom-ue56.patch", + "sizeMB": %s, + "downloadUrl": "%s/serialcom-ue56.patch" + } + ] +}' $size_src $cdn_base $size_pkg $cdn_base $size_tp $cdn_base $size_patch $cdn_base) + +curl -sS -X POST "$endpoint" \ + -H "Content-Type: application/json" \ + -H "x-api-key: $api_key" \ + -d "$payload" + +echo "" diff --git a/false.work/unreal-builder/scripts/upload-plugin-to-spaces.fish b/false.work/unreal-builder/scripts/upload-plugin-to-spaces.fish new file mode 100755 index 0000000000..853e4b127f --- /dev/null +++ b/false.work/unreal-builder/scripts/upload-plugin-to-spaces.fish @@ -0,0 +1,178 @@ +#!/usr/bin/env fish +# Upload a patched Unreal plugin + optional test project to the +# assets-aesthetic-computer DigitalOcean Space under `plugins/`. +# +# Mirrors the pattern of upload-to-spaces.sh but for plugin artifacts. +# Game builds still use upload-to-spaces.sh; this script handles the +# "we forked a plugin and want to host the patched version" case. +# +# Reads creds from: aesthetic-computer-vault/false.work/builds-spaces.env +# +# Usage: +# fish upload-plugin-to-spaces.fish [test-project-dir] [patch-file] +# +# Example: +# fish upload-plugin-to-spaces.fish serialcom-ue56 \ +# /tmp/serialcom-work/SerialCOM \ +# "C:/Users/me/Documents/Unreal Projects/SpiderlilySerialTest" \ +# /workspaces/aesthetic-computer/false.work/patches/serialcom-ue5.6.patch +# +# Produces and uploads: +# plugins/-src.tar.gz (source only, no binaries) +# plugins/-packaged-win64.tar.gz (source + Binaries/Win64/*.dll, no pdbs) [if Binaries/ present] +# plugins/-testproject.zip (cleaned test project with plugin dropped in) [if test-project-dir given] +# plugins/.patch (patch vs upstream) [if patch-file given] +# plugins/-*-latest.* (copy-aliases pointing at the newest upload) +# +# The script is idempotent: re-running with the same slug overwrites the previous files. + +set -l script_dir (dirname (status -f)) +set -l repo_root (realpath "$script_dir/../../..") +set -l vault_dir "$repo_root/aesthetic-computer-vault/false.work" +set -l env_file "$vault_dir/builds-spaces.env" + +if not test -f "$env_file" + echo "❌ $env_file not found — decrypt the vault first:" + echo " cd $repo_root/aesthetic-computer-vault && fish vault-tool.fish unlock" + exit 1 +end + +# Source the env file (bash-style KEY=VALUE) +for line in (cat "$env_file" | grep -v '^#' | grep '=') + set -l kv (string split -m 1 '=' $line) + if test (count $kv) -eq 2 + set -gx $kv[1] $kv[2] + end +end + +for var in BUILDS_SPACES_KEY BUILDS_SPACES_SECRET BUILDS_SPACES_BUCKET BUILDS_SPACES_REGION + if not set -q $var + echo "❌ $var not set after sourcing $env_file" + exit 1 + end +end + +if test (count $argv) -lt 2 + echo "Usage: fish upload-plugin-to-spaces.fish [test-project-dir] [patch-file]" + exit 1 +end + +set -l slug $argv[1] +set -l plugin_dir $argv[2] +set -l testproject_dir "" +set -l patch_file "" +if test (count $argv) -ge 3 + set testproject_dir $argv[3] +end +if test (count $argv) -ge 4 + set patch_file $argv[4] +end + +if not test -d "$plugin_dir" + echo "❌ Plugin source dir not found: $plugin_dir" + exit 1 +end +if test -n "$patch_file"; and not test -f "$patch_file" + echo "❌ Patch file not found: $patch_file" + exit 1 +end + +# Stage files in a temp workspace with a clean "SerialCOM" (or whatever) root dir +set -l work (mktemp -d /tmp/plugin-upload-XXXXXX) +set -l plugin_name (basename $plugin_dir) +cp -r "$plugin_dir" "$work/$plugin_name" + +# Build the source-only tarball (exclude Binaries, Intermediate, HostProject) +echo "📦 Packaging $slug-src.tar.gz (source only)..." +tar czf "$work/$slug-src.tar.gz" \ + --exclude='Binaries' \ + --exclude='Intermediate' \ + --exclude='HostProject*' \ + --exclude='*.pdb' \ + -C "$work" $plugin_name + +# If the source dir has prebuilt Binaries/Win64/*.dll, build a packaged tarball too +if test -d "$plugin_dir/Binaries/Win64" + echo "📦 Packaging $slug-packaged-win64.tar.gz (source + Win64 .dll, no pdb)..." + tar czf "$work/$slug-packaged-win64.tar.gz" \ + --exclude='Intermediate' \ + --exclude='HostProject*' \ + --exclude='*.pdb' \ + -C "$work" $plugin_name +end + +# If a test project dir was passed, zip it up (excluding build artifacts) +if test -n "$testproject_dir"; and test -d "$testproject_dir" + set -l tp_name (basename "$testproject_dir") + set -l tp_staging "$work/tp-staging" + mkdir -p "$tp_staging" + cp -r "$testproject_dir" "$tp_staging/$tp_name" + echo "📦 Packaging $slug-testproject.zip..." + cd "$tp_staging" + zip -qr "$work/$slug-testproject.zip" $tp_name \ + -x '*/Intermediate/*' '*/Saved/*' '*/.vs/*' '*/*.sln' \ + '*/*.vcxproj' '*/*.vcxproj.*' '*.pdb' '*/*.target' + cd - +end + +# If a patch file was passed, stage it with a stable slug-based CDN name. +if test -n "$patch_file" + echo "📦 Staging $slug.patch..." + cp "$patch_file" "$work/$slug.patch" +end + +# Build s3cmd config +set -l s3cfg (mktemp /tmp/s3cfg-XXXXXX) +printf '[default] +access_key = %s +secret_key = %s +host_base = %s.digitaloceanspaces.com +host_bucket = %%(bucket)s.%s.digitaloceanspaces.com +use_https = True +' $BUILDS_SPACES_KEY $BUILDS_SPACES_SECRET $BUILDS_SPACES_REGION $BUILDS_SPACES_REGION > "$s3cfg" +chmod 600 "$s3cfg" + +# Upload everything we built +set -l uploaded +for f in (ls "$work"/$slug-*.tar.gz "$work"/$slug-*.zip "$work"/$slug.patch 2>/dev/null) + set -l name (basename $f) + echo "☁️ Uploading $name..." + if s3cmd -c "$s3cfg" put "$f" "s3://$BUILDS_SPACES_BUCKET/plugins/$name" \ + --acl-public --no-mime-magic --guess-mime-type --no-progress 2>&1 | tail -2 + set uploaded $uploaded $name + end +end + +# Create -latest aliases +for name in $uploaded + # serialcom-ue56-src.tar.gz -> serialcom-ue56-src-latest.tar.gz + # serialcom-ue56.patch -> serialcom-ue56-latest.patch + set -l base $name + set -l ext "" + if string match -q "*.tar.gz" -- $name + set base (string replace -r '\.tar\.gz$' '' $name) + set ext ".tar.gz" + else if string match -q "*.zip" -- $name + set base (string replace -r '\.zip$' '' $name) + set ext ".zip" + else if string match -q "*.patch" -- $name + set base (string replace -r '\.patch$' '' $name) + set ext ".patch" + end + set -l latest "$base-latest$ext" + echo "🔗 Aliasing $name -> $latest" + s3cmd -c "$s3cfg" cp "s3://$BUILDS_SPACES_BUCKET/plugins/$name" \ + "s3://$BUILDS_SPACES_BUCKET/plugins/$latest" --acl-public 2>&1 | tail -1 +end + +rm -f "$s3cfg" +rm -rf "$work" + +echo "" +echo "✅ Done. CDN URLs:" +for name in $uploaded + echo " https://$BUILDS_SPACES_BUCKET.$BUILDS_SPACES_REGION.cdn.digitaloceanspaces.com/plugins/$name" +end +echo "" +echo "Next step: register the plugin in MongoDB so it shows up on builds.false.work:" +echo " fish $script_dir/register-serialcom-plugin.fish # (or your plugin-specific registration script)" diff --git a/system/netlify/functions/get-plugins.mjs b/system/netlify/functions/get-plugins.mjs new file mode 100644 index 0000000000..071f9d7478 --- /dev/null +++ b/system/netlify/functions/get-plugins.mjs @@ -0,0 +1,53 @@ +// Get UE plugin releases from MongoDB for builds.false.work +// Reads from the `false.work-plugins` collection populated by +// register-plugin.mjs. Gated by the same builds password as get-builds.mjs. + +import { connect } from "../../backend/database.mjs"; +import { respond } from "../../backend/http.mjs"; + +const BUILDS_PASSWORD = process.env.BUILDS_PASSWORD; + +export async function handler(event, context) { + if (event.httpMethod === "OPTIONS") { + return respond(204, ""); + } + + if (event.httpMethod !== "GET") { + return respond(405, { error: "Method not allowed" }); + } + + // Verify password from Authorization header (same pattern as get-builds.mjs) + const authHeader = event.headers.authorization || event.headers.Authorization; + const password = authHeader?.replace("Bearer ", ""); + + if (!password || password !== BUILDS_PASSWORD) { + return respond(401, { error: "Unauthorized" }); + } + + try { + const database = await connect(); + const plugins = database.db.collection("false.work-plugins"); + + // Optional filters + const name = event.queryStringParameters?.name; + const engineVersion = event.queryStringParameters?.engineVersion; + const limit = parseInt(event.queryStringParameters?.limit) || 50; + + const query = {}; + if (name) query.name = name; + if (engineVersion) query.engineVersion = engineVersion; + + const results = await plugins + .find(query) + .sort({ timestamp: -1 }) + .limit(limit) + .toArray(); + + await database.disconnect(); + + return respond(200, { plugins: results }); + } catch (error) { + console.error("Error fetching plugins:", error); + return respond(500, { error: "Database error" }); + } +} diff --git a/system/netlify/functions/register-plugin.mjs b/system/netlify/functions/register-plugin.mjs new file mode 100644 index 0000000000..cec80fcd4a --- /dev/null +++ b/system/netlify/functions/register-plugin.mjs @@ -0,0 +1,90 @@ +// Register a new UE plugin release in MongoDB for builds.false.work +// Called by false.work/unreal-builder/scripts/register-serialcom-plugin.fish +// (and similar future helper scripts) with API key authentication. +// +// Plugins live in a separate MongoDB collection from game builds so the +// existing game-build schema and register-build.mjs function stay untouched. +// Collection: `false.work-plugins` +// Upsert key: { name, version } (re-registering with same version replaces) + +import { connect } from "../../backend/database.mjs"; +import { respond } from "../../backend/http.mjs"; + +const BUILDS_API_KEY = process.env.BUILDS_API_KEY; + +export async function handler(event, context) { + if (event.httpMethod === "OPTIONS") { + return respond(204, ""); + } + + if (event.httpMethod !== "POST") { + return respond(405, { error: "Method not allowed" }); + } + + // Verify API key + const apiKey = event.headers["x-api-key"] || event.headers["X-API-Key"]; + if (!apiKey || apiKey !== BUILDS_API_KEY) { + return respond(401, { error: "Unauthorized" }); + } + + try { + const plugin = JSON.parse(event.body); + + // Validate required fields + const required = ["name", "version", "engineVersion", "artifacts"]; + for (const field of required) { + if (plugin[field] === undefined || plugin[field] === null) { + return respond(400, { error: `Missing required field: ${field}` }); + } + } + + // Validate artifacts shape + if (!Array.isArray(plugin.artifacts) || plugin.artifacts.length === 0) { + return respond(400, { error: "artifacts must be a non-empty array" }); + } + for (const a of plugin.artifacts) { + if (!a.kind || !a.label || !a.downloadUrl) { + return respond(400, { + error: "each artifact needs { kind, label, downloadUrl }", + }); + } + } + + const database = await connect(); + const plugins = database.db.collection("false.work-plugins"); + + // Indexes (idempotent) + await plugins.createIndex({ timestamp: -1 }); + await plugins.createIndex({ name: 1, version: 1 }, { unique: true }); + + const now = new Date(); + const pluginDoc = { + ...plugin, + timestamp: plugin.timestamp ? new Date(plugin.timestamp) : now, + updatedAt: now, + }; + + // Upsert so re-running the registration script with the same name+version + // updates in place instead of erroring. + const result = await plugins.findOneAndUpdate( + { name: plugin.name, version: plugin.version }, + { $set: pluginDoc, $setOnInsert: { createdAt: now } }, + { upsert: true, returnDocument: "after" }, + ); + + await database.disconnect(); + + console.log(`✅ Plugin registered: ${plugin.name} ${plugin.version}`); + + return respond(200, { + success: true, + plugin: { + ...result, + _id: result?._id?.toString(), + }, + }); + } catch (error) { + console.error("Error registering plugin:", error); + return respond(500, { error: error.message }); + } +} diff --git a/system/netlify/functions/version.mjs b/system/netlify/functions/version.mjs index 94aab2af6c..569706a483 100644 --- a/system/netlify/functions/version.mjs +++ b/system/netlify/functions/version.mjs @@ -15,8 +15,18 @@ const TANGLED_REPO_URL = const RECENT_COMMIT_COUNT = 10; const HISTORY_SCAN_LIMIT = 50; -// Get deployed commit from file written during build/deploy -function getDeployedCommit() { +// Get deployed commit from file written during build/deploy. +// In Netlify Dev (local), the .commit-ref on disk is usually a stale leftover +// from a previous deploy, so fall back to `git rev-parse HEAD` instead — the +// running code *is* the current checkout, there's no real "deployment". +async function getDeployedCommit(repoRoot) { + if (process.env.NETLIFY_DEV === "true" && repoRoot) { + try { + return await git(["rev-parse", "HEAD"], repoRoot); + } catch (e) { + // Fall through to the file-based lookup + } + } // Check multiple locations: Netlify writes to cwd/public/, lith deploy writes to system/public/ const candidates = [ path.join(process.cwd(), "public", ".commit-ref"), @@ -143,7 +153,8 @@ async function getLatestFromTangledMirror(deployedCommit) { } export default async (request) => { - const deployedCommit = getDeployedCommit(); + const repoRoot = getRepoRoot(); + const deployedCommit = await getDeployedCommit(repoRoot); const url = new URL(request.url); const clientHash = url.searchParams.get("current"); @@ -165,7 +176,6 @@ export default async (request) => { } try { - const repoRoot = getRepoRoot(); const { latestCommit, behindBy, diff --git a/system/public/aesthetic.computer/disks/line.mjs b/system/public/aesthetic.computer/disks/line.mjs index ee5c97ec28..d879569b96 100644 --- a/system/public/aesthetic.computer/disks/line.mjs +++ b/system/public/aesthetic.computer/disks/line.mjs @@ -7,8 +7,10 @@ // line:3 blue 128 → freehand 3px blue at 50% alpha import { nopaint_generateColoredLabel } from "../systems/nopaint.mjs"; +import { isFadeColor } from "../lib/num.mjs"; let colorParams, opaqueParams, strokeAlpha, thickness, randomColor, wasPainting; +let fadeColor = null; // Fade color object when using fade: syntax. let savedParams, savedHud, savedApi; // Saved for dynamic HUD/URL updates. let colorIndex = -1; // -1 = user-specified or random, 0+ = palette index. const points = []; @@ -32,13 +34,19 @@ const system = "nopaint"; function boot({ params, num, colon, hud, ...api }) { colorParams = num.parseColor(params); - randomColor = colorParams.length === 0; + randomColor = !colorParams || (Array.isArray(colorParams) && colorParams.length === 0); savedParams = params; savedHud = hud; savedApi = api; + fadeColor = null; - // Extract alpha and create an opaque version for buffer rendering. - if (!randomColor && (colorParams.length === 2 || colorParams.length === 4)) { + // 🌈 Fade color support — parseColor returns a fade object for "fade:..." params. + if (isFadeColor(colorParams)) { + fadeColor = colorParams; + strokeAlpha = (fadeColor.alpha ?? 255) / 255; + opaqueParams = fadeColor.fadeString; + } else if (!randomColor && (colorParams.length === 2 || colorParams.length === 4)) { + // Extract alpha and create an opaque version for buffer rendering. strokeAlpha = colorParams[colorParams.length - 1] / 255; opaqueParams = colorParams.slice(0, -1); if (opaqueParams.length === 1) opaqueParams.push(255); @@ -57,6 +65,27 @@ function boot({ params, num, colon, hud, ...api }) { updateLabel(); } +// 🔔 Pleasant sine-wave chirp with value-based pitch and brightness. +// Maps a normalized value [0..1] to a musical pitch within a pentatonic scale +// so sequential tweaks feel tonal rather than atonal. +function chirp(sound, normalizedValue, { bright = 1 } = {}) { + if (!sound?.synth) return; + // Major pentatonic scale (C D E G A), two octaves. + const scale = [0, 2, 4, 7, 9, 12, 14, 16, 19, 21, 24]; + const idx = Math.max(0, Math.min(scale.length - 1, Math.round(normalizedValue * (scale.length - 1)))); + const semitone = scale[idx]; + // Base ~C4 (261.63) then scale up with value. + const tone = 261.63 * Math.pow(2, semitone / 12); + sound.synth({ + type: "sine", + tone, + beats: 0.12, + attack: 0.008, + decay: 0.92, + volume: 0.09 * bright, + }); +} + function act({ event: e, net, needsPaint, sound, system: { nopaint } }) { if (e.is("scroll")) { const delta = e.y > 0 ? -1 : e.y < 0 ? 1 : 0; @@ -66,13 +95,8 @@ function act({ event: e, net, needsPaint, sound, system: { nopaint } }) { if (thickness !== prev) { updateLabel(); rewriteURL(net); - sound.synth({ - tone: 200 + thickness * 40, - beats: 0.04, - attack: 0.01, - decay: 0.1, - volume: 0.1, - }); + // Pitch = thickness (thicker → higher). Brightness falls off at extremes. + chirp(sound, (thickness - 1) / 49, { bright: 1 }); needsPaint(); } } @@ -83,13 +107,8 @@ function act({ event: e, net, needsPaint, sound, system: { nopaint } }) { colorIndex = (colorIndex + 1) % palette.length; applyPaletteColor(); rewriteURL(net); - sound.synth({ - tone: 300 + colorIndex * 80, - beats: 0.05, - attack: 0.01, - decay: 0.15, - volume: 0.12, - }); + // Pitch by palette index (follows the scale). + chirp(sound, colorIndex / (palette.length - 1), { bright: 1.1 }); needsPaint(); } @@ -99,22 +118,19 @@ function act({ event: e, net, needsPaint, sound, system: { nopaint } }) { if (e.is("keyboard:down:]")) strokeAlpha = Math.min(1, strokeAlpha + step); if (e.is("keyboard:down:[")) strokeAlpha = Math.max(step, strokeAlpha - step); strokeAlpha = Math.round(strokeAlpha * 10) / 10; - opaqueParams = randomColor ? [] : colorParams.slice(0, 3); - if (!randomColor && opaqueParams.length > 0) opaqueParams.push(255); - if (!randomColor) { - const colorName = savedParams[0] || ""; - const alphaVal = Math.round(strokeAlpha * 255); - savedParams = alphaVal < 255 ? [colorName, String(alphaVal)] : [colorName]; + if (!isFadeColor(colorParams)) { + opaqueParams = randomColor ? [] : colorParams.slice(0, 3); + if (!randomColor && opaqueParams.length > 0) opaqueParams.push(255); + if (!randomColor) { + const colorName = savedParams[0] || ""; + const alphaVal = Math.round(strokeAlpha * 255); + savedParams = alphaVal < 255 ? [colorName, String(alphaVal)] : [colorName]; + } } updateLabel(); rewriteURL(net); - sound.synth({ - tone: 400 + strokeAlpha * 600, - beats: 0.04, - attack: 0.01, - decay: 0.1, - volume: 0.1, - }); + // Pitch = alpha; brightness = alpha (so quieter lines sound softer). + chirp(sound, strokeAlpha, { bright: 0.5 + strokeAlpha * 0.7 }); needsPaint(); } } diff --git a/system/public/aesthetic.computer/disks/robo.mjs b/system/public/aesthetic.computer/disks/robo.mjs index f305935ceb..6a9ae5b75a 100644 --- a/system/public/aesthetic.computer/disks/robo.mjs +++ b/system/public/aesthetic.computer/disks/robo.mjs @@ -27,7 +27,10 @@ const robotState = { grid: { cols: 3, rows: 3 }, targetQueueSize: 1, autoContinue: true, - roboParams: null + roboParams: null, + // Synthetic screen-space pen position, independent of hardware pen. + // Used for cursor display and for distinguishing robot-driven paths. + syntheticPen: { x: 0, y: 0 } }; function getNopaintTransform(nopaint) { @@ -659,7 +662,7 @@ class RoboPathGenerator { const rgb = this.hslToRgb(hue / 360, 0.7, 0.5); colors.push({ r: Math.round(rgb[0] * 255), - g: Math.round(rgb[1] * 255), + g: Math.round(rgb[1] * 255), b: Math.round(rgb[2] * 255) }); } @@ -850,7 +853,6 @@ class RoboPathGenerator { } generateLinePaths(positions, width, height, count) { - console.log("🤖 Preparing robo line paths", { count, positions: positions.length }); const paths = []; const limit = Math.min(Math.max(count, 1), positions.length); @@ -860,8 +862,11 @@ class RoboPathGenerator { const points = this.createLinePoints(pos, width, height); const metrics = computePathMetrics(points, { closed: false }); const baseSize = Math.max(2, Math.min(pos.w, pos.h)); - const maxThickness = Math.max(1, Math.round(baseSize / 4)); + // Broader thickness range — sometimes thick, sometimes hairline. + const maxThickness = Math.max(2, Math.round(baseSize / 3)); const thickness = this.randomInt(1, Math.max(1, maxThickness)); + // Per-stroke alpha — bias toward visible (96..255). + const alpha = this.randomInt(96, 255); const duration = Math.max(30, Math.round(Math.max(metrics.totalLength, baseSize) * 1.2)); paths.push({ @@ -869,6 +874,8 @@ class RoboPathGenerator { color: colorInfo.color, colorTokens: colorInfo.colorTokens, colorParams: colorInfo.colorParams, + thickness, + alpha, duration, paintingMark: { points: points.map(({ x, y }) => ({ x, y })), @@ -881,7 +888,6 @@ class RoboPathGenerator { }); } - console.log(`🤖 Generated ${paths.length} total line paths`); return paths; } @@ -919,35 +925,108 @@ class RoboPathGenerator { return paths; } + // 🐢 Turtle-graphics line walker. Walks a virtual turtle inside the cell + // emitting a stream of (x, y) points. The turtle has an evolving heading + // that's perturbed each step by a chosen "program" — sine wiggle, random + // walk with damping, spiral, or zig-zag — producing organic curved lines. createLinePoints(base, width, height) { - const pointCount = Math.random() < 0.4 ? 3 : 2; - const points = []; const limitX = Math.max(width - 1, 0); const limitY = Math.max(height - 1, 0); + const cellBounds = { + left: base.x, + right: Math.min(limitX, base.x + base.w), + top: base.y, + bottom: Math.min(limitY, base.y + base.h), + }; - const randomPoint = () => ({ - x: clamp(Math.round(base.x + Math.random() * base.w), 0, limitX), - y: clamp(Math.round(base.y + Math.random() * base.h), 0, limitY) - }); + // Start somewhere inside the cell, with a random heading. + const startX = clamp( + cellBounds.left + Math.random() * Math.max(1, cellBounds.right - cellBounds.left), + 0, + limitX, + ); + const startY = clamp( + cellBounds.top + Math.random() * Math.max(1, cellBounds.bottom - cellBounds.top), + 0, + limitY, + ); + + const cellDiag = Math.hypot(base.w, base.h) || 1; + const stepSize = clamp(cellDiag / 18, 2, 8); + const totalDistance = clamp(cellDiag * (0.8 + Math.random() * 1.2), stepSize * 4, cellDiag * 2.5); + const stepCount = Math.max(6, Math.round(totalDistance / stepSize)); + + // Pick a turtle program — each gives a distinct line character. + const programs = ["sine", "randomWalk", "spiral", "zigzag"]; + const program = programs[Math.floor(Math.random() * programs.length)]; + + // Program parameters (regenerated per line for variety). + const sineAmplitude = (Math.random() * 0.6 + 0.2) * (Math.PI / 4); // ±45° swing at most + const sineFrequency = Math.random() * 0.35 + 0.1; // how fast the wiggle cycles + const randomTurnMax = (Math.random() * 0.4 + 0.1); // radians per step for random walk + const spiralTurn = (Math.random() * 0.08 + 0.02) * (Math.random() < 0.5 ? -1 : 1); + const zigzagPeriod = Math.max(3, Math.round(Math.random() * 6 + 3)); + const zigzagSwing = Math.random() * 0.8 + 0.3; + + const turtle = { + x: startX, + y: startY, + heading: Math.random() * Math.PI * 2, + }; + + const points = [{ x: Math.round(turtle.x), y: Math.round(turtle.y) }]; + + for (let step = 1; step <= stepCount; step++) { + // Evolve heading according to the selected program. + switch (program) { + case "sine": + turtle.heading += Math.sin(step * sineFrequency) * sineAmplitude; + break; + case "randomWalk": + turtle.heading += (Math.random() * 2 - 1) * randomTurnMax; + break; + case "spiral": + turtle.heading += spiralTurn; + break; + case "zigzag": + turtle.heading += (step % zigzagPeriod < zigzagPeriod / 2 ? zigzagSwing : -zigzagSwing) * 0.25; + break; + } + + // Move forward. + turtle.x += Math.cos(turtle.heading) * stepSize; + turtle.y += Math.sin(turtle.heading) * stepSize; + + // Soft-bounce off the cell bounds so the line stays roughly inside. + if (turtle.x < cellBounds.left) { + turtle.x = cellBounds.left + (cellBounds.left - turtle.x); + turtle.heading = Math.PI - turtle.heading; + } else if (turtle.x > cellBounds.right) { + turtle.x = cellBounds.right - (turtle.x - cellBounds.right); + turtle.heading = Math.PI - turtle.heading; + } + if (turtle.y < cellBounds.top) { + turtle.y = cellBounds.top + (cellBounds.top - turtle.y); + turtle.heading = -turtle.heading; + } else if (turtle.y > cellBounds.bottom) { + turtle.y = cellBounds.bottom - (turtle.y - cellBounds.bottom); + turtle.heading = -turtle.heading; + } - for (let i = 0; i < pointCount; i++) { - points.push(randomPoint()); + const px = clamp(Math.round(turtle.x), 0, limitX); + const py = clamp(Math.round(turtle.y), 0, limitY); + const last = points[points.length - 1]; + if (last.x !== px || last.y !== py) { + points.push({ x: px, y: py }); + } } - // Ensure distinct endpoints to avoid zero-length strokes - if (distanceBetweenPoints(points[0], points[points.length - 1]) < 2) { - points[points.length - 1] = { - x: clamp( - points[0].x + this.randomInt(-Math.round(base.w / 2), Math.round(base.w / 2)), - 0, - limitX - ), - y: clamp( - points[0].y + this.randomInt(-Math.round(base.h / 2), Math.round(base.h / 2)), - 0, - limitY - ) - }; + // Guarantee at least two distinct points. + if (points.length < 2) { + points.push({ + x: clamp(points[0].x + this.randomInt(-10, 10), 0, limitX), + y: clamp(points[0].y + this.randomInt(-10, 10), 0, limitY), + }); } return points; @@ -1021,13 +1100,13 @@ class RoboPathGenerator { function parseRoboParams(params) { const defaults = { speed: 1.0, - grid: "3x3", + grid: "3x3", pattern: "random", count: 5 }; - + const parsed = { ...defaults }; - + for (const param of params) { if (param.includes(":")) { const [key, value] = param.split(":"); @@ -1047,7 +1126,7 @@ function parseRoboParams(params) { } } } - + return parsed; } @@ -1055,16 +1134,16 @@ function parseRoboParams(params) { function parseGrid(gridStr) { const parts = gridStr.split('x'); if (parts.length !== 2) return { cols: 3, rows: 3 }; - + const cols = parseInt(parts[0]) || 3; const rows = parseInt(parts[1]) || 3; return { cols, rows }; } -// Boot function - Initialize robot with brush and parameters +// Boot function - Initialize robot with brush and parameters async function boot($api) { const { params, screen, net } = $api; - + if (params.length === 0) { console.log("🤖 Usage: robo [options]"); console.log("🤖 Options: speed:1.5, grid:3x3, pattern:random, count:5"); @@ -1091,19 +1170,19 @@ async function boot($api) { const fullUrl = `${url}/${brushName}.mjs?v=${cacheBuster}`; console.log(`🤖 Loading brush from URL: ${fullUrl}`); const brush = await import(fullUrl); - + // Create a painting buffer for the robot (like test.mjs does) const painting = $api.painting(screen.width, screen.height, (p) => { p.wipe(255, 255, 255, 0); // Transparent background }); - + // Set up system for the brush (preserve existing system data like nopaint) $api.system = { ...($api.system || {}), painting }; - + // Initialize the brush with the API (copy params so brush gets clean params) const brushApi = { ...$api, params: [...params.slice(1)] }; brush?.boot?.(brushApi); - + console.log(`🤖 Screen dimensions: ${screen.width}x${screen.height}`); // Parse grid dimensions @@ -1171,8 +1250,14 @@ async function boot($api) { robotState.previousDrawPoint = null; robotState.allowMultipleLift = false; + // 🤖 Lock nopaint input to robot events only — hardware mouse/touch + // will be ignored for pen events until robo leave() runs. + if ($api.system?.nopaint) { + $api.system.nopaint.robotActive = true; + } + console.log("🤖 Robot initialized and ready for execution"); - + } catch (error) { console.error(`🤖 Failed to load brush ${brushName}:`, error); } @@ -1181,56 +1266,92 @@ async function boot($api) { // Paint function - execute the loaded brush with robot-generated pen data function paint($api) { if (!robotState.active || !robotState.loadedBrush) return; - + // Don't override the existing system - just ensure painting is set if (!$api.system.painting) { $api.system.painting = robotState.painting; } - - // The robot doesn't directly call brush paint - it sends events through the robo API - // which then get processed by the nopaint system - console.log(`🤖 Paint called - robot state: ${robotState.state}`); - - // Monitor nopaint state processing + + // 🎨 Paint/bake brush support: if the loaded brush uses the paint/bake pattern + // (like line.mjs), call its paint() each frame so it can accumulate points + // and draw the stroke to the nopaint buffer. This is the alternative to the + // overlay/lift delegation pattern used by box/oldline/etc. + const brush = robotState.loadedBrush; + const brushUsesPaintBake = !brush.overlay && !brush.lift && typeof brush.paint === "function"; + if (brushUsesPaintBake) { + try { + brush.paint($api); + } catch (error) { + console.error("🤖 Robo: Error calling loaded brush paint:", error); + } + } + + // 👆 Draw a fake cursor where the simulated pen is, so viewers can see + // where the robot is "clicking" and dragging. + drawRobotCursor($api); + + // Return true to force continuous rendering when nopaint has pending updates if ($api.system?.nopaint) { const np = $api.system.nopaint; - console.log("🤖 Paint: nopaint state check:", { - needsPresent: np.needsPresent, - needsBake: np.needsBake, - bufferExists: !!np.buffer, - robotActive: robotState.active, - robotState: robotState.state - }); - - // If needsPresent is true, log that we're expecting a present call - if (np.needsPresent) { - console.log("🔍 NOPAINT PRESENT FLAG IS TRUE - expecting present() to be called soon"); - } - - // Return true to force continuous rendering when nopaint has pending updates - if (np.needsPresent || np.needsBake) { - return true; - } + if (np.needsPresent || np.needsBake) return true; + } + return true; // Always repaint so the cursor animates. +} + +// Draw a small crosshair + dot at the robot's current synthetic pen position. +// Uses robotState.syntheticPen so the hardware mouse cursor has no effect. +function drawRobotCursor($api) { + const { ink } = $api; + if (typeof ink !== "function") return; + const x = Math.round(robotState.syntheticPen?.x || 0); + const y = Math.round(robotState.syntheticPen?.y || 0); + const isPainting = robotState.state === "painting"; + const isLifting = robotState.state === "lift"; + // Pulse the cursor when painting for a little feedback. + const pulse = isPainting ? (Math.sin(performance.now() / 120) * 0.5 + 0.5) : 0; + const ringR = 6 + Math.round(pulse * 2); + const dotColor = isLifting + ? [255, 200, 0] + : isPainting + ? [0, 255, 180] + : [255, 255, 255]; + try { + // Outer ring (dark drop shadow for contrast) + ink(0, 0, 0, 160).circle(x + 1, y + 1, ringR, false); + ink(...dotColor, 220).circle(x, y, ringR, false); + // Crosshair arms + ink(0, 0, 0, 140) + .line(x - ringR - 3, y + 1, x - 2, y + 1) + .line(x + 2, y + 1, x + ringR + 3, y + 1) + .line(x + 1, y - ringR - 3, x + 1, y - 2) + .line(x + 1, y + 2, x + 1, y + ringR + 3); + ink(...dotColor, 220) + .line(x - ringR - 3, y, x - 2, y) + .line(x + 2, y, x + ringR + 3, y) + .line(x, y - ringR - 3, x, y - 2) + .line(x, y + 2, x, y + ringR + 3); + // Center dot + ink(...dotColor).box(x - 1, y - 1, 3, 3); + ink(0, 0, 0).plot(x, y); + } catch (error) { + // Silently ignore rendering errors for the cursor overlay. } - - // Note: Display is handled by nopaint system, not by manual paste - // The nopaint system will handle presenting the final result after baking } // Sim function - 120fps locked timing for robot execution function sim($api) { if (!robotState.active || !robotState.loadedBrush) return; - + const { simCount, needsPaint, system } = $api; - + // Force continuous rendering if nopaint has pending display updates if (system?.nopaint && (system.nopaint.needsPresent || system.nopaint.needsBake)) { needsPaint(); } - + // Speed control: advance robot logic every N sim frames const speedFrames = Math.max(1, Math.round(120 / (60 * robotState.speed))); - + if (simCount % BigInt(speedFrames) === 0n) { advanceRobotLogic($api); needsPaint(); // Request paint update when robot state changes @@ -1245,6 +1366,50 @@ function advanceRobotLogic($api) { const nextPath = robotState.pathQueue.shift(); ensurePathColorMetadata(nextPath); applyPathColorToNopaint($api.system?.nopaint, nextPath); + + // 🎨 For paint/bake brushes (like line.mjs), re-boot the brush with the + // new path's color/thickness/alpha so it picks up the new settings. The + // overlay/lift pattern (box) reads color from api.color each lift so it + // doesn't need this — but paint/bake brushes hold their state in module + // variables set during boot. + const brush = robotState.loadedBrush; + const brushUsesPaintBake = brush && !brush.overlay && !brush.lift && typeof brush.paint === "function"; + if (brushUsesPaintBake && typeof brush.boot === "function") { + try { + const tokens = Array.isArray(nextPath.colorTokens) && nextPath.colorTokens.length + ? [...nextPath.colorTokens] + : ["white"]; + // Append alpha as the second param. parseColor uses params[1] as + // alpha for both named colors and fade strings, and as the trailing + // RGBA alpha for raw ints. + const alpha = Math.max(1, Math.min(255, Number(nextPath.alpha ?? 255))); + const firstToken = tokens[0] || ""; + const isFadeToken = typeof firstToken === "string" && firstToken.startsWith("fade:"); + const isNumericFirst = !isNaN(parseInt(firstToken)); + let paramsWithAlpha; + if (isFadeToken) { + // Fade: ["fade:red-blue", alpha] + paramsWithAlpha = [firstToken, String(alpha)]; + } else if (isNumericFirst) { + // Raw rgb ints: append alpha if we have 3 tokens, replace if 4. + paramsWithAlpha = tokens.length >= 3 + ? [...tokens.slice(0, 3), String(alpha)] + : [...tokens, String(alpha)]; + } else { + // Named color: ["red", alpha] + paramsWithAlpha = [firstToken, String(alpha)]; + } + const thicknessStr = String(Math.max(1, Math.min(50, Number(nextPath.thickness ?? 1)))); + brush.boot({ + ...$api, + params: paramsWithAlpha, + colon: [thicknessStr], + }); + } catch (error) { + console.error("🤖 Robo: Error re-booting brush for new path:", error); + } + } + robotState.currentPath = nextPath; robotState.currentStrategy = getBrushStrategy(nextPath.type || robotState.brushType); robotState.frameCounter = 0; @@ -1261,9 +1426,7 @@ function advanceRobotLogic($api) { const touchPoint = robotState.previousDrawPoint; if (touchPoint) { const startScreen = paintingPointToScreen(touchPoint, transform); - console.log( - `🤖 Starting path ${currentIndex + 1} (${nextPath.type}) at screen (${startScreen.x}, ${startScreen.y})` - ); + robotState.syntheticPen = { x: startScreen.x, y: startScreen.y }; $api.robo.touch(startScreen.x, startScreen.y); } } @@ -1285,6 +1448,7 @@ function advanceRobotLogic($api) { if (drawPoint && previousPoint) { const currentScreen = paintingPointToScreen(drawPoint, transform); const prevScreen = paintingPointToScreen(previousPoint, transform); + robotState.syntheticPen = { x: currentScreen.x, y: currentScreen.y }; $api.robo.draw(currentScreen.x, currentScreen.y, prevScreen.x, prevScreen.y); robotState.previousDrawPoint = drawPoint; } @@ -1334,6 +1498,7 @@ function advanceRobotLogic($api) { replenishPathQueue($api); if (liftScreen) { + robotState.syntheticPen = { x: liftScreen.x, y: liftScreen.y }; $api.robo.lift(liftScreen.x, liftScreen.y); } else { $api.robo.lift(0, 0); @@ -1348,11 +1513,11 @@ function advanceRobotLogic($api) { // Act function - handle user interactions (required by disk system) function act($api) { const { event: e } = $api; - + // Monitor ALL events to debug synthetic robo events if (e) { console.log(`🔍 ACT EVENT: type="${e.type}" device="${e.device}" x=${e.x} y=${e.y} pressure=${e.pressure}`); - + // Special logging for robot events if (e.device === "robot") { console.log("🤖 ROBOT EVENT DETECTED:", { @@ -1366,7 +1531,7 @@ function act($api) { }); } } - + // Pass through to loaded brush if needed if (robotState.loadedBrush?.act) { try { @@ -1379,6 +1544,12 @@ function act($api) { // Brush delegation functions - called by nopaint system function overlay(api) { + // Paint/bake brushes (like line.mjs) don't use overlay — their paint() + // function handles the live preview on the nopaint buffer directly. + const brush = robotState.loadedBrush; + const brushUsesPaintBake = brush && !brush.overlay && !brush.lift && typeof brush.paint === "function"; + if (brushUsesPaintBake) return null; + if (robotState.state === "lift") { console.log("🤖 ROBO overlay: currently lifting, delegating to lift"); return lift(api); @@ -1438,14 +1609,25 @@ function overlay(api) { function lift(api) { console.log("🤖🤖🤖 ROBO LIFT FUNCTION CALLED! 🤖🤖🤖"); + + // Paint/bake brushes handle their own lift in bake(). Skip the overlay/lift + // delegation path entirely — robo's bake() will call the brush's bake(). + const brush = robotState.loadedBrush; + const brushUsesPaintBake = brush && !brush.overlay && !brush.lift && typeof brush.paint === "function"; + if (brushUsesPaintBake) { + robotState.state = "idle"; + robotState.allowMultipleLift = false; + return null; + } + console.log(`🤖 ROBO LIFT CALLED - current state: ${robotState.state} - this should trigger box drawing!`); - + // Prevent multiple lift calls when already in idle state (fix for duplicate calls) if (robotState.state === "idle" && !robotState.allowMultipleLift) { console.log("🤖 ROBO LIFT: Already in idle state, ignoring duplicate lift call"); return; } - + console.log("🤖 Lift API context:", { hasInk: !!api.ink, inkType: typeof api.ink, @@ -1460,16 +1642,16 @@ function lift(api) { hasLoadedBrush: !!robotState.loadedBrush, hasLoadedBrushLift: !!robotState.loadedBrush?.lift }); - + if (robotState.loadedBrush?.lift) { console.log("📦 ROBO: About to call box brush lift function"); - + // Use lastCompletedPath data if available, otherwise use API data const lastCompleted = robotState.lastCompletedPath; ensurePathColorMetadata(lastCompleted); applyPathColorToNopaint(api.system?.nopaint, lastCompleted); let finalBox, finalColor; - + if (lastCompleted && lastCompleted.boxDimensions) { finalBox = lastCompleted.boxDimensions; finalColor = normalizeColorSpec(lastCompleted.color); @@ -1483,14 +1665,14 @@ function lift(api) { finalColor = normalizeColorSpec(api.color) || [255, 255, 255]; console.log("📦 ROBO: Using API data (no completed path):", { box: finalBox, color: finalColor }); } - + if (!finalBox || finalBox.w <= 0 || finalBox.h <= 0) { console.warn("📦 ROBO: No valid final box to paint during lift", { finalBox }); robotState.state = "idle"; robotState.allowMultipleLift = false; return; } - + // Create proper context for the brush lift function const brushApi = { ...api, @@ -1499,20 +1681,20 @@ function lift(api) { mark: finalBox, system: api.system // Make sure to pass the system context to lift }; - + console.log("📦 ROBO: Calling box brush lift with:", { hasInk: !!brushApi.ink, color: brushApi.color, mark: brushApi.mark, markIsValid: !!(brushApi.mark && brushApi.mark.x !== undefined && brushApi.mark.y !== undefined && brushApi.mark.w > 0 && brushApi.mark.h > 0) }); - + console.log("📦 ROBO: About to call lift function:", { liftExists: !!robotState.loadedBrush.lift, liftType: typeof robotState.loadedBrush.lift, liftString: robotState.loadedBrush.lift?.toString()?.substring(0, 200) }); - + try { // Call the box brush lift function - this should draw the final box const result = robotState.loadedBrush.lift(brushApi); @@ -1522,14 +1704,14 @@ function lift(api) { robotState.state = "idle"; robotState.allowMultipleLift = false; // Reset flag to prevent duplicate calls console.log("🤖 Lift completed - state set to idle"); - + // If there are more paths in the queue, they will be started on the next sim cycle if (robotState.pathQueue.length > 0) { console.log(`🤖 Lift completed, ${robotState.pathQueue.length} paths remaining - will start next path`); } else { console.log("🤖 Lift completed, all paths finished"); } - + return result; } catch (error) { console.error("📦 ROBO: Error calling box brush lift:", error); @@ -1548,36 +1730,33 @@ function lift(api) { } // 🍪 Bake - Transfer drawn content to the final painting -function bake({ paste, system, page, needsPaint }) { +function bake($api) { + const { paste, system, page, needsPaint } = $api; console.log("🤖🍪 ROBO BAKE CALLED! 🍪🤖"); - console.log("🤖 Robo bake: Current nopaint state:", { - hasBuffer: !!system.nopaint.buffer, - needsPresent: system.nopaint.needsPresent, - needsBake: system.nopaint.needsBake, - bufferSize: system.nopaint.buffer ? `${system.nopaint.buffer.width}x${system.nopaint.buffer.height}` : "none" - }); - + + // 🎨 If the loaded brush uses paint/bake pattern, delegate to its bake. + // It will handle the paste onto the painting itself (and any alpha + // compositing, e.g. line.mjs per-gesture alpha). + const brush = robotState.loadedBrush; + const brushUsesPaintBake = brush && !brush.overlay && !brush.lift && typeof brush.bake === "function"; + if (brushUsesPaintBake) { + try { + brush.bake($api); + system.nopaint.needsBake = false; + system.nopaint.needsPresent = false; + needsPaint(); + return true; + } catch (error) { + console.error("🤖 Robo: Error calling loaded brush bake:", error); + } + } + if (system.nopaint.buffer) { - console.log("🤖 BAKING: About to paste nopaint buffer to final painting"); - - // Try direct paste to main painting buffer - const result = paste(system.nopaint.buffer); - console.log("🤖 BAKING: Paste result:", result); - - console.log("🤖 BAKING: Pasted buffer, now wiping nopaint buffer"); + paste(system.nopaint.buffer); page(system.nopaint.buffer).wipe(255, 255, 255, 0); - console.log("🤖🍪 ROBO BAKE COMPLETED - Successfully transferred to final painting! 🍪🤖"); - - // Reset nopaint flags to indicate baking is done system.nopaint.needsBake = false; system.nopaint.needsPresent = false; - console.log("🤖 BAKING: Reset nopaint flags after baking"); - - // Force immediate display update of the main painting needsPaint(); - console.log("🤖 BAKING: Called needsPaint() to display final result"); - - // Signal that baking is complete and changes were made return true; } else { console.warn("🤖 Robo bake: No nopaint buffer to paste - nothing to bake"); @@ -1585,7 +1764,17 @@ function bake({ paste, system, page, needsPaint }) { } } -export { boot, paint, sim, act, overlay, lift, bake }; +// 🚪 Leave - release the robot lock on nopaint input so the hardware pen +// regains control when switching to another piece. +function leave($api) { + if ($api?.system?.nopaint) { + $api.system.nopaint.robotActive = false; + } + robotState.active = false; + robotState.loadedBrush = null; +} + +export { boot, paint, sim, act, overlay, lift, bake, leave }; // Export system type so disk.mjs recognizes this as a nopaint piece export const system = "nopaint"; diff --git a/system/public/aesthetic.computer/lib/disk.mjs b/system/public/aesthetic.computer/lib/disk.mjs index 033ebfef0c..097e844e07 100644 --- a/system/public/aesthetic.computer/lib/disk.mjs +++ b/system/public/aesthetic.computer/lib/disk.mjs @@ -1,16280 +1,16260 @@ -// Manages a piece and the transitions between pieces like a -// hypervisor or shell. - -/* #region 🏁 todo -#endregion */ - -// 📊 Worker timing telemetry - stores timing data to be sent to main thread -const diskTimings = { - sessionStarted: null, - preambleComplete: null, - loadStarted: null, - fetchComplete: null, - compileComplete: null, - bootStarted: null, - bootComplete: null, - firstPaint: null, - firstRenderSent: null, -}; -const diskTimingStart = performance.now(); - -// 🔬 Module load timing -const _importStart = performance.now(); - -import * as quat from "../dep/gl-matrix/quat.mjs"; -import * as mat3 from "../dep/gl-matrix/mat3.mjs"; -import * as mat4 from "../dep/gl-matrix/mat4.mjs"; -import * as vec2 from "../dep/gl-matrix/vec2.mjs"; -import * as vec3 from "../dep/gl-matrix/vec3.mjs"; -import * as vec4 from "../dep/gl-matrix/vec4.mjs"; - -import * as graph from "./graph.mjs"; - -import * as num from "./num.mjs"; -import * as text from "./text.mjs"; -import * as geo from "./geo.mjs"; -import * as gizmo from "./gizmo.mjs"; -import * as ui from "./ui.mjs"; -import * as help from "./help.mjs"; -import * as platform from "./platform.mjs"; - -import { signed as shop } from "./shop.mjs"; -import { parse, metadata, inferTitleDesc, updateCode } from "./parse.mjs"; -import { Socket } from "./socket.mjs"; // TODO: Eventually expand to `net.Socket` -import { Chat } from "./chat.mjs"; // TODO: Eventually expand to `net.Socket` -import { - notArray, - defaultTemplateStringProcessor, - uint8ArrayToBase64, - base64ToUint8Array, -} from "./helpers.mjs"; -import { createRestrictedApi, shouldRestrictPiece } from "./restricted-api.mjs"; -import { clearPermissions, resolvePermissionRequest } from "./piece-permissions.mjs"; -const { pow, abs, round, sin, random, min, max, floor, cos } = Math; -const { keys } = Object; - -import { nopaint_boot, nopaint_act, nopaint_is, nopaint_cancelStroke, nopaint_renderPerfHUD, nopaint_triggerBakeFlash } from "../systems/nopaint.mjs"; -import { getPreserveFadeAlpha, setPreserveFadeAlpha } from "./fade-state.mjs"; -import * as prompt from "../systems/prompt-system.mjs"; -import * as world from "../systems/world.mjs"; -import { headers } from "./headers.mjs"; -import { logs, log } from "./logs.mjs"; -import { soundWhitelist } from "./sound/sound-whitelist.mjs"; - -import { CamDoll } from "./cam-doll.mjs"; - -import { TextInput, Typeface } from "../lib/type.mjs"; - -import * as lisp from "./kidlisp.mjs"; -import { isKidlispSource, fetchCachedCode, fetchKidlispMetadata, getCachedCode, initPersistentCache, getCachedCodeMultiLevel, saveCodeToAllCaches, enableKidlispConsole, enableKidlispTrace, disableKidlispTrace, clearExecutionTrace, postExecutionTrace } from "./kidlisp.mjs"; // Add lisp evaluator. -import * as l5 from "./l5.mjs?v=20260330-runtime-support"; - -import { qrcode as qr, ErrorCorrectLevel } from "../dep/@akamfoad/qr/qr.mjs"; -import { microtype, MatrixChunky8 } from "../disks/common/fonts.mjs"; -import { - calculateVHSEffects, - getLeaderPixelColor, - blendColorWithVHS -} from "../disks/common/tape-player.mjs"; - -const _importEnd = performance.now(); -// Only log if imports took more than 50ms (slow network or first load) -if (_importEnd - _importStart > 50) { - console.log(`📦 [DISK] Imports: ${(_importEnd - _importStart).toFixed(0)}ms`); -} - -function matrixDebugEnabled() { - if (typeof window !== "undefined" && window?.acMatrixDebug) return true; - if (typeof globalThis !== "undefined" && globalThis?.acMatrixDebug) - return true; - return false; -} - -function inkFloodLoggingEnabled() { - if (typeof globalThis !== "undefined" && globalThis.AC_LOG_INK_COLORS) return true; - if (typeof process !== "undefined" && process.env?.AC_LOG_INK_COLORS === "1") return true; - return false; -} - -function inkFloodLogPrefix() { - let label = null; - if (typeof process !== "undefined" && process.env?.AC_LOG_INK_LABEL) { - label = process.env.AC_LOG_INK_LABEL; - } else if (typeof globalThis !== "undefined" && globalThis.AC_LOG_INK_LABEL) { - label = globalThis.AC_LOG_INK_LABEL; - } - return label ? `[${label}] ` : ""; -} - -function cloneColorForLog(color) { - if (Array.isArray(color)) return Array.from(color); - return color; -} - -function cloneArgsForLog(args) { - return Array.from(args).map((arg) => { - if (Array.isArray(arg)) return Array.from(arg); - if (arg && typeof arg === "object") { - try { - return JSON.parse(JSON.stringify(arg)); - } catch (err) { - return String(arg); - } - } - return arg; - }); -} - -if (typeof globalThis !== "undefined") { - if (globalThis.acMatrixDebug === undefined) { - globalThis.acMatrixDebug = true; - } - if (typeof window !== "undefined" && window.acMatrixDebug === undefined) { - window.acMatrixDebug = globalThis.acMatrixDebug; - } - - // 🖼️ Expose image cache utilities for debugging - if (typeof window !== "undefined") { - window.acClearImageCache = () => { - imageCache.clear(); - console.log("🗑️ Image cache cleared"); - }; - window.acImageCacheStats = () => { - console.log("🖼️ Image cache stats:", { - memorySize: imageCache.memory.size, - urls: Array.from(imageCache.memory.keys()) - }); - }; - } -} - -// Fetch piece metadata (trust level, author, etc.) from API -async function fetchPieceMetadata(pieceCode) { - try { - const { protocol, hostname } = getSafeUrlParts(); - const apiUrl = `${protocol}//${hostname}/api/piece-metadata?code=${encodeURIComponent(pieceCode)}`; - - const response = await fetch(apiUrl); - if (!response.ok) { - console.warn(`⚠️ Failed to fetch metadata for piece: ${pieceCode}`); - return { code: pieceCode, trustLevel: "untrusted", anonymous: true }; - } - - const metadata = await response.json(); - return metadata; - } catch (error) { - console.warn(`⚠️ Error fetching piece metadata:`, error); - // Default to untrusted on error - return { code: pieceCode, trustLevel: "untrusted", anonymous: true }; - } -} - -// Helper function to safely check for sandboxed environments in both main thread and worker contexts -function isSandboxed() { - try { - // Check for spider mode first - if (typeof window !== "undefined" && window.acSPIDER) { - return true; // Spider mode should behave like sandboxed for URL construction - } - - // In workers, window is undefined but we can still check self.origin or location - if (typeof window !== "undefined") { - return window.origin === "null"; - } else if (typeof self !== "undefined" && self.origin) { - return self.origin === "null"; - } else if (typeof location !== "undefined" && location.origin) { - return location.origin === "null"; - } else { - return false; // Default to not sandboxed if we can't determine - } - } catch (err) { - return false; // Default to not sandboxed if there's an error - } -} - -// Helper function to get safe protocol and hostname for URL construction -function getSafeUrlParts() { - try { - const sandboxed = isSandboxed(); - - if (sandboxed) { - return { - protocol: "https:", - hostname: "aesthetic.computer" - }; - } else { - // Try to get location info from various contexts - let loc = null; - if (typeof location !== "undefined") { - loc = location; - } else if (typeof self !== "undefined" && self.location) { - loc = self.location; - } else if (typeof window !== "undefined" && window.location) { - loc = window.location; - } - - if (loc) { - return { - protocol: loc.protocol, - hostname: loc.hostname || loc.host - }; - } else { - // Fallback if no location available - return { - protocol: "https:", - hostname: "aesthetic.computer" - }; - } - } - } catch (err) { - // Fallback to defaults if there's any error - return { - protocol: "https:", - hostname: "aesthetic.computer" - }; - } -} - -const SAME_ORIGIN_BUILT_IN_PIECE_HOSTS = new Set([ - "aesthetic.computer", - "www.aesthetic.computer", - "kidlisp.com", - "www.kidlisp.com", - "notepat.com", - "www.notepat.com", - "p5.aesthetic.computer", - "sitemap.aesthetic.computer", -]); - -function isLocalDevelopmentHost(hostname) { - return ( - hostname === "localhost" || - hostname === "127.0.0.1" || - /^192\.168\./.test(hostname) || - /^10\./.test(hostname) || - /^172\.(1[6-9]|2[0-9]|3[01])\./.test(hostname) - ); -} - -function getBuiltInPieceBaseUrl() { - const { protocol, hostname } = getSafeUrlParts(); - - if (typeof window !== "undefined" && window.acSPIDER) { - return "https://aesthetic.computer"; - } - - if ( - isLocalDevelopmentHost(hostname) && - typeof location !== "undefined" && - location.port - ) { - return `${protocol}//${hostname}:${location.port}`; - } - - if (SAME_ORIGIN_BUILT_IN_PIECE_HOSTS.has(hostname)) { - return `${protocol}//${hostname}`; - } - - return "https://aesthetic.computer"; -} - -let tf; // Active typeface global. - -// Cache for loaded typefaces to avoid recreating them -const typefaceCache = new Map(); - -// 🔤 Dev helper: Clear all font caches (call from console: clearFontCaches()) -// This clears typeface instances, advance caches, and forces fonts.mjs to reload -async function clearFontCaches() { - console.log("🔤 Clearing font caches..."); - - // 1. Clear typeface instance cache - const clearedTypefaces = typefaceCache.size; - for (const [name, typeface] of typefaceCache) { - // Clear the advance cache on each typeface - if (typeface.advanceCache) { - typeface.advanceCache.clear(); - } - } - typefaceCache.clear(); - - // 2. Clear IndexedDB glyph cache - try { - const { clearGlyphCache } = await import("./type.mjs"); - if (clearGlyphCache) { - await clearGlyphCache(); - console.log("🔤 IndexedDB glyph cache cleared"); - } - } catch (e) { - console.log("🔤 IndexedDB cache clear skipped:", e.message); - } - - // 3. Force reimport of fonts.mjs by cache-busting - try { - const timestamp = Date.now(); - const fontsModule = await import(`../disks/common/fonts.mjs?t=${timestamp}`); - console.log("🔤 fonts.mjs reloaded, MatrixChunky8 * advance:", fontsModule.MatrixChunky8?.advances?.['*']); - } catch (e) { - console.log("🔤 fonts.mjs reload skipped:", e.message); - } - - console.log(`🔤 Cleared ${clearedTypefaces} typeface(s). Refresh page or load a piece to see changes.`); - return true; -} - -// Expose to window for dev console access -if (typeof window !== "undefined") { - window.clearFontCaches = clearFontCaches; - - // 🔤 Auto-clear font caches in dev mode on page load - const isDevMode = location.host === "localhost:8888" || - location.host === "aesthetic.local:8888" || - location.host === "local.aesthetic.computer"; - if (isDevMode) { - // Clear IndexedDB glyph cache on dev mode page loads - (async () => { - try { - const { clearGlyphCache } = await import("./type.mjs"); - if (clearGlyphCache) { - await clearGlyphCache(); - console.log("🔤 Dev mode: IndexedDB glyph cache auto-cleared"); - } - } catch (e) { - // Silently ignore if not available yet - } - })(); - } -} - -const DEFAULT_TYPEFACE_BLOCK_WIDTH = 6; -const DEFAULT_TYPEFACE_BLOCK_HEIGHT = 10; -const HUD_LABEL_TEXT_MARGIN = 0; -const KIDLISP_HUD_WRAP_CHARACTER_LIMIT = 28; -const MIN_HUD_WRAP_WIDTH = 120; - -function resolveTypefaceInstance(typefaceRef) { - if (!typefaceRef) return undefined; - if (typefaceRef instanceof Typeface) return typefaceRef; - if (typeof typefaceRef === "string") { - if (typefaceCache.has(typefaceRef)) { - return typefaceCache.get(typefaceRef); - } - const instance = new Typeface(typefaceRef); - typefaceCache.set(typefaceRef, instance); - return instance; - } - if (typeof typefaceRef === "object" && typeof typefaceRef?.getGlyph === "function") { - return typefaceRef; - } - return undefined; -} - -function ensureTypefaceLoaded(typeface) { - if (!typeface || typeof typeface.load !== "function") return; - const requiresLoad = - typeface.data?.bdfFont || - typeface.name === "unifont" || - typeface.name === "MatrixChunky8"; - - if (!requiresLoad) return; - - if (!typeface.__loadPromise) { - typeface.__loadPromise = typeface.load($commonApi.net.preload, () => { - if (typeof window !== "undefined" && window.$activePaintApi?.needsPaint) { - window.$activePaintApi.needsPaint(); - } - }); - } - - return typeface.__loadPromise; -} - -function getTypefaceForMeasurement(typefaceName) { - if (!typefaceName) return tf; - const resolved = resolveTypefaceInstance(typefaceName); - return resolved || tf; -} - -// Flag to skip $ replacement during shadow rendering -let _isRenderingShadow = false; - -function writeHudLabelText( - $, - text, - { - x = 0, - y = 0, - typefaceName, - preserveColors = true, - bounds, - wordWrap, - } = {}, -) { - if (text === undefined || text === null || text === "") return; - - const content = preserveColors ? text : stripColorCodes(text); - const effectiveBounds = typeof bounds === "number" && bounds > 0 ? bounds : undefined; - const shouldWrap = wordWrap === undefined ? effectiveBounds !== undefined : wordWrap; - - $.write( - content, - { x, y }, - undefined, - effectiveBounds, - shouldWrap, - typefaceName, - ); - - return $; -} - -// Helper function to determine if a color is dark (needs light shadow) or light (needs dark shadow) -function isColorDark(colorStr) { - - let rgb; - - // Handle RGB comma-separated format like "0,0,0" or "255,0,0" - if (typeof colorStr === 'string' && colorStr.includes(',')) { - const parts = colorStr.split(',').map(n => parseInt(n.trim(), 10)); - rgb = parts; - } - // Handle named colors - else if (typeof colorStr === 'string') { - const lower = colorStr.toLowerCase(); - const resolved = graph.findColor(lower); - if (Array.isArray(resolved)) { - rgb = resolved; - } else { - // For unknown colors, assume they're light - return false; - } - } - // Handle array format [r, g, b] - else if (Array.isArray(colorStr)) { - rgb = colorStr; - } - - if (!rgb || rgb.length < 3) return false; - - // Calculate relative luminance using the formula from WCAG - // https://www.w3.org/TR/WCAG20/#relativeluminancedef - const [r, g, b] = rgb; - const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; - - // If luminance is less than 0.5, it's a dark color - return luminance < 0.5; -} - -// Helper function to get shadow color for a specific text color -const BRIGHT_SHADOW_RGB = "220,220,220"; -const DARK_SHADOW_RGB = "30,20,50"; // Dark purple-blue shadow for light mode - -function getShadowColorForText(colorStr) { - if (!colorStr) return "64,64,64"; // Default to dark gray shadow - - // Check if we're in light mode (need darker shadows for bright colors) - const isLightMode = $commonApi && !$commonApi.dark; - - let rgb; - let normalizedCommand = null; - if (typeof colorStr === "string") { - normalizedCommand = colorStr.trim().toLowerCase(); - // Treat complex command-based color strings (fade, scroll, random, etc.) as needing appropriate shadows - if (/[:(]/.test(normalizedCommand) || normalizedCommand.includes("?")) { - return isLightMode ? DARK_SHADOW_RGB : BRIGHT_SHADOW_RGB; - } - } - - // Parse the color string to get RGB values - if (typeof colorStr === 'string' && colorStr.includes(',')) { - const parts = colorStr.split(',').map(n => parseInt(n.trim(), 10)); - rgb = parts; - } - else if (typeof colorStr === 'string') { - const lower = colorStr.toLowerCase(); - const resolved = graph.findColor(lower); - if (Array.isArray(resolved)) { - rgb = resolved; - } else { - // Fallback to dark gray for unknown colors - return "64,64,64"; - } - } - else if (Array.isArray(colorStr)) { - rgb = colorStr; - } - - if (!rgb || rgb.length < 3) return "64,64,64"; - - const [r, g, b] = rgb; - - // Handle very dark colors (near black) before channel-specific logic - if (r <= 24 && g <= 24 && b <= 24) { - return BRIGHT_SHADOW_RGB; - } - - // Calculate luminance to check if color is bright - const luminance = (0.299 * r + 0.587 * g + 0.114 * b); - - // 🌞 LIGHT MODE: For bright colors on light background, use strong dark shadows - if (isLightMode && luminance > 140) { - // Use a consistent dark shadow for all bright colors in light mode - return DARK_SHADOW_RGB; - } - - // 🎨 Special case: Detect RGB channel colors and return channel-tinted shadows - // Red channel: R value, zero G and B - if (r >= 0 && g === 0 && b === 0) { - // Red channel shadow - darker red, stronger and more saturated - const shadowR = Math.max(32, Math.round(r * 0.4)); // Keep at least 40% intensity - return `${shadowR},0,0`; // Pure red shadow - } - - // Green channel: zero R, G value, zero B - if (r === 0 && g >= 0 && b === 0) { - // Green channel shadow - darker green - const shadowG = Math.max(32, Math.round(g * 0.4)); - return `0,${shadowG},0`; // Pure green shadow - } - - // Blue channel: zero R, G value (75% of B), B value (deepskyblue pattern: 0, 191, 255) - // Detects pattern where g = b * 0.75 (within rounding tolerance) - if (r === 0 && b >= 0 && g >= 0) { - const expectedG = Math.round(b * 0.75); - // Allow 1 pixel tolerance for rounding - if (Math.abs(g - expectedG) <= 1) { - // Blue/deepskyblue channel shadow - darker blue with cyan tint - const shadowG = Math.max(24, Math.round(g * 0.4)); - const shadowB = Math.max(32, Math.round(b * 0.4)); - return `0,${shadowG},${shadowB}`; // Cyan-blue shadow - } - } - - // Check if the color is dark - if so, lighten it for shadow - if (isColorDark(colorStr)) { - // Render near-white shadows under very dark text for readability - if (r <= 24 && g <= 24 && b <= 24) { - return "255,255,255"; - } - - // Lighten the color by mixing with white (stronger mix for dark colors) - const factor = 0.85; // Mix 85% with white for a much lighter shadow - const shadowR = Math.round(r + (255 - r) * factor); - const shadowG = Math.round(g + (255 - g) * factor); - const shadowB = Math.round(b + (255 - b) * factor); - return `${shadowR},${shadowG},${shadowB}`; - } - - // For light colors, darken by mixing with dark gray - const factor = 0.6; // Mix 60% darker for shadow - const shadowR = Math.round(r * (1 - factor)); - const shadowG = Math.round(g * (1 - factor)); - const shadowB = Math.round(b * (1 - factor)); - return `${shadowR},${shadowG},${shadowB}`; -} - -// Helper function to replace color codes in text with shadow colors -function replaceColorCodesWithShadows(text, defaultTextColor = "white") { - if (!text || !textContainsColorCodes(text)) return text; - - let currentTextColor = defaultTextColor; - - COLOR_CODE_MATCH_REGEX.lastIndex = 0; - return text.replace(COLOR_CODE_MATCH_REGEX, (match, colorStr) => { - if (!colorStr) return match; - - const normalized = colorStr.trim(); - const lower = normalized.toLowerCase(); - - if (lower === "reset" || lower === "default" || lower === "base") { - currentTextColor = defaultTextColor; - } else { - currentTextColor = normalized; - } - - // Get the appropriate shadow color for this text color - const shadowColor = getShadowColorForText(currentTextColor); - - // Return the shadow color code - return `\\${shadowColor}\\`; - }); -} - -function drawHudLabelText( - $, - text, - { - x = 0, - y = 0, - typefaceName, - textColor = "white", - shadowColor, - shadowOffsetX = 1, - shadowOffsetY = 1, - preserveColors = true, - bounds, - wordWrap, - } = {}, -) { - if (!text) return; - - const containsColorCodes = textContainsColorCodes(text); - const shouldPreserveColors = preserveColors || ((typefaceName === "MatrixChunky8" || typefaceName === "unifont") && containsColorCodes); - - const effectiveBounds = typeof bounds === "number" && bounds > 0 ? bounds : undefined; - const shouldWrap = wordWrap === undefined ? effectiveBounds !== undefined : wordWrap; - - - let effectiveShadowColor = shadowColor; - if (!effectiveShadowColor && textColor) { - effectiveShadowColor = getShadowColorForText(textColor); - } - if (!effectiveShadowColor) { - effectiveShadowColor = "black"; - } - - - const shouldRenderShadow = effectiveShadowColor && !( - (typefaceName === "MatrixChunky8" || typefaceName === "unifont") && - matrixDebugEnabled() - ); - - if (shouldRenderShadow) { - _isRenderingShadow = true; - // If the text has color codes and we're preserving colors, use dynamic shadows - if (shouldPreserveColors && containsColorCodes) { - // Replace color codes with appropriate shadow colors - const shadowText = replaceColorCodesWithShadows(text, textColor); - - writeHudLabelText($, shadowText, { - x: x + shadowOffsetX, - y: y + shadowOffsetY, - typefaceName, - preserveColors: true, // Keep the shadow color codes - bounds: effectiveBounds, - wordWrap: shouldWrap, - }); - } else { - // Original behavior: single shadow color for entire text - $.ink(effectiveShadowColor); - writeHudLabelText($, text, { - x: x + shadowOffsetX, - y: y + shadowOffsetY, - typefaceName, - preserveColors: false, - bounds: effectiveBounds, - wordWrap: shouldWrap, - }); - } - _isRenderingShadow = false; - } - - if (textColor) { - $.ink(textColor); - } - - const content = shouldPreserveColors ? text : stripColorCodes(text); - $.write( - content, - { x, y }, - undefined, - effectiveBounds, - shouldWrap, - typefaceName, - ); -} - -export const noWorker = { onMessage: undefined, postMessage: undefined }; - -let ROOT_PIECE = "prompt"; // This gets set straight from the host html file for the ac. - -let USER; // A holder for the logged in user. (Defined in `boot`) -let sessionStarted = false; // A flag that waits to boot until a session was -// found or not. - -let LAN_HOST; // The IP address of the hosting machine on the local network. -let SHARE_SUPPORTED; // Whether navigator.share is supported. (For `dl`) -let PREVIEW_OR_ICON; // Whether we are in preview or icon mode. (From boot.) -let VSCODE; // Whether we are running the vscode extesion or not. (From boot.) -let TV_MODE = false; // Whether running in TV mode (disables touch/keyboard input) -let DEVICE_MODE = false; // Whether running in device mode (device.kidlisp.com - skip HUD overlays) -let SOLO_MODE = false; // Whether running in solo mode (prevents navigating away from piece) -let HIGHLIGHT_MODE = false; // Whether HUD highlighting is enabled -let HIGHLIGHT_COLOR = "64,64,64"; // Default highlight color (gray) -let PERF_MODE = false; // Whether to show KidLisp performance/FPS HUD -let AUTO_SCALE_MODE = false; // Whether to enable auto-density scaling to maintain target FPS -let SPOOF_AUDIO_MODE = false; // Whether to generate synthetic audio data (for headless captures) -let NOGAP_MODE = false; // Whether gap is disabled (nogap URL param) -let AUDIO_SAMPLE_RATE = 0; -let loopPaused = false; // Whether the main loop is paused (sent from bios) -let debug = false; // This can be overwritten on boot. -let nopaintPerf = false; // Performance panel for nopaint system debugging (disabled by default) -let visible = true; // Is aesthetic.computer visibly rendering or not? -let pieceBackground = false; // Does the current piece want background sim ticks? -// True screen buffer state — refreshed each frame after content.pixels transfer. -// _mainScreenObject tracks the actual screen object so page() can detect when a brush -// switches back to the main screen (vs a sub-buffer) by object identity. -let _trueScreenPixels = null; -let _trueScreenWidth = 0; -let _trueScreenHeight = 0; -let _mainScreenObject = null; - -// 🎯 Global KidLisp Instance - Single source of truth for all KidLisp operations -let globalKidLispInstance = null; - -// 🎨 Persistent first line color storage - survives instance resets -let persistentFirstLineColor = null; - -// 🎨 Global function to store background color from KidLisp (worker-safe) -function storePersistentFirstLineColor(color) { - persistentFirstLineColor = color; - // Also store in window if available - if (typeof window !== "undefined" && window.setPersistentFirstLineColor) { - window.setPersistentFirstLineColor(color); - } -} - -// 🎨 Global function to get background color for reframe (worker-safe) -function getPersistentFirstLineColor() { - - return persistentFirstLineColor; -} - -// 🎮 Create gamepad helper API for easy access to per-gamepad events -function createGamepadAPI(gamepadEvents) { - if (!gamepadEvents) return []; - - return gamepadEvents.map((events, index) => { - if (!events || events.length === 0) return null; - - return { - index, - events, - - // Helper to check if button was pushed/released - button(buttonIndex) { - const pushEvent = events.find(e => - e.button === buttonIndex && e.action === 'push' - ); - const releaseEvent = events.find(e => - e.button === buttonIndex && e.action === 'release' - ); - return { - pushed: !!pushEvent, - released: !!releaseEvent, - event: pushEvent || releaseEvent - }; - }, - - // Helper to get axis value (returns most recent value in frame) - axis(axisIndex) { - const axisEvent = events.findLast(e => e.axis === axisIndex); - return axisEvent ? axisEvent.value : 0; - }, - - // Check if this gamepad is connected (has events) - connected() { - return events && events.length > 0; - }, - - // Get the gamepad device ID/name - get id() { - return events[0]?.gamepadId || null; - } - }; - }); -} - -// 🎨 Make storage function globally available for KidLisp -if (typeof globalThis !== "undefined") { - globalThis.storePersistentFirstLineColor = storePersistentFirstLineColor; - globalThis.getPersistentFirstLineColor = getPersistentFirstLineColor; -} - -// Global function to set persistent first line color -if (typeof window !== "undefined") { - window.setPersistentFirstLineColor = function(color) { - persistentFirstLineColor = color; - }; - - // Global function to get persistent first line color - window.getPersistentFirstLineColor = function() { - return persistentFirstLineColor; - }; -} - -function resolveBackgroundFillSpec(colorLike) { - if (colorLike === undefined || colorLike === null) return null; - - let resolved; - try { - resolved = graph.findColor(colorLike); - } catch (err) { - return null; - } - - if (!Array.isArray(resolved) || resolved.length === 0) { - return null; - } - - const first = resolved[0]; - if (typeof first === "string" && first.startsWith("fade:")) { - const fadeInfo = graph.parseFadeColor(resolved); - if (fadeInfo) { - return { - type: "fade", - fadeInfo, - }; - } - return null; - } - - const clampChannel = (value, fallback = 0) => { - if (typeof value !== "number" || !Number.isFinite(value)) return fallback; - return Math.max(0, Math.min(255, Math.round(value))); - }; - - const r = clampChannel(resolved[0], 0); - const g = clampChannel(resolved[1], r); - const b = clampChannel(resolved[2], r); - const a = clampChannel(resolved[3], 255); - - return { - type: "solid", - rgba: [r, g, b, a], - }; -} - -function computeFadePositionForPixel(x, y, width, height, fadeInfo) { - if (!fadeInfo || !width || !height) return 0; - - const maxX = Math.max(width - 1, 0); - const maxY = Math.max(height - 1, 0); - const direction = fadeInfo.direction; - - if (typeof direction === "number" && Number.isFinite(direction)) { - return graph.calculateAngleFadePosition( - x, - y, - 0, - 0, - maxX, - maxY, - direction, - ); - } - - const numeric = direction !== undefined ? parseFloat(direction) : NaN; - if (!Number.isNaN(numeric) && Number.isFinite(numeric)) { - return graph.calculateAngleFadePosition( - x, - y, - 0, - 0, - maxX, - maxY, - numeric, - ); - } - - const safeDiv = (value, denom) => (denom <= 0 ? 0 : value / denom); - - switch (direction) { - case "horizontal-reverse": - return safeDiv(maxX - x, maxX); - case "vertical": - return safeDiv(y, maxY); - case "vertical-reverse": - return safeDiv(maxY - y, maxY); - case "diagonal": { - const dx = safeDiv(x, maxX); - const dy = safeDiv(y, maxY); - return (dx + dy) / 2; - } - case "diagonal-reverse": { - const dx = safeDiv(maxX - x, maxX); - const dy = safeDiv(maxY - y, maxY); - return (dx + dy) / 2; - } - case "horizontal": - default: - return safeDiv(x, maxX); - } -} - -function fillExpandedWithSolidPixels(screen, width, height, oldWidth, oldHeight, rgba) { - const [r, g, b] = rgba; - - const applyPixel = (x, y) => { - const i = (y * width + x) * 4; - screen.pixels[i] = r; - screen.pixels[i + 1] = g; - screen.pixels[i + 2] = b; - screen.pixels[i + 3] = 255; - }; - - if (width > oldWidth) { - for (let y = 0; y < height; y++) { - for (let x = oldWidth; x < width; x++) { - applyPixel(x, y); - } - } - } - - if (height > oldHeight) { - const bottomLimit = width > oldWidth ? oldWidth : width; - for (let y = oldHeight; y < height; y++) { - for (let x = 0; x < bottomLimit; x++) { - applyPixel(x, y); - } - } - } -} - -function fillExpandedWithFadePixels(screen, width, height, oldWidth, oldHeight, fadeInfo) { - const applyPixel = (x, y) => { - const t = computeFadePositionForPixel(x, y, width, height, fadeInfo); - const [r = 0, g = 0, b = 0, a = 255] = graph.getLocalFadeColor(t, x, y, fadeInfo); - const i = (y * width + x) * 4; - screen.pixels[i] = Math.max(0, Math.min(255, Math.round(r))); - screen.pixels[i + 1] = Math.max(0, Math.min(255, Math.round(g))); - screen.pixels[i + 2] = Math.max(0, Math.min(255, Math.round(b))); - screen.pixels[i + 3] = Math.max(0, Math.min(255, Math.round(a || 255))); - }; - - if (width > oldWidth) { - for (let y = 0; y < height; y++) { - for (let x = oldWidth; x < width; x++) { - applyPixel(x, y); - } - } - } - - if (height > oldHeight) { - const bottomLimit = width > oldWidth ? oldWidth : width; - for (let y = oldHeight; y < height; y++) { - for (let x = 0; x < bottomLimit; x++) { - applyPixel(x, y); - } - } - } -} - -const projectionMode = location.search.indexOf("nolabel") > -1; // Skip loading noise. - -import { setDebug } from "../disks/common/debug.mjs"; -import { customAlphabet } from "../dep/nanoid/nanoid.js"; -import { setPackMode, getPackMode, checkPackMode } from "./pack-mode.mjs"; -// import { update } from "./glaze.mjs"; -const alphabet = - "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; -const nanoid = customAlphabet(alphabet, 4); - -const defaults = { - boot: ({ cursor, screen: { width, height }, resolution, api }) => { - if (location.host.indexOf("botce") > -1) resolution(width, height, 0); - if (platform.AestheticExtension) resolution(width, height, 0); - cursor("native"); - }, // aka Setup - sim: () => false, // A framerate independent of rendering. - paint: ({ noise16Aesthetic, noise16Sotce, slug, wipe, ink, write, screen, net }) => { - // In PACK mode (exported bundles), skip noise16 — just show black - if (typeof window !== "undefined" && window.acPACK_MODE) { wipe("black"); return; } - // TODO: Make this a boot choice via the index.html file? - if (!projectionMode) { - if (slug?.indexOf("wipppps") > -1) { - wipe("black"); - } else if (slug?.indexOf("botce") > -1) { - noise16Sotce(); - } else { - noise16Aesthetic(); - if (net.motd) { - ink(255, 255, 255, 200).write( - net.motd, - { center: "x", y: Math.floor(screen.height / 2) }, - undefined, - screen.width - 18, - ); - } - if (net.loadFailureText) { - ink("white").write( - net.loadFailureText, - { x: 6, y: 6 }, - [64, 64], - screen.width - 6, - ); - } - } - } - }, - beat: () => false, // Runs every bpm. - act: () => false, // All user interaction. - leave: () => false, // Before unload. - receive: () => false, // Handle messages from BIOS (file drops, etc.) - preview: ({ wipe, slug }) => { - wipe(64).ink(255).write(slug, { center: "xy", size: 1 }); - }, - icon: ({ glaze, wipe, screen }) => { - glaze({ on: false }); - // console.log(screen.width, screen.height); - wipe(70, 50, 100) - .ink(200, 30, 100) - .box(screen.width / 2, screen.height / 2, 48, 72, "*center"); - }, -}; - -let loadAfterPreamble = null; -let hotSwap = null; - -// 🔎 NoPaint -// Inheritable via `export const system = "nopaint"` from any piece. -// Boilerplate for a distributed raster editor. -const nopaint = { - leave: function leave($) { - const { store, system, page, screen, flatten } = $; - if (NPnoOnLeave === false) { - // ^ This is set when reloading a brush without storing changes. (`N` key) - - if (system.nopaint.bakeOnLeave) { - // Add bake commands. - page(system.painting); - //$activePaintApi = $; // In case of recursive paint functions like `write`. - bake($); - // page(screen); // TODO: This should work but it doesnt... - // Seems to have something to do with - // the $activePaintAPI being set - // and it's specific to the `write` - // implementation. - flatten(); // Force-run the above painting commands. - } - // Bake any changes into the existing painting using the screen buffer. - // Note... this may not include the full API that `paint` has... - // Which could lead to inconsistencies in `bake` calls. 23.05.04.13.01 - - // const p = system.nopaint.translation; // Offset by the pan position. - // page(system.painting).paste(screen, -p.x, -p.y); // TODO: Why the + 1 offset here... - // painting.paint(); // TODO: Why is this here? - // page(screen); - //} - - addUndoPainting(system.painting, $.slug); - - // Idea: Check to see if anything actually got painted by doing a diff on - // the pixels? - - store["painting"] = { - width: system.painting.width, - height: system.painting.height, - pixels: system.painting.pixels, - }; //system.painting; // Remember the painting data. - - // console.log("System painting:", system.painting); - - store.persist("painting", "local:db"); - - // 🎨 Broadcast painting update to other tabs - $commonApi.broadcastPaintingUpdate("updated", { - source: "leave", - slug: $.slug - }); - - // And its transform. - store["painting:transform"] = { - translation: system.nopaint.translation, - zoom: system.nopaint.zoomLevel, - }; - store.persist("painting:transform", "local:db"); - } else { - // Restore a painting if `no`ing. - const paintings = system.nopaint.undo.paintings; - page(system.painting) - .paste(paintings[paintings.length - 1]) - .page(screen); - } - - NPnoOnLeave = false; - }, - // 🥞 Bake (to the painting) - bake: function bake({ paste, system }) { - paste(system.nopaint.buffer); - }, -}; - -const undoPaintings = []; // Stores the last two paintings. -let undoPosition = 0; - -function addUndoPainting(painting, step = "unspecified") { - if (!painting) return; // If there is no painting present, silently pass. - const op = painting.pixels; - const pixels = new Uint8ClampedArray(op.length); - pixels.set(op); - - if (undoPaintings.length > undoPosition + 1) { - undoPaintings.length = undoPosition + 1; - } - - if (undoPaintings.length > 0) { - const lastPainting = undoPaintings[undoPaintings.length - 1]; - - // Check for equality in the two states. - // TODO: How long does this take? - const eq = - painting.width === lastPainting.width && - painting.height === lastPainting.height && - pixels.every((value, index) => value === lastPainting.pixels[index]); - - if (eq) { - // console.log("💩 The undo stack was not changed:", undoPaintings.length); - return; - } - } - - undoPaintings.push({ - pixels, - width: painting.width, - height: painting.height, - }); - - if ($commonApi.system.nopaint.recording) { - $commonApi.system.nopaint.addToRecord({ - label: step, - painting: { - pixels, - width: painting.width, - height: painting.height, - }, - }); - } - - undoPosition = undoPaintings.length - 1; - - // Note: This could be extended to increase the size of the - // undo stack, and images could be diffed? 23.01.31.01.30 - const maxUndoSteps = 32; - if (undoPaintings.length > maxUndoSteps) undoPaintings.shift(); - - if (debug && logs.painting) - console.log("💩 Added undo painting...", undoPaintings.length); -} - -let system = null; // Used to add built-in templated behaviors like `nopaint`. - -// 🎞️ Piece-level FPS control (timing-based, doesn't affect global render loop) -let pieceFPS = null; // Target FPS for the current piece (null = no limit) -let lastPaintTime = 0; // Last time paint() was executed -let lastPaintOut = undefined; // Store last paintOut to preserve correct noPaint behavior -let shouldSkipPaint = false; // Whether to skip this frame's paint call -let pieceFrameCount = 0; // Frame counter that only increments when piece actually paints - -// 🎬 Piece Transition System -// Two transition types available: "drip" (default, fast) and "bubble-wrap" (organic, slower) -// Set to "none" to disable transitions entirely (fast cut) -const TRANSITION_TYPE = "none"; // "none", "drip" or "bubble-wrap" - -// 🧬 Piece Transition State: overlay pixels → fades to reveal piece underneath -// Captures the OUTGOING piece's pixels when transitioning between pieces -let pieceTransition = { - active: false, - phase: "loading", // "loading" (slow, reach ~50%) or "revealing" (fast, finish) - overlayPixels: null, // The captured frame that overlays on top (from outgoing piece) - generation: 0, - maxGenerations: 60, // Total frames for full transition - width: 0, - height: 0, - // Drip-specific state (per-column y-offset) - BLINDS STYLE - dripOffsets: null, // Int16Array of column y-offsets (positive = distance moved) - dripSpeeds: null, // Float32Array of per-column speeds - dripDirections: null, // Int8Array: 1 = down, -1 = up (alternating blinds) - targetOffset: 0, // Target offset for loading phase (~50% of height) - // Bubble-wrap-specific state - blockData: null, // Per-block data for bubble-wrap - blockSize: 8, - blocksX: 0, - blocksY: 0, -}; - -// 🧬 Legacy alias for backwards compatibility -const golTransition = pieceTransition; - -// 🧬 Scale overlay pixels to match new resolution (nearest-neighbor) -function scaleOverlayPixels(srcPixels, srcWidth, srcHeight, dstWidth, dstHeight) { - const dstPixels = new Uint8ClampedArray(dstWidth * dstHeight * 4); - const xRatio = srcWidth / dstWidth; - const yRatio = srcHeight / dstHeight; - - for (let y = 0; y < dstHeight; y++) { - for (let x = 0; x < dstWidth; x++) { - const srcX = Math.floor(x * xRatio); - const srcY = Math.floor(y * yRatio); - const srcIdx = (srcY * srcWidth + srcX) * 4; - const dstIdx = (y * dstWidth + x) * 4; - - dstPixels[dstIdx] = srcPixels[srcIdx]; - dstPixels[dstIdx + 1] = srcPixels[srcIdx + 1]; - dstPixels[dstIdx + 2] = srcPixels[srcIdx + 2]; - dstPixels[dstIdx + 3] = srcPixels[srcIdx + 3]; - } - } - - return dstPixels; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 🪟 BLINDS TRANSITION - Alternating up/down strips (FAST & PERFORMANT) -// ═══════════════════════════════════════════════════════════════════════════ - -// Initialize blinds transition - alternating columns slide up/down -function initDripTransition(width, height) { - pieceTransition.dripOffsets = new Int16Array(width); - pieceTransition.dripSpeeds = new Float32Array(width); - pieceTransition.dripDirections = new Int8Array(width); - pieceTransition.maxGenerations = 30; // Faster! - pieceTransition.phase = "loading"; - pieceTransition.targetOffset = Math.floor(height * 0.35); // ~35% during loading (less wait) - - // Initialize per-column state - alternating directions like blinds - for (let x = 0; x < width; x++) { - pieceTransition.dripOffsets[x] = 0; // Start at 0 (overlay fully in place) - // Alternate: even columns slide DOWN, odd columns slide UP - pieceTransition.dripDirections[x] = (x % 2 === 0) ? 1 : -1; - // Faster speed during loading phase - pieceTransition.dripSpeeds[x] = 4 + Math.random() * 3; // 4-7 pixels per frame - } -} - -// Signal that piece is loaded - switch to fast reveal phase -function transitionPieceLoaded() { - if (pieceTransition.active && pieceTransition.phase === "loading" && pieceTransition.dripSpeeds) { - pieceTransition.phase = "revealing"; - // Boost speeds for the reveal phase - FAST! - for (let x = 0; x < pieceTransition.dripSpeeds.length; x++) { - pieceTransition.dripSpeeds[x] = 10 + Math.random() * 8; // 10-18 pixels per frame - } - console.log("🎬 Transition: Piece loaded! Switching to reveal phase"); - } -} - -// Step blinds transition - advance each column's offset -function dripStep() { - pieceTransition.generation++; - const { dripOffsets, dripSpeeds, dripDirections, height, phase, targetOffset } = pieceTransition; - - let allDone = true; - - for (let x = 0; x < dripOffsets.length; x++) { - const currentOffset = dripOffsets[x]; - - if (phase === "loading") { - // Loading phase: slowly move toward target (~45%) - if (currentOffset < targetOffset) { - dripOffsets[x] += dripSpeeds[x]; - // Clamp to target during loading - if (dripOffsets[x] > targetOffset) { - dripOffsets[x] = targetOffset; - } - allDone = false; - } - } else { - // Revealing phase: accelerate to completion - if (currentOffset < height) { - dripOffsets[x] += dripSpeeds[x]; - // Add acceleration (gravity/momentum) - dripSpeeds[x] += 0.3; - allDone = false; - } - } - } - - // During loading phase, never report "done" - wait for reveal - if (phase === "loading") { - return true; // Keep running - } - - return !allDone && pieceTransition.generation < pieceTransition.maxGenerations; -} - -// Apply blinds overlay - alternating columns slide up/down to reveal new frame -function applyDripOverlay(screenPixels) { - const { overlayPixels, dripOffsets, dripDirections, width, height } = pieceTransition; - if (!overlayPixels || !screenPixels || !dripOffsets || !dripDirections) return; - - for (let x = 0; x < width; x++) { - const offset = Math.max(0, dripOffsets[x] | 0); - const direction = dripDirections[x]; - - if (offset >= height) continue; // Column fully slid off - - const visibleRows = height - offset; - - if (direction > 0) { - // SLIDE DOWN - overlay moves down, reveals new frame from TOP - for (let y = 0; y < visibleRows; y++) { - const srcY = y; // Read from top of overlay - const dstY = y + offset; // Write shifted down - const srcIdx = (srcY * width + x) * 4; - const dstIdx = (dstY * width + x) * 4; - - screenPixels[dstIdx] = overlayPixels[srcIdx]; - screenPixels[dstIdx + 1] = overlayPixels[srcIdx + 1]; - screenPixels[dstIdx + 2] = overlayPixels[srcIdx + 2]; - screenPixels[dstIdx + 3] = overlayPixels[srcIdx + 3]; - } - } else { - // SLIDE UP - overlay moves up, reveals new frame from BOTTOM - for (let y = 0; y < visibleRows; y++) { - const srcY = offset + y; // Read from bottom portion of overlay - const dstY = y; // Write shifted up - const srcIdx = (srcY * width + x) * 4; - const dstIdx = (dstY * width + x) * 4; - - screenPixels[dstIdx] = overlayPixels[srcIdx]; - screenPixels[dstIdx + 1] = overlayPixels[srcIdx + 1]; - screenPixels[dstIdx + 2] = overlayPixels[srcIdx + 2]; - screenPixels[dstIdx + 3] = overlayPixels[srcIdx + 3]; - } - } - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 🫧 BUBBLE-WRAP TRANSITION - Organic morphing dissolve (DEPRECATED: slower) -// ═══════════════════════════════════════════════════════════════════════════ - -// Initialize bubble-wrap transition - per-pixel dissolve with organic noise -function initBubbleWrapCells(width, height) { - pieceTransition.blockSize = 1; // Per-pixel - pieceTransition.blocksX = width; - pieceTransition.blocksY = height; - pieceTransition.blockData = null; - pieceTransition.maxGenerations = 30; // Slower for organic feel -} - -// Step bubble-wrap transition -function bubbleWrapStep(screenPixels) { - pieceTransition.generation++; - return pieceTransition.generation < pieceTransition.maxGenerations; -} - -// Apply bubble-wrap overlay - chunky biological smoothie morph -function applyBubbleWrapOverlay(screenPixels) { - const { overlayPixels, width, height, generation, maxGenerations } = pieceTransition; - if (!overlayPixels || !screenPixels) return; - - // Progress 0-1 - const progress = generation / maxGenerations; - // Eased for smooth start/end - const eased = progress < 0.5 - ? 2 * progress * progress - : 1 - Math.pow(-2 * progress + 2, 2) / 2; - - // Time offset for animated noise - const t = generation * 0.1; - - // Displacement peaks mid-transition - CHUNKY - const dispPeak = Math.sin(progress * Math.PI); - const maxDisp = dispPeak * 16; - - // Block size for chunky feel - const bs = 4; - - for (let y = 0; y < height; y++) { - const rowOff = y * width; - // Quantize y for chunky blocks - const qy = (y / bs | 0) * bs; - - for (let x = 0; x < width; x++) { - const i = (rowOff + x) * 4; - // Quantize x for chunky blocks - const qx = (x / bs | 0) * bs; - - // Organic noise at block resolution for chunky dissolve - const n1 = Math.sin(qx * 0.05 + t) * Math.cos(qy * 0.04 + t * 0.7); - const n2 = Math.sin(qx * 0.09 + qy * 0.07 - t * 0.6) * 0.5; - const n3 = Math.cos(qx * 0.02 - qy * 0.03 + t * 0.4) * 0.4; - const noise = (n1 + n2 + n3) * 0.5 + 0.5; - - // Per-block blend with organic variation - const threshold = eased * 1.5 - 0.25; - const edge = 0.3; - let blend = (threshold - noise * 0.85) / edge; - blend = blend < 0 ? 0 : blend > 1 ? 1 : blend; - - // SMOOTHIE: chaotic displacement that samples from BOTH frames - const swirl = Math.sin(qx * 0.06 + qy * 0.04 + t * 1.5) * maxDisp; - const churn = Math.cos(qx * 0.05 - qy * 0.07 + t * 1.2) * maxDisp; - - // Sample old frame with swirling displacement - let ox = (x + swirl) | 0; - let oy = (y + churn) | 0; - ox = ox < 0 ? 0 : ox >= width ? width - 1 : ox; - oy = oy < 0 ? 0 : oy >= height ? height - 1 : oy; - const oi = (oy * width + ox) * 4; - - // Sample new frame with OPPOSITE swirl - mixing! - let nx = (x - churn * 0.7) | 0; - let ny = (y + swirl * 0.6) | 0; - nx = nx < 0 ? 0 : nx >= width ? width - 1 : nx; - ny = ny < 0 ? 0 : ny >= height ? height - 1 : ny; - const ni = (ny * width + nx) * 4; - - // Get swirled colors from both - const oldR = overlayPixels[oi]; - const oldG = overlayPixels[oi + 1]; - const oldB = overlayPixels[oi + 2]; - const newR = screenPixels[ni]; - const newG = screenPixels[ni + 1]; - const newB = screenPixels[ni + 2]; - - // Chunky biological blend - smoothie mix! - const inv = 1 - blend; - let r = oldR * inv + newR * blend; - let g = oldG * inv + newG * blend; - let b = oldB * inv + newB * blend; - - // Extra chaos: sometimes swap channels mid-transition - if (dispPeak > 0.5 && ((qx + qy + generation) & 7) === 0) { - const tmp = r; - r = g; - g = b; - b = tmp; - } - - screenPixels[i] = r; - screenPixels[i + 1] = g; - screenPixels[i + 2] = b; - } - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 🎬 UNIFIED TRANSITION API - Dispatches to drip, bubble-wrap, or none -// ═══════════════════════════════════════════════════════════════════════════ - -// Initialize transition based on current type -function initGOLCells(width, height) { - if (TRANSITION_TYPE === "none") return; // No transition - if (TRANSITION_TYPE === "drip") { - initDripTransition(width, height); - } else { - initBubbleWrapCells(width, height); - } -} - -// Step transition -function golStep(screenPixels) { - if (TRANSITION_TYPE === "none") return false; // Immediately done - if (TRANSITION_TYPE === "drip") { - return dripStep(); - } else { - return bubbleWrapStep(screenPixels); - } -} - -// Apply transition overlay -function applyGOLOverlay(screenPixels) { - if (TRANSITION_TYPE === "drip") { - applyDripOverlay(screenPixels); - } else { - applyBubbleWrapOverlay(screenPixels); - } -} - -let boot = defaults.boot; -let sim = defaults.sim; -let paint = defaults.paint; -let beat = defaults.beat; -let brush, lift, filter; // Only set in the `nopaint` system. -let act = defaults.act; -let leave = defaults.leave; -let receive = defaults.receive; // Handle messages from BIOS -let preview = defaults.preview; -let icon = defaults.icon; -let bake; // Currently only used by the `nopaint` system. - -let leaving = false; // Set to true on first piece. -let leaveLoad; // A callback for loading the next disk after leaving. - -let previewMode = false; // Detects ?preview on a piece and yields its -// preview function if it exists. -let firstPreviewOrIcon = true; -let iconMode = false; // Detects ?icon on a piece and yields its -// icon function if it exists. -let previewOrIconMode; -let hideLabel = false; -let hideLabelViaTab = false; // Track if label is hidden via tab key toggle - -// Helper function to toggle HUD visibility (used by Tab key and center tap) -function toggleHUDVisibility(isDoubleTap = false, skipSound = false) { - const currentTime = performance.now(); - - if (hudAnimationState.animating) { - // Animation in progress: reverse direction and continue from current position - const elapsed = currentTime - hudAnimationState.startTime; - const progress = Math.min(elapsed / hudAnimationState.duration, 1.0); - - // Flip the target state - hudAnimationState.visible = !hudAnimationState.visible; - - // Update the remembered state for when QR fullscreen is turned off - if (hudAnimationState.qrFullscreen) { - hudAnimationState.cornersVisibleBeforeFullscreen = hudAnimationState.visible; - } - - // Restart animation from current position by adjusting the start time - // If we were 30% through a hide animation, start the show animation at 70% progress - const remainingProgress = 1.0 - progress; - hudAnimationState.startTime = currentTime - (remainingProgress * hudAnimationState.duration); - - // Special double-tap: immediately show HUD if hiding - if (isDoubleTap && !hudAnimationState.visible) { - hudAnimationState.animating = false; - hudAnimationState.visible = true; - hudAnimationState.opacity = 1.0; - hudAnimationState.slideOffset = { x: 0, y: 0 }; - hudAnimationState.qrSlideOffset = { x: 0, y: 0 }; - - // Update remembered state - if (hudAnimationState.qrFullscreen) { - hudAnimationState.cornersVisibleBeforeFullscreen = true; - } - } - - if (!skipSound) { - $commonApi.sound.synth({ - type: "sine", - tone: hudAnimationState.visible ? 600 : 400, - duration: 0.15, - attack: 0.1, - decay: 0.8, - volume: 0.2, - }); - } - } else { - // No animation in progress: start new animation - hudAnimationState.animating = true; - hudAnimationState.startTime = currentTime; - hudAnimationState.visible = !hudAnimationState.visible; - - // Update the remembered state for when QR fullscreen is turned off - if (hudAnimationState.qrFullscreen) { - hudAnimationState.cornersVisibleBeforeFullscreen = hudAnimationState.visible; - } - - if (!skipSound) { - $commonApi.sound.synth({ - type: "sine", - tone: hudAnimationState.visible ? 600 : 400, - duration: 0.15, - attack: 0.1, - decay: 0.8, - volume: 0.2, - }); - } - } -} - -// Helper function to toggle QR fullscreen mode for KidLisp pieces -function toggleQRFullscreen() { - try { - // Only allow QR fullscreen for KidLisp pieces that have QR codes - const sourceCode = currentText || currentHUDTxt; - console.log("🔍 [QR Toggle] sourceCode:", sourceCode, "currentPath:", currentPath); - - // Use the centralized KidLisp detection from kidlisp.mjs - const isInlineKidlispPiece = (currentPath && lisp.isKidlispSource(currentPath) && !currentPath.endsWith('.lisp')) || - currentPath === "(...)" || - (sourceCode && sourceCode.startsWith("$")) || - (currentPath && currentPath.includes("/disks/$")) || - (sourceCode && lisp.isKidlispSource(sourceCode)); - - console.log("🔍 [QR Toggle] isInlineKidlispPiece:", isInlineKidlispPiece); - - if (!isInlineKidlispPiece) { - console.log("⚠️ [QR Toggle] Not a KidLisp piece, skipping QR fullscreen toggle"); - return; - } - - if (!hudAnimationState.qrFullscreen) { - // Turning ON fullscreen QR - console.log("✅ [QR Toggle] Enabling fullscreen mode"); - - // Remember current corner visibility state - hudAnimationState.cornersVisibleBeforeFullscreen = hudAnimationState.visible; - - // If corners are currently visible, animate them out for fullscreen QR - if (hudAnimationState.visible) { - // Check if animation is already in progress - if (hudAnimationState.animating) { - // Animation in progress: reverse it - const currentTime = performance.now(); - const elapsed = currentTime - hudAnimationState.startTime; - const remaining = Math.max(0, hudAnimationState.duration - elapsed); - hudAnimationState.startTime = currentTime - remaining; - } else { - // No animation in progress: start new animation - hudAnimationState.animating = true; - hudAnimationState.startTime = performance.now(); - } - hudAnimationState.visible = false; - } - - hudAnimationState.qrFullscreen = true; - - // Remove corner QR hitbox when going fullscreen - send({ type: "button:hitbox:remove", content: "qr-corner" }); - - } else { - // Turning OFF fullscreen QR - console.log("✅ [QR Toggle] Disabling fullscreen mode"); - hudAnimationState.qrFullscreen = false; - - // Remove fullscreen QR hitbox - send({ type: "button:hitbox:remove", content: "qr-fullscreen" }); - - // Restore corner visibility to what it was before fullscreen QR was activated - if (hudAnimationState.cornersVisibleBeforeFullscreen && !hudAnimationState.visible) { - // Check if animation is already in progress - if (hudAnimationState.animating) { - // Animation in progress: reverse it - const currentTime = performance.now(); - const elapsed = currentTime - hudAnimationState.startTime; - const remaining = Math.max(0, hudAnimationState.duration - elapsed); - hudAnimationState.startTime = currentTime - remaining; - } else { - // No animation in progress: start new animation - hudAnimationState.animating = true; - hudAnimationState.startTime = performance.now(); - } - hudAnimationState.visible = true; - } - } - - // Play sound feedback - if ($commonApi?.sound?.synth) { - $commonApi.sound.synth({ - type: "sine", - tone: hudAnimationState.qrFullscreen ? 500 : 250, - duration: 0.12, - attack: 0.1, - decay: 0.9, - volume: 0.25, - }); - } - } catch (err) { - console.error("❌ [QR Toggle] Error toggling QR fullscreen:", err); - } -} - -// In PACK mode, show HUD for non-KidLisp pieces (JS bundles) but hide for KidLisp -const _packModeHudVisible = getPackMode() - ? (typeof window !== 'undefined' && window.acPACK_COLOPHON?.piece?.isKidLisp === false) - : true; -let hudAnimationState = { - visible: _packModeHudVisible, - animating: false, - startTime: 0, - duration: 500, // 500ms animation - opacity: 1.0, - slideOffset: { x: 0, y: 0 }, // HUD label offset (slides to top-left) - qrSlideOffset: { x: 0, y: 0 }, // QR overlay offset (slides to bottom-right) - labelWidth: 120, // HUD label width - updated when HUD is drawn - labelHeight: 40, // HUD label height - updated when HUD is drawn - qrSize: 80, // QR overlay size for bounding box animations - lastTabTime: 0, // Track last tab press for double-tap detection - qrFullscreen: false, // Track if QR code is in fullscreen mode - cornersVisibleBeforeFullscreen: true // Remember corner state before fullscreen QR -}; - -// Clock melody cached code for QR display (received from clock.mjs) -let cachedClockCode = null; - -let module, loadedModule; // Currently loaded piece module code with an extra reference for `hotSwap`. - -// 🎨 Enhanced painting change detection with immediate pixel monitoring -let lastPaintingHash = null; -let paintingChangeCheckInterval = null; -let frameBasedMonitoring = false; - -// Performance optimization: Use requestAnimationFrame for more efficient monitoring -function enableFrameBasedMonitoring() { - if (frameBasedMonitoring) return; - frameBasedMonitoring = true; - - let lastDimensions = null; - - function checkPaintingChanges() { - if ($commonApi.system?.painting && !$commonApi._processingBroadcast) { - const currentHash = generatePaintingHash($commonApi.system.painting); - const currentDimensions = { - width: $commonApi.system.painting.width, - height: $commonApi.system.painting.height - }; - - // Add detailed logging for nopaint interference detection - const isNopaintActive = $commonApi.system?.nopaint?.is?.("painting"); - const nopaintBuffer = $commonApi.system?.nopaint?.buffer; - - if (lastPaintingHash !== null && currentHash !== lastPaintingHash) { - // Check if this is a dimension change (resize operation) - const isDimensionChange = lastDimensions && - (lastDimensions.width !== currentDimensions.width || - lastDimensions.height !== currentDimensions.height); - - // Skip broadcasting during active nopaint operations to prevent interference - if (isNopaintActive) { - console.log(`🚫 SKIPPING broadcast during nopaint operation to prevent live preview interference`); - } else { - // Immediate broadcast with appropriate source - $commonApi.broadcastPaintingUpdateImmediate("frame_update", { - source: isDimensionChange ? "resize" : "frame_monitor", - hash: currentHash.substr(0,8) - }); - } - } - - lastPaintingHash = currentHash; - lastDimensions = currentDimensions; - } - - if (frameBasedMonitoring) { - requestAnimationFrame(checkPaintingChanges); - } - } - - requestAnimationFrame(checkPaintingChanges); -} - -// 🎨 Broadcast throttling to prevent infinite loops -let lastBroadcastTime = 0; -let broadcastThrottleDelay = 100; // Minimum ms between broadcasts -let pieceMetadata = null; // Metadata for current piece (trustLevel, etc.) -let currentPath, - currentHost, - currentSearch, - currentColon, - currentParams, - currentHash, - currentText, - currentCode, - currentHUDTxt, - currentHUDPlainTxt, // Plain text version without color codes - currentOriginalCodeId, // Track original $code identifier for sharing - currentHUDTextColor, - currentHUDStatusColor = "red", - currentHUDButton, - currentHUDButtonActive = false, // Global flag to block other button interactions when HUD is active - currentHUDButtonDirectTouch = false, // Track if HUD button was directly tapped (not rolled over) - currentHUDScrub = 0, - currentHUDScrubReadyState = null, // null | "share" | "edit" - currentHUDLabelFontName, - currentHUDLabelBlockWidth = tf?.blockWidth ?? DEFAULT_TYPEFACE_BLOCK_WIDTH, - currentHUDLabelBlockHeight = tf?.blockHeight ?? DEFAULT_TYPEFACE_BLOCK_HEIGHT, - currentHUDShareWidth = (tf?.blockWidth ?? DEFAULT_TYPEFACE_BLOCK_WIDTH) * "share ".length, - currentHUDEditWidth = tf?.blockWidth ?? DEFAULT_TYPEFACE_BLOCK_WIDTH, - currentHUDLabelMeasuredWidth = 0, - currentHUDTextHeight = 0, - currentHUDTextBoxWidth = 0, - currentHUDTextBoxHeight = 0, - currentHUDLeftPad = 0, - currentHUDOffset, - currentHUDQR = null, // QR code URL to show to the left of label (set via hud.qr()) - currentHUDQRCells = null, // Cached QR code cells - currentHUDAuthor = null, // @handle of the author for KidLisp pieces - currentHUDHits = null, // Hit count for KidLisp pieces - currentHUDSuperscript = null, // Custom superscript text (e.g. ".com") shown after label in cyan - forceTinyHudLabel = false, // Force MatrixChunky8 font for HUD label (set via hud.tinyLabel()) - qrOverlayCache = new Map(), // Cache for QR overlays to prevent regeneration every frame - hudLabelCache = null; // Cache for HUD label to prevent regeneration every frame - -// 📱 LAN Dev mode identity (set by session server in dev mode) -let devIdentity = null; // { name, host, hostIp, mode, connectionId } - -// 📡 Remote log forwarding for LAN Dev mode -let remoteLogQueue = []; -let remoteLogSocket = null; -const MAX_REMOTE_LOG_QUEUE = 50; - -function setupRemoteLogging() { - const originalConsole = { - log: console.log.bind(console), - warn: console.warn.bind(console), - error: console.error.bind(console), - }; - - function sendRemoteLog(level, args) { - if (!devIdentity || !remoteLogSocket?.connected) { - // Queue logs until connected - if (remoteLogQueue.length < MAX_REMOTE_LOG_QUEUE) { - remoteLogQueue.push({ level, args: serializeArgs(args), time: Date.now() }); - } - return; - } - - try { - const logData = { - level, - args: serializeArgs(args), - deviceName: devIdentity.name, - connectionId: devIdentity.connectionId, - time: Date.now(), - }; - remoteLogSocket.send("dev:log", logData); - } catch (e) { - // Silently fail to avoid infinite loop - } - } - - function serializeArgs(args) { - return args.map(arg => { - if (arg === null) return 'null'; - if (arg === undefined) return 'undefined'; - if (typeof arg === 'string') return arg; - if (typeof arg === 'number' || typeof arg === 'boolean') return String(arg); - if (arg instanceof Error) return `${arg.name}: ${arg.message}\n${arg.stack}`; - try { - return JSON.stringify(arg, null, 2); - } catch (e) { - return String(arg); - } - }); - } - - // Intercept console methods - console.log = (...args) => { - originalConsole.log(...args); - sendRemoteLog('log', args); - }; - - console.warn = (...args) => { - originalConsole.warn(...args); - sendRemoteLog('warn', args); - }; - - console.error = (...args) => { - originalConsole.error(...args); - sendRemoteLog('error', args); - }; - - // Capture unhandled errors (only in main thread with window) - if (typeof window !== 'undefined') { - window.addEventListener('error', (event) => { - sendRemoteLog('error', [`Uncaught: ${event.message} at ${event.filename}:${event.lineno}:${event.colno}`]); - }); - - window.addEventListener('unhandledrejection', (event) => { - sendRemoteLog('error', [`Unhandled Promise: ${event.reason}`]); - }); - } -} - -function flushRemoteLogQueue() { - if (!devIdentity || !remoteLogSocket?.connected) return; - - while (remoteLogQueue.length > 0) { - const log = remoteLogQueue.shift(); - try { - remoteLogSocket.send("dev:log", { - level: log.level, - args: log.args, - deviceName: devIdentity.name, - connectionId: devIdentity.connectionId, - time: log.time, - queued: true, - }); - } catch (e) { - break; - } - } -} - -let lastMatrixChunkyHighlightLog = null; -let lastMatrixChunkyWriteDiagnosticLog = null; -let lastMatrixChunkyPixelLog = null; - - - -// Helper functions to safely access window flags in both main thread and worker contexts -function isQROverlayCacheDisabled() { - try { - return typeof window !== 'undefined' && window.acDISABLE_QR_OVERLAY_CACHE; - } catch (e) { - return false; // Default to enabled (caching on) - } -} - -function isHUDLabelCacheDisabled() { - try { - return typeof window !== 'undefined' && window.acDISABLE_HUD_LABEL_CACHE; - } catch (e) { - return false; // Default to enabled if we can't check - } -} - -// Make cache globally accessible for character loading system -if (typeof window !== 'undefined') { - window.qrOverlayCache = qrOverlayCache; - - // Clear caches if they are disabled from BIOS - if (isQROverlayCacheDisabled()) { - qrOverlayCache.clear(); - // console.log("🚫 QR overlay cache disabled and cleared from BIOS"); - } - - // if (isHUDLabelCacheDisabled()) { - // console.log("🚫 HUD label cache disabled from BIOS"); - // } -} -//currentPromptButton; - -const COLOR_CODE_MATCH_REGEX = /\\([^\\]+)\\/g; -const COLOR_CODE_TEST_REGEX = /\\[^\\]+\\/; - -// Utility function to strip color codes from text -function stripColorCodes(str) { - if (!str) return str; - // Remove all \\color\\ sequences including: - // - Named colors: \\red\\, \\blue\\, \\cyan\\ - // - RGB values: \\255,20,147\\, \\192,192,192\\ - // - Complex patterns: \\color(args)\\ - COLOR_CODE_MATCH_REGEX.lastIndex = 0; - return str.replace( - COLOR_CODE_MATCH_REGEX, - "", - ); -} - -function textContainsColorCodes(str) { - if (!str) return false; - return COLOR_CODE_TEST_REGEX.test(str); -} - -function hasKidLispMarkers(text) { - if (!text) return false; - return ( - text.includes("ink ") || - text.includes("line ") || - text.includes("box ") || - text.includes("circle ") || - text.includes("spin ") || - text.includes("(") || - textContainsColorCodes(text) || - /\d+s\.\.\./.test(text) || - /\?\s/.test(text) - ); -} - -function detectKidLispPiece({ currentPath, currentHUDTxt, currentText, cleanText }) { - const sourceCode = currentText || currentHUDTxt; - - return ( - (currentPath && lisp.isKidlispSource(currentPath) && !currentPath.endsWith('.lisp')) || - currentPath === "(...)" || - (sourceCode && sourceCode.startsWith("$")) || - (currentPath && (currentPath.includes("/disks/$") || currentPath.includes("$"))) || - (sourceCode && lisp.isKidlispSource && lisp.isKidlispSource(sourceCode)) || - hasKidLispMarkers(currentHUDTxt) || - textContainsColorCodes(cleanText) || - hasKidLispMarkers(sourceCode) - ); -} - -// Detect embedded/inline KidLisp specifically (kidlisp.com editor, PJ KidLisp) -// Does NOT include .lisp file pieces - only inline/embedded code -function detectEmbeddedKidLisp({ currentPath, currentHUDTxt, currentText }) { - const sourceCode = currentText || currentHUDTxt; - return ( - currentPath === "(...)" || - (sourceCode && sourceCode.startsWith("$")) || - (currentPath && currentPath.includes("/disks/$")) || - (currentPath && currentPath.includes("$") && !currentPath.endsWith('.lisp')) - ); -} - -function updateHUDStatus() { - if (udp.connected && socket?.connected) { - currentHUDStatusColor = "lime"; - } else if (udp.connected || socket?.connected) { - currentHUDStatusColor = "orange"; - } else { - currentHUDStatusColor = "red"; - } -} - -let loading = false; -let loadingStartTime = null; // Track when loading started for timeout safety -const LOADING_TIMEOUT_MS = 10000; // 10 second max loading time - -// Safety check for stuck loading state -function checkLoadingTimeout() { - if (loading && loadingStartTime && (Date.now() - loadingStartTime > LOADING_TIMEOUT_MS)) { - console.warn("🔴 Loading timeout exceeded - forcing loading reset"); - loading = false; - loadingStartTime = null; - return true; // Was stuck - } - return false; -} - -let reframe; -let pendingPieceReload = null; // Queue for piece-reload messages before $commonApi.reload is ready - -const sfxProgressReceivers = {}, - sfxSampleReceivers = {}, - sfxKillReceivers = {}, - sfxDurationReceivers = {}; -let $sampleCount = 0n; - -const signals = []; // Easy messages from embedded DOM content. -const actAlerts = []; // Messages that get put into act and cleared after -// every frame. -let reframed = false; -let formReframing = false; // Just for 3D camera updates. - -let paintings = {}; // Cached bitmaps from a piece. - -// Expose pending image count for oven/grabber to poll (available immediately) -if (typeof window !== "undefined") { - window.acPendingImages = () => - Object.values(paintings).filter((v) => v === "fetching").length; -} - -// 🖼️ Image cache system for paste/stamp - persists across refreshes -// Similar to $kidlisp code cache but for image data -const imageCache = { - // In-memory cache for fast access (RAM) - memory: new Map(), // url -> ImageBitmap - - // IndexedDB persistence reference (set during boot) - store: null, - - // Initialize with store reference for persistent caching - init(storeRef) { - this.store = storeRef; - }, - - // Get from memory cache - get(url) { - return this.memory.get(url); - }, - - // Check if image is cached in memory - has(url) { - return this.memory.has(url); - }, - - // Save to both memory and persistent storage - async set(url, imageBitmap) { - // Save to memory - this.memory.set(url, imageBitmap); - - // Save to IndexedDB for persistence across refreshes - if (this.store && imageBitmap) { - try { - let pixelData, imgWidth, imgHeight; - - if (imageBitmap.pixels) { - // Already a pixel buffer { width, height, pixels } - imgWidth = imageBitmap.width; - imgHeight = imageBitmap.height; - pixelData = imageBitmap.pixels; - } else { - // ImageBitmap — convert to pixel data via canvas drawImage - const canvas = typeof OffscreenCanvas !== 'undefined' - ? new OffscreenCanvas(imageBitmap.width, imageBitmap.height) - : document.createElement('canvas'); - canvas.width = imageBitmap.width; - canvas.height = imageBitmap.height; - const ctx = canvas.getContext('2d'); - ctx.drawImage(imageBitmap, 0, 0); - const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); - imgWidth = imageData.width; - imgHeight = imageData.height; - pixelData = imageData.data; - } - - // Store the pixel data along with metadata - this.store[`image-cache:${url}`] = { - width: imgWidth, - height: imgHeight, - data: Array.from(pixelData), - cached: Date.now(), - version: 1 - }; - this.store.persist(`image-cache:${url}`, "local:db"); - } catch (error) { - console.warn(`Failed to save image to persistent cache: ${url}`, error); - } - } - }, - - // Load from persistent storage (IndexedDB) - async loadFromPersistent(url) { - if (!this.store) return null; - - try { - const data = await this.store.retrieve(`image-cache:${url}`, "local:db"); - if (data && data.data && data.width && data.height) { - // Reconstruct ImageData from stored array - const imageData = new ImageData( - new Uint8ClampedArray(data.data), - data.width, - data.height - ); - - // Convert ImageData back to ImageBitmap - const imageBitmap = await createImageBitmap(imageData); - - // Cache in memory for future access - this.memory.set(url, imageBitmap); - - return imageBitmap; - } - } catch (error) { - console.warn(`Failed to load image from persistent cache: ${url}`, error); - } - return null; - }, - - // Clear all caches - clear() { - this.memory.clear(); - }, - - // Check if URL is from aesthetic.computer media backend (safe to cache) - isCacheable(url) { - if (!url) return false; - const urlStr = url.toString().toLowerCase(); - return ( - urlStr.includes('aesthetic.computer') || - urlStr.includes('art.aesthetic.computer') || - urlStr.includes('/media/@') || // User paintings - urlStr.match(/\/@[\w-]+\/\d+/) // @handle/timestamp format - ); - } -}; - -let screen; -let currentDisplay; // TODO: Remove this? 22.09.29.11.38 -let cursorCode; -let pieceHistoryIndex = -1; // Gets incremented to 0 when first piece loads. -let paintCount = 0n; -let simCount = 0n; -let booted = false; -// let initialSim = true; -let noPaint = false; -let labelBack = false; -let hiccupTimeout; // Prevent multiple hiccups from being triggered at once. - -// 🌐 Check if running on a branded .com domain (e.g. notepat.com) and redirect to aesthetic.computer -function redirectIfBrandedDomain() { - const { hostname } = getSafeUrlParts(); - if (!hostname) return false; - // Match notepat.com or www.notepat.com (add more branded domains here as needed) - if (hostname === "notepat.com" || hostname === "www.notepat.com") { - send({ type: "web", content: { url: "https://aesthetic.computer/notepat", blank: false } }); - return true; - } - return false; -} - -let storeRetrievalResolutions = {}, - storeDeletionResolutions = {}; - -// USB flash support (Electron only) -let usbDeviceListResolution = null; -let usbFlashResolution = null; - -// There are two instances of Socket that run in parallel... -let socket, socketStartDelay; // Socket server for each piece. - -// ❤️‍🔥 TODO: Explose these somehow to the $commonApi. - -// TODO: Extract `chat` into an external class. - -const chatDebug = - location.host === "local.aesthetic.computer" || - location.host === "localhost:8888" || - location.host === "aesthetic.local:8888"; -const chatClient = new Chat(chatDebug, send); - -let udp = { - send: (type, content) => { - send({ type: "udp:send", content: { type, content } }); - }, - receive: ({ type, content }) => { - // console.log("🩰 Received `piece` message from UDP:", type, content); - - // 🧚 Ambient cursor (fairies) support. Disabled for KidLisp pieces. - if (type === "fairy:point" /*&& socket?.id !== id*/ && visible) { - const isKidlisp = detectKidLispPiece({ currentPath, currentHUDTxt, currentText }) || - (currentPath && currentPath.endsWith('.lisp')); - if (!isKidlisp) { - fairies.push({ x: content.x, y: content.y }); - } - return; - } - - udpReceive?.(type, content); - }, - kill: (outageSeconds) => { - udp.connected = false; - send({ type: "udp:disconnect", content: { outageSeconds } }); - }, - connected: false, - }, - udpReceive = undefined; - -let scream = null; // 😱 Allow priviledged users to send alerts to everyone. -// (A great end<->end socket + redis test.) -let screaming = false; -let screamingTimer; // Keep track of scream duration. - -const fairies = []; // Render cursor points of other active users, -// dumped each frame. - -let glazeEnabled = false; // Keep track of whether glaze is on or off. - -// *** Dark Mode *** -// (By @tarighian) -// Pass `true` or `false` to override or `default` to the system setting. -function darkMode(enabled) { - if (enabled === "default") { - darkMode($commonApi.dark || false); - store.delete("dark-mode"); - actAlerts.push($commonApi.dark ? "dark-mode" : "light-mode"); - return $commonApi.dark; - } else { - // true or false - store["dark-mode"] = enabled; - store.persist("dark-mode"); - $commonApi.dark = enabled; - actAlerts.push($commonApi.dark ? "dark-mode" : "light-mode"); - return enabled; - } -} - -// *** Store *** -// This object is used to store and retrieve data across disks -// The `local` method encodes everything as a JSON string. - -// Note: It strangely also contains an API which could be redefined -// unintentionally. 22.10.15.01.57 -// 22.11.25.11.07: The methods should be refactored out of the storage object / not share keys. -const store = { - persist: function (key, method = "local") { - // Send data over the thread through a key in this object. - send({ - type: "store:persist", - content: { - key, - data: this[key], - method, - }, - }); - // TODO: Turn the existing key into a retrieval function / promise? - }, - retrieve: function (key, method = "local") { - const promise = new Promise((resolve) => { - storeRetrievalResolutions[key] = resolve; - }); - - send({ type: "store:retrieve", content: { key, method } }); - - return promise; - }, - delete: function (key, method = "local") { - // Remove the key from the ram store, no matter what the method. - delete store[key]; - - const promise = new Promise((resolve) => { - storeDeletionResolutions[key] = resolve; - }); - - send({ - type: "store:delete", - content: { - key, - method, - }, - }); - return promise; - }, -}; - -// Promise based API calls (through `bios` and back) -let fileImport; -let serverUpload, serverUploadProgressReporter; -let zipCreation; -let authorizationRequest; -let fileOpenRequest; -let fileEncodeRequest; -let gpuResponse; -let web3Response; -let tezosConnectResponse; -let tezosDisconnectResponse; -let tezosAddressResponse; -let tezosSignResponse; -let tezosCallResponse; - -// Other -let activeVideo; // TODO: Eventually this can be a bank to store video textures. -let videoDeviceCount = 0; -let lastActiveVideo; -let videoSwitching = false; -let preloadPromises = {}; -let inFocus; -let loadFailure; - -// 🎄 Piece code cache for merry preloading -// Maps piece slug -> { code, blobUrl, type: 'mjs' | 'lisp' } -const pieceCodeCache = new Map(); - -// Duration tracking for playlist progress bar (similar to tape system) -let durationStartTime = null; // When the piece started (null if no duration) - using performance.now() -let durationTotal = null; // Total duration in seconds (null if no duration) -let durationProgress = 0; // Current progress (0-1) -let durationCompleted = false; // Whether duration has completed -let durationBlinkState = false; // For blinking the completed bar -let pageLoadTime = performance.now(); // Time when the page first loaded - using performance.now() - -// 1. ✔ API - -// TODO: Eventually add a wiggle bank so all wiggles are indexed -// and start at random angles. -// let wiggler = 0; -let wiggleAngle = 0; - -// TODO; Change this to true and update all brushes. -// let NPdontPaintOnLeave = false; -let NPnoOnLeave = false; - -// 🔴 Recorder (Singleton) -class Recorder { - printProgress = 0; - presentProgress = 0; - printing = false; // Set by a callback from `bios`. - printed = false; // " - recording = false; // " - rollingCallback; - recorded = false; // " - presenting = false; // " - playing = false; // " - cutCallback; - printCallback; - framesCallback; - loadCallback; - - tapeTimerStart; - tapeProgress = 0; - tapeTimerDuration; - tapeFrameMode = false; // Whether we're recording based on frames instead of time - tapeFrameStart = 0; // Starting frame count - tapeFrameTarget = 0; // Target number of frames to record - - videoOnLeave = false; - - constructor() {} - - tapeTimerSet(durationOrFrames, time, isFrameMode = false) { - if (isFrameMode) { - // Frame-based recording - this.tapeFrameMode = true; - this.tapeFrameStart = Number($commonApi.paintCount || 0n); - this.tapeFrameTarget = durationOrFrames; - this.tapeTimerStart = null; - this.tapeTimerDuration = null; - - log.tape.log(`Starting frame-based recording: ${durationOrFrames} frames`); - - // Set a longer failsafe for frame-based recording (frames could take a while) - if (this.failsafeTimeout) { - clearTimeout(this.failsafeTimeout); - } - - this.failsafeTimeout = setTimeout(() => { - log.tape.warn("Frame-based failsafe triggered! Recording may have stalled."); - this.tapeProgress = 0; - this.tapeFrameMode = false; - this.tapeFrameStart = 0; - this.tapeFrameTarget = 0; - - if (typeof this.cut === 'function') { - this.cut(() => { - $commonApi.jump("video"); - }); - } else { - log.tape.warn("Cut function not available in failsafe"); - $commonApi.jump("video"); - } - this.failsafeTimeout = null; - }, Math.max(30000, durationOrFrames * 1000)); // At least 30 seconds, or 1 second per frame - } else { - // Time-based recording (existing behavior) - this.tapeFrameMode = false; - this.tapeTimerStart = time; - this.tapeTimerDuration = durationOrFrames; - - // Add a failsafe timer as backup in case tapeTimerStep stops being called - if (this.failsafeTimeout) { - clearTimeout(this.failsafeTimeout); - } - - // Set a failsafe that will trigger cut if the normal timer fails - this.failsafeTimeout = setTimeout(() => { - log.tape.warn("Failsafe timer triggered! Normal timer may have failed."); - if (this.tapeTimerDuration && this.tapeTimerStart) { - this.tapeProgress = 0; - this.tapeTimerStart = null; - this.tapeTimerDuration = null; - - if (typeof this.cut === 'function') { - this.cut(() => { - $commonApi.jump("video"); - }); - } else { - console.warn(`🎬 ⚠️ Cut function not available in failsafe, manual jump to video`); - $commonApi.jump("video"); - } - } - this.failsafeTimeout = null; - }, (durationOrFrames + 1) * 1000); // Add 1 second buffer to the failsafe - } - } - - tapeTimerStep({ needsPaint, sound: { time } }) { - if (this.tapeFrameMode) { - // Frame-based recording - if (!this.tapeFrameTarget) return; - - const currentFrame = Number($commonApi.paintCount || 0n); - const framesPassed = currentFrame - this.tapeFrameStart; - - this.tapeProgress = framesPassed / this.tapeFrameTarget; - needsPaint(); - - // Check if we've reached or exceeded the target frame count - if (framesPassed >= this.tapeFrameTarget) { - // Clear failsafe since normal timer completed - if (this.failsafeTimeout) { - clearTimeout(this.failsafeTimeout); - this.failsafeTimeout = null; - } - - this.tapeProgress = 0; - this.tapeFrameMode = false; - this.tapeFrameStart = 0; - this.tapeFrameTarget = 0; - - log.tape.success(`Frame-based recording complete: ${framesPassed} frames`); - - // Add safety check for callback existence - if (typeof this.cut === 'function') { - this.cut(() => { - $commonApi.jump("video"); - }); - } else { - log.tape.warn("Cut function not available"); - $commonApi.jump("video"); - } - } - } else { - // Time-based recording (existing behavior) - if (!this.tapeTimerDuration) return; - - // Enhanced timing with race condition protection - this.tapeProgress = (time - this.tapeTimerStart) / this.tapeTimerDuration; - needsPaint(); - - const secondsOver = - this.tapeProgress * this.tapeTimerDuration - this.tapeTimerDuration; - - // Run for an extra 150 milliseconds. - if (this.tapeProgress >= 1 && secondsOver > 0.15) { - // Clear failsafe since normal timer completed - if (this.failsafeTimeout) { - clearTimeout(this.failsafeTimeout); - this.failsafeTimeout = null; - } - - this.tapeProgress = 0; - this.tapeTimerStart = null; - this.tapeTimerDuration = null; - - // Add safety check for callback existence - if (typeof this.cut === 'function') { - this.cut(() => { - $commonApi.jump("video"); - }); - } else { - log.tape.warn("Cut function not available"); - $commonApi.jump("video"); - } - } - } - } - - slate() { - send({ type: "recorder:slate" }); // Kill the MediaRecorder instance. - // TODO: Should printing and playing also be set to false? - //$commonApi.rec.printing = false; // " - $commonApi.rec.recording = false; // Reset this singleton. - $commonApi.rec.recorded = false; // - $commonApi.rec.printed = false; // " - $commonApi.rec.printProgress = 0; // " - $commonApi.rec.cleanMode = false; // Reset clean mode flag - this.tapeFrameMode = false; - this.tapeFrameStart = 0; - this.tapeFrameTarget = 0; - } - - rolling(opts, cb) { - send({ type: "recorder:rolling", content: opts }); - this.rollingCallback = cb; - } - - cut(cb) { - $commonApi.rec.cutCallback = cb; - this.tapeProgress = 0; - this.tapeTimerStart = null; - this.tapeTimerDuration = null; - this.tapeFrameMode = false; - this.tapeFrameStart = 0; - this.tapeFrameTarget = 0; - send({ type: "signal", content: "recorder:cut" }); - } - - print(cb) { - $commonApi.rec.printCallback = cb; - send({ type: "recorder:print" }); - } - - present(noplay = false) { - this.presentProgress = 0; - send({ type: "recorder:present", content: { noplay } }); - } - - unpresent() { - send({ type: "recorder:unpresent" }); - } - - play() { - send({ type: "recorder:present:play" }); - } - - pause() { - send({ type: "recorder:present:pause" }); - } - - requestFrames(cb) { - $commonApi.rec.framesCallback = cb; - send({ type: "recorder:request-frames" }); - } -} - -let cachedAPI; // 🪢 This is a bit hacky. 23.04.21.14.59 - -const hourGlasses = []; - -async function uploadPainting(picture, progress, handle, filename) { - if (typeof picture === "string") { - return { url: picture }; // Assume a URL and just return it. - } else { - // Assume picture is a buffer with `{ pixels, width, height }` - // Upload it to the temporary bucket. - filename ||= `painting-${num.timestamp()}.png`; - - try { - const data = await $commonApi.upload( - filename, - { - pixels: picture.pixels, - width: picture.width, - height: picture.height, - }, - (p) => { - console.log("Painting upload progress:", p); - progress?.(p); - }, - !handle ? "art" : undefined, // Store in temporary if no HANDLE. - ); - console.log("🪄 Painting uploaded:", data.slug, data.ext, data.url); - return data; - } catch (err) { - console.error("🪄 Painting upload failed:", err); - } - } -} - -// Returns whether leaving a piece and also can overwrite the value. -function isLeaving(set) { - if (set === true || set === false) leaving = set; - return leaving; -} - -let docs; // Memorized by `requestDocs`. - -let baseTime = Date.now(); // Virtual clock base -let baseReal = Date.now(); // Real time at last baseTime -let clockFetching = false; -let lastServerTime = undefined; -let clockOffset = 0; // Smoothed offset from server - -// 🤖 Robo Class - For sending synthetic events through the act system -class Robo { - constructor() { - this.currentAPI = null; - } - - // Set the current API context (called from makeFrame) - setAPI(api) { - this.currentAPI = api; - } - - // Send synthetic events directly to the nopaint system - sendEvent(eventType, coordinates = {}) { - if (!this.currentAPI) { - console.warn("🤖 Robo: No API context available"); - return; - } - - // Ensure pen object exists and initialize if needed - if (!this.currentAPI.pen) { - this.currentAPI.pen = { x: 0, y: 0, px: 0, py: 0, pressure: 0.5, device: "robot", delta: { x: 0, y: 0 } }; - console.log("🤖 Robo: Initialized pen object for robot events"); - } - - // Update the pen object with robot event data including pressure - const x = coordinates.x ?? 0; - const y = coordinates.y ?? 0; - const px = coordinates.px ?? coordinates.x ?? 0; - const py = coordinates.py ?? coordinates.y ?? 0; - const pressure = coordinates.pressure ?? 0.5; - - this.currentAPI.pen.x = x; - this.currentAPI.pen.y = y; - this.currentAPI.pen.px = px; - this.currentAPI.pen.py = py; - this.currentAPI.pen.pressure = pressure; - this.currentAPI.pen.device = "robot"; - - // Update delta if it exists - if (this.currentAPI.pen.delta) { - this.currentAPI.pen.delta.x = x - px; - this.currentAPI.pen.delta.y = y - py; - } - - // Create event payload that mirrors real input events - const eventPayload = { - device: "robot", - type: eventType, - x, - y, - px, - py, - pressure, - delta: { - x: x - px, - y: y - py, - }, - }; - - try { - this.currentAPI.act(eventType, eventPayload); - console.log(`🤖 Robo: Dispatched ${eventType} via act() at (${x}, ${y})`); - } catch (error) { - console.error("🤖 Robo: Error dispatching event:", error); - } - } - - // Convenience methods for common events - touch(x, y) { - this.sendEvent("touch:1", { x, y, pressure: 0.5 }); - } - - draw(x, y, px, py) { - this.sendEvent("draw:1", { x, y, px, py, pressure: 0.5 }); - } - - lift(x, y) { - this.sendEvent("lift:1", { x, y, pressure: 0.5 }); - } - - // Generic method for any event type - act(eventType, coordinates) { - this.sendEvent(eventType, coordinates); - } -} - -// 🎹 Persistent DAW state - survives frame updates since $commonApi.sound gets recreated each frame -const persistentDawState = { - bpm: null, - playing: null, - time: null, - sampleRate: null, -}; - -// 🎛️ Persistent spreadnob state -const persistentSpreadnobState = { - note: null, - target: null, - value: null, - active: null, - min: null, - max: null, -}; - -const $commonApi = { - lisp, // A global reference to the `kidlisp` evalurator. - undef: undefined, // A global api shorthand for undefined. - clock: { - offset: function () { - if (clockFetching) return; - - // Skip API calls in OBJKT mode or SPIDER mode - if (getPackMode() || (typeof window !== 'undefined' && window.acSPIDER)) { - clockFetching = false; - return; - } - - clockFetching = true; - const t0 = Date.now(); - - fetch("/api/clock") - .then((response) => { - if (!response.ok) { - return response.text().then((err) => { - clockFetching = false; - throw new Error( - `Failed to fetch offset: ${response.status} ${err}`, - ); - }); - } - - return response.text().then((serverTimeISO) => { - const t1 = Date.now(); - const serverTime = new Date(serverTimeISO).getTime(); - - // Assume serverTime is the midpoint of request - const rtt = t1 - t0; - const approxClientMidpoint = t0 + rtt / 2; - const targetOffset = serverTime - approxClientMidpoint; - - // Blend the clock offset gradually (e.g. 10% of the way each resync) - const blendFactor = 0.25; - clockOffset += (targetOffset - clockOffset) * blendFactor; - - // Recompute base time to keep virtual time in sync - baseTime = Date.now() + clockOffset; - baseReal = Date.now(); - - // console.log('synced') - - lastServerTime = serverTime; - clockFetching = false; - }); - }) - .catch((err) => { - console.error("Clock:", err); - clockFetching = false; - }); - }, - - resync: function () { - $commonApi.clock.offset(); - }, - - time: function () { - return new Date(baseTime + (Date.now() - baseReal)); - }, - }, - - // Enable Pointer Lock - penLock: () => { - send({ type: "pen:lock" }); - }, - // Send a message to BIOS (for effect control, etc.) - send: (msg) => { - send(msg); - }, - chat: chatClient.system, - dark: undefined, // If we are in dark mode. - theme: { - light: { - wipeBG: 150, - wipeNum: 200, - }, - dark: { - wipeBG: 32, - wipeNum: 64, - }, - }, - glaze: function (content) { - if (glazeEnabled === content.on) return; // Prevent glaze from being fired twice... - glazeEnabled = content.on; - // Send glaze message immediately when turning ON (don't wait for paint cycle) - // This ensures glaze activates even when returning from a non-glaze piece - if (content.on) { - send({ type: "glaze", content }); - } else { - glazeAfterReframe = { type: "glaze", content }; - } - }, - - // Toggle HUD visibility (same as Tab key functionality) - // skipSound: true for KidLisp pieces to avoid beep/bop sounds on tap - toggleHUD: function (isDoubleTap = false, skipSound = false) { - toggleHUDVisibility(isDoubleTap, skipSound); - }, - - jump: function jump(to, ahistorical = false, alias = false) { - console.log("🧭 jump() called:", to, "SOLO_MODE:", SOLO_MODE, "leaving:", leaving, "loading:", loading); - - if (SOLO_MODE) { - console.log("🔒 Jump blocked: solo mode active"); - return; - } - - if (leaving) { - console.log("🚪🐴 Jump cancelled, already leaving..."); - return; - } - const jumpOut = - to.startsWith("out:") || (to.startsWith("http") && platform.Aesthetic); - - if (shop.indexOf(to) > -1) to = "/" + to; // Jump out for shop products. - - if ( - ((to.startsWith("http") || to.startsWith("/")) && !to.endsWith(".mjs")) || - jumpOut - ) { - to = to.replace("out:", ""); - try { - // url = new URL(to); - console.log("🐎 Jumping to web URL:", to); - $commonApi.net.web(to, jumpOut); - return; - } catch (e) { - // Could not construct a valid url from the jump, so we will be - // running a local aesthetic.computer piece. - console.log("🐎 URL construction failed, treating as local piece:", e.message); - return; - } - } else { - leaving = true; - graph.unmask(); // Clear any active mask when leaving a piece - } - - // Preserve resolution parameters across jumps (device mode, TV mode, etc.) - // This ensures HUD visibility and other display settings persist when navigating - { - const params = []; - if (DEVICE_MODE) params.push("device=true"); - if (TV_MODE) params.push("tv=true"); - if (SOLO_MODE) params.push("solo=true"); - if (HIGHLIGHT_MODE) { - if (HIGHLIGHT_COLOR && HIGHLIGHT_COLOR !== "64,64,64") { - params.push(`highlight=${encodeURIComponent(HIGHLIGHT_COLOR)}`); - } else { - params.push("highlight=true"); - } - } - if (PERF_MODE) params.push("perf=true"); - if (AUTO_SCALE_MODE) params.push("autoScale=true"); - if (hideLabel) params.push("nolabel=true"); - if (NOGAP_MODE) params.push("nogap=true"); - if (SPOOF_AUDIO_MODE) params.push("spoofaudio=true"); - - if (params.length > 0) { - const separator = to.includes("?") ? "&" : "?"; - to = to + separator + params.join("&"); - console.log("🧭 Preserving resolution params:", to); - } - } - - function loadLine() { - load(parse(to), ahistorical, alias, false, callback); - } - - let callback; - leaveLoad = () => { - // Intercept returns to the prompt when taping from a piece directly. - if ($commonApi.rec.videoOnLeave && to.split("~")[0] === "prompt") { - to = "video"; - $commonApi.rec.videoOnLeave = false; - $commonApi.rec.cut(loadLine); - } else { - if (to.split("~")[0] === "prompt" && - globalKidLispInstance?.clearBakedLayers) { - globalKidLispInstance.clearBakedLayers(); - graph.unmask(); // Clear mask when returning to prompt from KidLisp - } - loadLine(); // Or just load normally. - } - }; - return (cb) => (callback = cb); - }, - - // 🎄 Preload piece modules for merry pipelines (prevents network latency during fast cycling) - preloadPieces: async function preloadPieces(pieceNames) { - if (!pieceNames || pieceNames.length === 0) return; - - const baseUrl = getBuiltInPieceBaseUrl(); - - const fetchPromises = pieceNames.map(async (piece) => { - // Skip if already cached - if (pieceCodeCache.has(piece)) { - console.log(`🎄 Piece already cached: ${piece}`); - return; - } - - try { - // Try .mjs first, then .lua, then .lisp - const mjsUrl = `${baseUrl}/aesthetic.computer/disks/${piece}.mjs?v=${Date.now()}`; - let response = await fetch(mjsUrl, { cache: 'no-store' }); - - if (response.ok) { - const code = await response.text(); - pieceCodeCache.set(piece, { code, type: 'mjs' }); - console.log(`🎄 Preloaded mjs: ${piece}`); - return; - } - - // Try .lua - const luaUrl = `${baseUrl}/aesthetic.computer/disks/${piece}.lua?v=${Date.now()}`; - response = await fetch(luaUrl, { cache: 'no-store' }); - - if (response.ok) { - const code = await response.text(); - pieceCodeCache.set(piece, { code, type: 'lua' }); - console.log(`🎄 Preloaded lua: ${piece}`); - return; - } - - // Try .lisp - const lispUrl = `${baseUrl}/aesthetic.computer/disks/${piece}.lisp?v=${Date.now()}`; - response = await fetch(lispUrl, { cache: 'no-store' }); - - if (response.ok) { - const code = await response.text(); - pieceCodeCache.set(piece, { code, type: 'lisp' }); - console.log(`🎄 Preloaded lisp: ${piece}`); - return; - } - - console.warn(`🎄 Could not preload piece: ${piece}`); - } catch (err) { - console.warn(`🎄 Error preloading ${piece}:`, err); - } - }); - - await Promise.all(fetchPromises); - console.log(`🎄 Preloaded ${pieceCodeCache.size} pieces`); - }, - - // Get cached piece code (returns undefined if not cached) - getCachedPieceCode: function (pieceName) { - return pieceCodeCache.get(pieceName); - }, - - canShare: false, // Whether navigator.share is enabled for mobile devices. - leaving: isLeaving, - handle: () => { - return HANDLE; - }, - notice: (msg, color = ["white", "green"], opts) => { - notice = msg; - noticeColor = color; - noticeOpts = opts; - const sound = {}; - if (color[0] === "yellow" && color[1] === "red") sound.tone = 300; - noticeBell(cachedAPI, sound); - }, - // ⌛ Delay a function by `time` number of sim steps. - delay: (fun, time) => { - hourGlasses.push(new gizmo.Hourglass(time, { completed: () => fun() })); - }, - // Different syntax than `delay` but the same with looped behavior. - blink: (time, fun) => { - hourGlasses.push( - new gizmo.Hourglass(time, { completed: () => fun(), autoFlip: true }), - ); - }, - // 🎟️ Open a ticketed paywall on the page. - ticket: (name) => { - send({ type: "ticket-wall", content: name }); - }, - // 🪙 Mint a url or the `pixels` that get passed into the argument to a - // network of choice. - mint: async (picture, progress, params) => { - console.log("🪙 Minting...", picture); - // Determine if picture is a string or an object. - - // A record will be attached if one exists via the prompt and the user is - // logged in. - let filename; - let zipped; - if (picture.record && HANDLE) { - const record = picture.record; - filename = `painting-${record[record.length - 1].timestamp}.png`; - // Set filename based on record. - - zipped = await $commonApi.zip( - { destination: "upload", painting: { record } }, - (p) => { - console.log("🤐 Zip progress:", p); - progress?.(p); - }, - ); - - console.log("🤐 Zipped:", zipped); - } - - const data = await uploadPainting(picture, progress, HANDLE, filename); - - let description; - - if (picture.width && picture.height) { - description = `A ${picture.width}x${picture.height} pixel painting made on [aesthetic computer](https://aesthetic.computer).`; - } else { - description = `A painting made on [aesthetic computer](https://aesthetic.computer).`; - } - - if (data) { - // Redirect to Zora. - if (HANDLE && zipped) { - description = `[\` ${HANDLE}/${data.slug}\`](https://aesthetic.computer/painting~${HANDLE}/${data.slug})\n\n${description}`; - } - - if (HANDLE) data.slug = `${HANDLE}/painting/${data.slug}`; - - const pixels = `https://aesthetic.computer/api/pixel/2048:contain/${data.slug}.${data.ext}`; - - // TODO: Also add the `zip` here? - // Look into writing custom chunks into PNGS: https://chat.openai.com/c/0ed77e57-c5d2-4827-a733-c024da4bebd7 - - $commonApi.jump( - encodeURI( - `https://zora.co/create/single-edition?image=${pixels}&name=${ - params[0] || "Untitled Painting" - }&symbol=$${data.slug}&description=${description}`, - ), - ); - } - }, - // 🖨️ Print either a url or the `pixels` that get passed into - // the argument, with N quantity. - print: async (picture, quantity = 1, progress) => { - console.log("🖨️ Printing:", picture, "Quantity:", quantity); - const data = await uploadPainting(picture, progress); - let pixels; - if (data && data.code) { - // Use the short code (2-6 chars) which pixel.js can look up in the database - pixels = `${data.code}.${data.ext}`; - } else if (data && data.slug) { - // Fallback to slug if code is not available - pixels = `${data.slug}.${data.ext}`; - } else if (data) { - pixels = data.url; - } else { - $commonApi.notice("UPLOAD ERROR", ["red", "yellow"]); - return; - } - - try { - const headers = { "Content-Type": "application/json" }; - try { - // Include authorization token if logged in. - const token = await $commonApi.authorize(); // Get user token. - if (token) headers.Authorization = `Bearer ${token}`; - } catch (err) {} // Handled up-stream. - - const res = await fetch(`/api/print?new=true&pixels=${pixels}`, { - method: "POST", - headers, - body: JSON.stringify({ quantity, slug: $commonApi.slug }), - // TODO: Add order info here. ^ - }); - - const data = await res.json(); - if (!res.ok) - throw new Error( - `🖨️ Print: HTTP error! Status: ${JSON.stringify(data)}`, - ); - console.log("🖨️ Print order:", data); - $commonApi.jump(data.location); // Redirect to checkout. - } catch (error) { - console.error("🖨️ Print order error:", error); - } - }, - // ☕ Mug - Print a painting on a ceramic mug with optional color. - // `picture` can be a painting object OR a code string like "abc" or "#abc" - mug: async (picture, color = "white", quantity = 1, progress) => { - console.log("☕ Mug:", picture, "Color:", color, "Quantity:", quantity); - let pixels; - - // Check if picture is a code string (e.g., "abc" or "#abc") - if (typeof picture === "string") { - const code = picture.startsWith("#") ? picture.slice(1) : picture; - pixels = `${code}.png`; // Assume PNG for existing paintings - console.log("☕ Using existing painting code:", code); - } else { - // Upload the painting - const data = await uploadPainting(picture, progress); - if (data && data.code) { - pixels = `${data.code}.${data.ext}`; - } else if (data && data.slug) { - pixels = `${data.slug}.${data.ext}`; - } else if (data) { - pixels = data.url; - } else { - $commonApi.notice("UPLOAD ERROR", ["red", "yellow"]); - return; - } - } - - try { - const headers = { "Content-Type": "application/json" }; - try { - const token = await $commonApi.authorize(); - if (token) headers.Authorization = `Bearer ${token}`; - } catch (err) {} - - const res = await fetch(`/api/mug?new=true&pixels=${pixels}&color=${color}`, { - method: "POST", - headers, - body: JSON.stringify({ quantity, slug: $commonApi.slug }), - }); - - const data = await res.json(); - if (!res.ok) - throw new Error( - `☕ Mug: HTTP error! Status: ${JSON.stringify(data)}`, - ); - console.log("☕ Mug order:", data); - $commonApi.jump(data.location); - } catch (error) { - console.error("☕ Mug order error:", error); - } - }, - // Create a zip file of specified content. (Used for storing painting data.) - zip: (content, progress) => { - const prom = new Promise((resolve, reject) => { - zipCreation = { resolve, reject }; - }); - - if (content.destination === "upload") { - serverUploadProgressReporter = progress; - serverUploadProgressReporter?.(0); - } - - send({ type: "zip", content }); - return prom; - }, - // Track device motion. - motion: { - on: false, - start: () => { - send({ type: "motion:start" }); - }, - stop: () => { - send({ type: "motion:stop" }); - // TODO: Automatically stop when changing a disk? - }, - current: {}, // Will get replaced by an update event. - }, - // 🔷 Tezos wallet management (via bios.mjs) - wallet: { - _state: { - connected: false, - address: null, - balance: null, - network: "ghostnet", - domain: null, // .tez domain if resolved - }, - _stateRequested: false, // Track if we've requested state from bios - // Get current wallet state (requests from bios if not yet synced) - get: function () { - // Request fresh state from bios on first call - if (!$commonApi.wallet._stateRequested) { - $commonApi.wallet._stateRequested = true; - send({ type: "wallet:get-state" }); - } - return { ...$commonApi.wallet._state }; - }, - // Connect wallet (with optional address for manual entry) - connect: function (options = {}) { - // Support both connect(network) and connect({ address, network }) - if (typeof options === "string") { - send({ type: "wallet:connect", content: { network: options } }); - } else { - send({ type: "wallet:connect", content: options }); - } - }, - // Disconnect wallet - disconnect: function () { - send({ type: "wallet:disconnect" }); - }, - // Refresh balance from RPC - refreshBalance: function () { - send({ type: "wallet:refresh-balance" }); - }, - // Sign a message with the connected wallet - sign: function (message, callback) { - if (callback) $commonApi.wallet._signCallback = callback; - send({ type: "wallet:sign", content: { message } }); - }, - // Get pairing URI for mobile wallet QR code - getPairingUri: function (network = "mainnet", callback) { - if (callback) $commonApi.wallet._pairingCallback = callback; - send({ type: "wallet:get-pairing-uri", content: { network } }); - }, - // Internal callbacks - _pairingCallback: null, - _signCallback: null, - // Check if connected - isConnected: function () { - return $commonApi.wallet._state.connected; - }, - // Request state sync from bios - sync: function () { - send({ type: "wallet:get-state" }); - }, - }, - // Speak an `utterance` aloud. - speak: function speak(utterance, voice = "female:18", mode = "cloud", opts) { - return send({ type: "speak", content: { utterance, voice, mode, opts } }); - }, - // Broadcast an event through the entire act system. - act: (event, data = {}) => { - // console.log("Acted:", event); Show the synthetic event. - data.is = (e) => e === event; - cachedAPI.event = data; - try { - act(cachedAPI); - } catch (e) { - console.warn("️ ✒ Act failure...", e); - } - }, - // 🚥 `Get` api - // Retrieve media assets from a user account. - get: { - picture: (url) => { - return $commonApi.net.preload( - encodeURI(url), - true, - undefined, - // byOpts, - ); - }, - painting: (code, opts) => { - return { - by: async function (handle = "anon", byOpts) { - const extension = opts?.record ? "zip" : "png"; - - // Use the same origin-aware URL construction logic as module loading - const { protocol, hostname } = getSafeUrlParts(); - - let baseUrl; - // Check if we're in a development environment (localhost with port) - const isDevelopment = hostname === 'localhost' && typeof location !== 'undefined' && location.port; - if (isDevelopment) { - // Use the local development server - baseUrl = `${protocol}//${hostname}:${location.port}`; - } else { - // Use the production server for sandboxed iframes or production - baseUrl = `https://aesthetic.computer`; - } - - // Detect timestamp format (YYYY.M.D.H.M.S.mmm or YYYY.MM.DD.HH.MM.SS.mmm) - const isTimestamp = code.match(/^\d{4}\.\d{1,2}\.\d{1,2}\.\d{1,2}\.\d{1,2}\.\d{1,2}\.\d{1,3}$/); - - let mediaUrl; - if (isTimestamp && handle && handle !== "anon") { - // Legacy user painting with timestamp - use old format - const handleWithAt = handle.startsWith('@') ? handle : `@${handle}`; - mediaUrl = `${baseUrl}/media/${handleWithAt}/painting/${code}.${extension}`; - } else { - // 3-letter code or anonymous painting - use new endpoint - mediaUrl = `${baseUrl}/media/paintings/${code}.${extension}`; - } - - return $commonApi.net.preload( - mediaUrl, - true, - undefined, - byOpts, - ); - }, - }; - }, - }, - // ***Actually*** upload a file to the server. - // 📓 The file name can have `media-` which will sort it on the server into - // a directory via `presigned-url.js`. - upload: async (filename, data, progress, bucket, recordingSlug) => { - const prom = new Promise((resolve, reject) => { - serverUpload = { resolve, reject }; - }); - serverUploadProgressReporter = progress; - serverUploadProgressReporter?.(0); - - console.log("Painting data:", data); - - send({ type: "upload", content: { filename, data, bucket, recordingSlug } }); - return prom; - }, - code: { - channel: (chan) => { - codeChannel = chan; // Set the current `codeChannel`. - store["code-channel"] = codeChannel; // Store and keep it in the browser. - store.persist("code-channel"); - if (!codeChannel || codeChannel?.length === 0) { - console.log("📭 Code channel cleared!"); - } else { - console.log("📬 Code channel set to:", codeChannel); - } - socket.send("code-channel:sub", codeChannel); - // Tell any parent iframes that the channel has been updated. - send({ - type: "post-to-parent", - content: { type: "setCode", value: codeChannel }, - }); - // ❤️‍🔥 - // TODO: Should return a promise here, and wait for a `code-channel:subbed` - // event, that way users get better confirmation if the socket - // doesn't go through / there is a server issue. 23.07.04.18.01 - }, - // Store a painting code mapping (timestamp/slug -> 3-letter code) - store: (slug, handle, code) => { - codeCache.storeCode(slug, handle, code); - }, - // Get a painting code from timestamp/slug - get: (slug, handle) => { - return codeCache.getCode(slug, handle); - }, - }, - encode: async (file) => { - // File should be { type, data } where type is "png", "webp", "jef", etc. - const prom = new Promise((resolve, reject) => { - fileEncodeRequest = { resolve, reject }; - }); - send({ type: "file-encode:request", content: file }); - return prom; - }, - file: async () => { - const prom = new Promise((resolve, reject) => { - fileOpenRequest = { resolve, reject }; - }); - send({ type: "file-open:request" }); - return prom; - }, - // Authorize a user. - authorize: async () => { - // TODO: This should always fail while running user code. - // console.log("Sending auth request..."); - const prom = new Promise((resolve, reject) => { - authorizationRequest = { resolve, reject }; - }); - send({ type: "authorization:request" }); - return prom; - }, // Get a token for a logged in user. - // Hand-tracking. 23.04.27.10.19 TODO: Move eventually. - hand: { mediapipe: { screen: [], world: [], hand: "None" } }, - hud: { - label: (text, color, offset, plainTextOverride) => { - currentHUDTxt = text; - // Use plainTextOverride if provided, otherwise strip color codes from text - currentHUDPlainTxt = plainTextOverride || stripColorCodes(text); // Store plain text version - - if (!color) { - currentHUDTextColor = currentHUDTextColor || graph.findColor(color); - } else { - currentHUDTextColor = graph.findColor(color); - } - currentHUDOffset = offset; - - // Calculate and store dimensions for animation when directly called - // (This ensures kidlisp pieces have proper animation geometry) - if (currentHUDTxt && currentHUDTxt.length > 0) { - // Use plain text (without color codes) for dimension calculations - const textForMeasurement = currentHUDPlainTxt || currentHUDTxt; - - // Detect if this is a KidLisp piece for more generous width allowance - const sourceCode = currentText || currentHUDTxt; - const isKidlispPiece = (currentPath && lisp?.isKidlispSource && lisp.isKidlispSource(currentPath) && !currentPath.endsWith('.lisp')) || - currentPath === "(...)" || - (sourceCode && sourceCode.startsWith("$")) || - (currentPath && currentPath.includes("/disks/$")) || - (sourceCode && lisp?.isKidlispSource && lisp.isKidlispSource(sourceCode)); - - // Use full screen width for text wrapping - const maxWidth = cachedAPI.screen.width; - - const labelBounds = cachedAPI.text.box( - textForMeasurement, - undefined, - maxWidth, - 1, // scale - true, // wordWrap - // TODO: This should check useTinyHudLabel like the main corner label, but that's not accessible here - // For now, use default font to maintain consistency with current behavior - undefined // Use default font - will need to be updated when useTinyHudLabel is globally accessible - ); - - // Use the actual computed width from text.box instead of character count - const measuredWidth = labelBounds.box.width; - const fallbackShareWidth = (currentHUDLabelBlockWidth || DEFAULT_TYPEFACE_BLOCK_WIDTH) * "share ".length; - const shareWidth = Math.max(currentHUDShareWidth || 0, fallbackShareWidth); - currentHUDLeftPad = shareWidth; - const baseLabelWidth = measuredWidth + shareWidth; - const scrubExtension = Math.max(0, currentHUDScrub); - const h = labelBounds.box.height + cachedAPI.typeface.blockHeight; - - currentHUDLabelMeasuredWidth = baseLabelWidth; - hudAnimationState.labelWidth = baseLabelWidth + scrubExtension; - hudAnimationState.labelHeight = h; - } - }, - // 📱 Set a QR code to display to the LEFT of the HUD label - qr: (url) => { - console.log("📱 HUD QR called with:", JSON.stringify(url), "current:", JSON.stringify(currentHUDQR)); - if (url !== currentHUDQR) { - currentHUDQR = url; - currentHUDQRCells = null; // Clear cache to regenerate - console.log("📱 HUD QR set to:", url); - } - }, - // 🔤 Force MatrixChunky8 (tiny) font for HUD label - tinyLabel: (enabled = true) => { - forceTinyHudLabel = enabled; - }, - // ✨ Set a custom superscript (e.g. ".com") to display after the label in cyan - superscript: (text) => { - currentHUDSuperscript = text || null; - }, - currentStatusColor: () => currentHUDStatusColor, - currentLabel: () => ({ - text: currentHUDTxt, - plainText: currentHUDPlainTxt, // Include plain text version - btn: currentHUDButton, - qrSize: currentHUDQRCells?.length || hudAnimationState?.qrSize || 0, - leftPad: currentHUDLeftPad || 0, - }), - labelBack: () => { - labelBack = true; - // Store the source piece for special handling (e.g., chat returning to prompt) - send({ type: "labelBack:source", content: { sourcePiece: $commonApi.piece } }); - }, - }, - // 📱 LAN Dev mode identity (from session server) - get devIdentity() { return devIdentity; }, - send, - platform, - // USB flash support (Electron only) - usb: { - listDevices: () => new Promise((resolve) => { - usbDeviceListResolution = resolve; - send({ type: "usb:list-devices" }); - }), - flashImage: (opts) => new Promise((resolve) => { - usbFlashResolution = resolve; - send({ type: "usb:flash-image", content: opts }); - }), - }, - history: [], // Populated when a disk loads and sets the former piece. - // Trigger background music. - // Eventually add an "@" style parameter similar to what a stamp system would have. - bgm: { - set: function (trackNumber, volume) { - send({ type: "bgm-change", content: { trackNumber, volume } }); - }, - stop: () => send({ type: "bgm-stop" }), - data: {}, - }, - system: { - // prompt: { input: undefined }, Gets set in `prompt_boot`. - world: { - // Populated in `world_boot` of `world.mjs`. - teleported: false, - telepos: undefined, - teleport: (to, telepos) => { - $commonApi.system.world.teleported = true; - $commonApi.system.world.telepos = telepos; - $commonApi.jump(to); - }, - }, - nopaint: { - //boot: nopaint_boot, // TODO: Why are these in the commonApi? 23.02.12.14.26 - // act: nopaint_act, - buffer: null, // An overlapping brush buffer that gets drawn on top of the - // painting. - recording: false, - record: [], // Store a recording here. - gestureRecord: [], // Store the active gesture. - startRecord: function (fullText) { - const sys = $commonApi.system; - sys.nopaint.record = []; // Clear any existing recording. - sys.nopaint.recording = true; - sys.nopaint.addToRecord({ - label: fullText || "start", - painting: { - pixels: new Uint8ClampedArray(sys.painting.pixels), - width: sys.painting.width, - height: sys.painting.height, - }, - }); - // console.log("🖌️🔴 Now recording:", sys.nopaint.record); - }, - addToRecord: function (record) { - record.timestamp = num.timestamp(); // Insert the timestamp data. - record.gesture = $commonApi.system.nopaint.gestureRecord.slice(); - if (record.gesture.length === 0) delete record.gesture; - $commonApi.system.nopaint.gestureRecord = []; - $commonApi.system.nopaint.record.push(record); - store["painting:record"] = $commonApi.system.nopaint.record; - store.persist("painting:record", "local:db"); - // console.log("🖌️🟠 Recorded a step:", record.label); - }, - is: nopaint_is, - cancelStroke: nopaint_cancelStroke, - undo: { paintings: undoPaintings }, - needsBake: false, - needsPresent: false, - bakeOnLeave: false, - addUndoPainting, - // Regresses the system painting to a previous state. - // Or the reverse... ("yes") - no: ({ system, store, needsPaint }, yes = false) => { - const paintings = system.nopaint.undo.paintings; - - let dontRecord = false; - - if (yes) { - // ⏩ Fast-forward mode. - undoPosition += 1; - if (undoPosition > paintings.length - 1) { - undoPosition = paintings.length - 1; - dontRecord = true; - } - } else { - // ⏪ Rewind mode. - undoPosition -= 1; - if (undoPosition < 0) { - undoPosition = 0; - dontRecord = true; - } - } - - if (paintings.length > 1) { - // Copy over the old picture here... - const p = paintings[undoPosition]; - - const op = p.pixels; - const pixels = new Uint8ClampedArray(op.length); - pixels.set(op); - - store["painting"] = { - width: p.width, - height: p.height, - pixels, - }; - - const resolutionChange = - paintings[0].width !== paintings[1].width || - paintings[0].height !== paintings[1].height; - - // 🦢 Swap mode. - // 'no' should swap... - // const temp = paintings[0]; - // paintings[0] = paintings[1]; - // paintings[1] = temp; - - store.persist("painting", "local:db"); - - // 🎨 Broadcast painting update to other tabs - $commonApi.broadcastPaintingUpdate("updated", { - source: "yes-no-decision" - }); - - system.painting = store["painting"]; - - if (system.nopaint.recording && dontRecord === false) { - const label = yes ? "yes" : "no"; - system.nopaint.addToRecord({ - label, //, - // painting: { - // width: system.painting.width, - // height: system.painting.height, - // pixels: new Uint8Array(system.painting.pixels), - // }, - }); - } - - if (resolutionChange) { - system.nopaint.resetTransform({ system }); - system.nopaint.storeTransform(store, system); - } - - needsPaint(); - } - }, - // Center the picture within the screen / default translation. - resetTransform: ({ system: sys }) => { - sys.nopaint.zoomLevel = 1; - - if (!sys.painting) { - sys.nopaint.translation = { x: 0, y: 0 }; - return; - } - - sys.nopaint.translation.x = floor( - screen.width / 2 - sys.painting.width / 2, - ); - sys.nopaint.translation.y = floor( - screen.height / 2 - sys.painting.height / 2, - ); - }, - storeTransform: (store, sys) => { - store["painting:transform"] = { - translation: sys.nopaint.translation, - zoom: sys.nopaint.zoomLevel, - }; - store.persist("painting:transform", "local:db"); - }, - translation: { x: 0, y: 0 }, - zoomLevel: 1, - translate: ({ system }, x, y) => { - system.nopaint.translation.x += x; - system.nopaint.translation.y += y; - }, - // zoom: ({ system }, dir) => { - // system.nopaint.zoomLevel += dir === "in" ? 1 : -1; - // console.log("🔭 Zoom level:", system.nopaint.zoomLevel); - // if (system.nopaint.zoomLevel <= 0) system.nopaint.zoomLevel = 1; - // // TODO: Adjust the translation based on system.nopaint.brush.x and y - // // Which would serve as the zoom origin point. - // }, - zoom: ({ system }, dir, cursor) => { - // Store old zoom level - const oldZoomLevel = system.nopaint.zoomLevel; - - // Adjust the zoom level - system.nopaint.zoomLevel += dir === "in" ? 1 : -1; - - // Ensure zoom level is at least 1 - if (system.nopaint.zoomLevel <= 0) system.nopaint.zoomLevel = 1; - - // Calculate the scaling factor based on the change in zoom levels - const scale = system.nopaint.zoomLevel / oldZoomLevel; - - // Adjust the translation based on the scaling factor and the cursor's position - system.nopaint.translation.x = floor( - cursor.x + (system.nopaint.translation.x - cursor.x) * scale, - ); - system.nopaint.translation.y = floor( - cursor.y + (system.nopaint.translation.y - cursor.y) * scale, - ); - }, - brush: { x: 0, y: 0, dragBox: undefined }, - transform: (p) => { - return { - x: (p.x - nopaintAPI.translation.x) / nopaintAPI.zoomLevel, - y: (p.y - nopaintAPI.translation.y) / nopaintAPI.zoomLevel, - }; - }, - // Similar to `updateBrush` but for arbitrary points, - // with no change to `system.nopaint.brush`. - pointToPainting: ({ system, pen }) => { - const zoom = system.nopaint.zoomLevel; - const x = floor(((pen?.x || 0) - system.nopaint.translation.x) / zoom); - const y = floor(((pen?.y || 0) - system.nopaint.translation.y) / zoom); - - return { x, y }; - }, - updateBrush: ({ pen, system }, act) => { - // TODO: Use `pointToPainting` above. 23.10.11.08.49 - // let { x, y } = system.nopaint.pointToPainting({ system }); - const zoom = system.nopaint.zoomLevel; - const x = floor(((pen?.x || 0) - system.nopaint.translation.x) / zoom); - const y = floor(((pen?.y || 0) - system.nopaint.translation.y) / zoom); - - if (act === "touch") { - system.nopaint.startDrag = { x, y }; - } - - // Ensure startDrag exists before creating dragBox - if (!system.nopaint.startDrag) { - system.nopaint.startDrag = { x, y }; - } - - const dragBox = new geo.Box( - system.nopaint.startDrag.x, - system.nopaint.startDrag.y, - x - system.nopaint.startDrag.x, - y - system.nopaint.startDrag.y, - ); - - system.nopaint.brush = { x, y, dragBox, pressure: pen?.pressure || 0.5 }; - - // Call needsPaint during any pen movement to ensure continuous painting and HUD updates - $commonApi.needsPaint(); - }, - - // Helper to display the existing painting on the screen, with an - // optional pan amount, that returns an adjusted pen pointer as `brush`. - - // TODO: - [] Add Zoom - // - [] And Rotation! - - present: ( - { system, screen, wipe, paste, ink, slug, dark, theme, blend }, - tx, - ty, - ) => { - system.nopaint.needsPresent = false; - - // Guard: system.painting must exist for present to work - if (!system.painting) { - console.warn("🖼️ present: system.painting is undefined, skipping"); - return; - } - - const x = tx || system.nopaint.translation.x; - const y = ty || system.nopaint.translation.y; - - system.nopaint.translation = { x, y }; - - const fullbleed = - x === 0 && - y === 0 && - screen.width <= system.painting.width && - screen.height <= system.painting.height; - - if (fullbleed) { - // If we are not panned and the painting fills the screen. - paste(system.painting); - paste(system.nopaint.buffer); - } else { - // If we are panned or the painting is a custom resolution. - - wipe(theme[dark ? "dark" : "light"].wipeBG) - .paste(system.painting, x, y, system.nopaint.zoomLevel); - - // Normal alpha blending for overlay buffer - paste(system.nopaint.buffer, x, y, system.nopaint.zoomLevel); - - ink(128) - .box( - x, - y, - system.painting.width * system.nopaint.zoomLevel, - system.painting.height * system.nopaint.zoomLevel, - "outline", - ); - } - - // 🎯 DEBUG: Draw dragBox overlay if one exists (COMMENTED OUT) - /* - if (system.nopaint.brush?.dragBox) { - const dragBox = system.nopaint.brush.dragBox; - - // Apply zoom and translation to the dragBox coordinates - const overlayX = (dragBox.x * system.nopaint.zoomLevel) + x; - const overlayY = (dragBox.y * system.nopaint.zoomLevel) + y; - const overlayW = dragBox.w * system.nopaint.zoomLevel; - const overlayH = dragBox.h * system.nopaint.zoomLevel; - - // Draw the dragBox outline in bright green with some transparency - ink(0, 255, 0, 180).box(overlayX, overlayY, overlayW, overlayH, "outline"); - - // Draw corner indicators - const cornerSize = 4; - ink(0, 255, 0, 220); - // Top-left corner - ink().box(overlayX - cornerSize/2, overlayY - cornerSize/2, cornerSize, cornerSize); - // Top-right corner - ink().box(overlayX + overlayW - cornerSize/2, overlayY - cornerSize/2, cornerSize, cornerSize); - // Bottom-left corner - ink().box(overlayX - cornerSize/2, overlayY + overlayH - cornerSize/2, cornerSize, cornerSize); - // Bottom-right corner - ink().box(overlayX + overlayW - cornerSize/2, overlayY + overlayH - cornerSize/2, cornerSize, cornerSize); - - // Display coordinate info - const coordText = `${dragBox.x},${dragBox.y} ${dragBox.w}x${dragBox.h}`; - ink(0, 255, 0, 200).write(coordText, { - x: overlayX, - y: Math.max(overlayY - 15, 5) // Position above the box, but not off screen - }, undefined, undefined, false, "MatrixChunky8"); - } - */ - - // Graph `zoomLevel` - if (system.nopaint.zoomLevel !== 1 && slug !== "prompt") - ink(255, 127).write(`${system.nopaint.zoomLevel}x`, { x: 6, y: 18 }); - - return { - x, - y, //, - //brush: { x: (pen?.x || 0) - x, y: (pen?.y || 0) - y }, - }; - }, - // Kill an existing painting. - noBang: async ( - { system, store, needsPaint, painting, theme, dark }, - res = { w: screen.width, h: screen.height }, - ) => { - // console.log("deleting..."); - const deleted = await store.delete("painting", "local:db"); - await store.delete("painting:resolution-lock", "local:db"); - await store.delete("painting:transform", "local:db"); - system.nopaint.undo.paintings.length = 0; // Reset undo stack. - system.painting = null; - system.nopaint.resetTransform({ system, screen }); // Reset transform. - needsPaint(); - - // Make a blank painting. - // I don't like that these getters will not re-associate. - system.painting = painting(res.w, res.h, ($) => { - $.wipe(theme[dark ? "dark" : "light"].wipeNum); - }); - - // Store only the pixel data, not the full painting object with functions - store["painting"] = { - width: system.painting.width, - height: system.painting.height, - pixels: system.painting.pixels, - }; - - // Store the new painting BEFORE broadcasting - store.persist("painting", "local:db"); - - // 🎨 Broadcast painting cleared to other tabs - $commonApi.broadcastPaintingUpdate("cleared", { - source: "clear", - width: res.w, - height: res.h, - resetTransform: true // Flag that receiving tabs should reset their transform - }); - - // Clear any existing painting recording in RAM and - // storage. - await store.delete("painting:record", "local:db"); - if (system.nopaint.recording) { - system.nopaint.recording = false; - system.nopaint.record.length = 0; - } - - return deleted; - }, - // Replace a painting entirely, remembering the last one. - // (This will always enable fixed resolution mode.) - replace: ( - { system, screen, store, needsPaint }, - painting, - message = "(replace)", - ) => { - system.painting = painting; // Update references. - - // Store only the pixel data, not the full painting object - store["painting"] = { - width: system.painting.width, - height: system.painting.height, - pixels: system.painting.pixels, - }; - - store.persist("painting", "local:db"); // Persist to storage. - - // 🎨 Broadcast painting replacement to other tabs - $commonApi.broadcastPaintingUpdate("replaced", { - source: "replace", - message: message - }); - - store["painting:resolution-lock"] = true; - store.persist("painting:resolution-lock", "local:db"); - system.nopaint.resetTransform({ system, screen }); // Reset transform. - system.nopaint.storeTransform(store, system); - system.nopaint.addUndoPainting(system.painting, message); - system.nopaint.needsPresent = true; - needsPaint(); - }, - abort: () => (NPnoOnLeave = true), - }, - }, - // Paint all queued rendering commands immediately. - flatten: () => { - return painting.paint(true); - }, - connect: () => { - const p = new Promise((resolve, reject) => { - web3Response = { resolve, reject }; - }); - send({ type: "web3-connect" }); - return p; - }, - // Tezos Wallet API - tezos: { - // Connect to a Tezos wallet (opens Beacon popup) - connect: (network = "mainnet") => { - const p = new Promise((resolve, reject) => { - tezosConnectResponse = { resolve, reject }; - }); - send({ type: "tezos-connect", content: { network } }); - return p; - }, - // Disconnect from Tezos wallet - disconnect: () => { - const p = new Promise((resolve, reject) => { - tezosDisconnectResponse = { resolve, reject }; - }); - send({ type: "tezos-disconnect" }); - return p; - }, - // Get current wallet address (null if not connected) - address: () => { - const p = new Promise((resolve, reject) => { - tezosAddressResponse = { resolve, reject }; - }); - send({ type: "tezos-address" }); - return p; - }, - // Sign a message with the connected wallet - sign: (message) => { - const p = new Promise((resolve, reject) => { - tezosSignResponse = { resolve, reject }; - }); - send({ type: "tezos-sign", content: { message } }); - return p; - }, - // Call a contract method - call: (contractAddress, method, args, amount = 0) => { - const p = new Promise((resolve, reject) => { - tezosCallResponse = { resolve, reject }; - }); - send({ type: "tezos-call", content: { contractAddress, method, args, amount } }); - return p; - }, - }, - wiggle: function (n, level = 0.2, speed = 6) { - wiggleAngle = (wiggleAngle + 1 * speed) % 360; - const osc = sin(num.radians(wiggleAngle)); - return n + n * level * osc; - }, - dark: true, // Dark mode. (Gets set on startup and on any change.) - darkMode, // Toggle dark mode or set to `true` or `false`. - // content: added programmatically: see Content class - gpuReady: false, - gpu: { - message: (content) => { - const p = new Promise((resolve, reject) => { - gpuResponse = { resolve, reject }; - }); - send({ type: "gpu-event", content }); - return p; - }, - }, - // WebGPU 2D Renderer API - webgpu: { - enabled: false, // Flag to disable CPU renderer when true - backend: null, // Preferred backend: "webgpu", "webgl2", "canvas2d", "vello", etc. - // 🛑 Disable WebGPU and restore normal CPU rendering - disable: () => { - $commonApi.webgpu.enabled = false; - $commonApi.webgpu.backend = null; - send({ type: "webgpu-command", content: { type: "disable" } }); - }, - clear: (r = 0, g = 0, b = 0, a = 255) => { - send({ - type: "webgpu-command", - content: { - type: "clear", - color: [r, g, b, a] - } - }); - }, - line: (x1, y1, x2, y2, r = 255, g = 255, b = 255, a = 255) => { - send({ - type: "webgpu-command", - content: { - type: "line", - x1, y1, x2, y2, - color: [r, g, b, a] - } - }); - }, - render: () => { - send({ - type: "webgpu-command", - content: { type: "render" } - }); - }, - // 📊 Toggle performance overlay - perf: (enabled) => { - send({ - type: "webgpu-command", - content: { type: "perf-overlay", enabled } - }); - }, - }, - // 📊 DOM-based stats overlay (always on top of everything, even GPU canvases) - stats: { - show: (data = {}) => { - send({ type: "stats-overlay", content: { enabled: true, data } }); - }, - hide: () => { - send({ type: "stats-overlay", content: { enabled: false } }); - }, - update: (data) => { - send({ type: "stats-overlay", content: { data } }); - }, - }, - // Deprecated in favor of `bios` -> `hitboxes`. (To support iOS) - // clipboard: { - // copy: (text) => { - // send({ type: "copy", content: text }); - // }, - // }, - text: { - capitalize: text.capitalize, - reverse: text.reverse, - // Get the pixel width of a string of characters. - width: (text, fontName) => { - if (Array.isArray(text)) text = text.join(" "); - - // Use specified font or fall back to current typeface - const useTypeface = getTypefaceForMeasurement(fontName) || tf; - if (!useTypeface) { - return text.length * DEFAULT_TYPEFACE_BLOCK_WIDTH; - } - - const isProportional = - useTypeface?.data?.proportional === true || - !!useTypeface?.data?.advances || - !!useTypeface?.data?.bdfFont; - - if (isProportional && typeof useTypeface.getAdvance === "function") { - let totalWidth = 0; - for (const char of text) { - if (char === "\n" || char === "\r") continue; - const advance = useTypeface.getAdvance(char); - totalWidth += typeof advance === "number" ? advance : useTypeface.blockWidth; - } - return totalWidth; - } else { - // For monospace fonts, use the typeface's block width - const blockWidth = useTypeface.blockWidth || DEFAULT_TYPEFACE_BLOCK_WIDTH; - return text.length * blockWidth; - } - }, - height: (text) => { - // Get the pixel height of a string of characters. - return 10; - }, - // Return a text's bounding box. - box: (text, pos = { x: 0, y: 0 }, bounds, scale = 1, wordWrap = true, fontName) => { - - if (!text) { - console.warn("⚠️ No text for `box`."); - return; - } - - pos = { ...pos }; - const absScale = abs(scale ?? 1); - - let useTypeface = getTypefaceForMeasurement(fontName) || tf; - if (!useTypeface) { - useTypeface = tf; - } - - const baseBlockWidth = - useTypeface?.blockWidth ?? tf?.blockWidth ?? DEFAULT_TYPEFACE_BLOCK_WIDTH; - const baseBlockHeight = - useTypeface?.blockHeight ?? tf?.blockHeight ?? DEFAULT_TYPEFACE_BLOCK_HEIGHT; - - const blockHeight = baseBlockHeight * absScale; - - const isProportional = - useTypeface?.data?.proportional === true || - !!useTypeface?.data?.advances || - !!useTypeface?.data?.bdfFont; - - const getAdvanceWidth = (char) => { - if (!char) return baseBlockWidth * absScale; - if (isProportional && typeof useTypeface?.getAdvance === "function") { - const raw = useTypeface.getAdvance(char); - if (typeof raw === "number") { - return raw * absScale; - } - } - return baseBlockWidth * absScale; - }; - - const getCharWidth = (char) => { - if (!char || char === "\n" || char === "\r") return 0; - if (char === "\t") { - return getAdvanceWidth(" ") * 4; - } - return getAdvanceWidth(char); - }; - - if (bounds === undefined) { - const sampleWidth = getAdvanceWidth("0"); - bounds = (text.length + 2) * sampleWidth; - } - if (!(bounds > 0)) { - bounds = Number.POSITIVE_INFINITY; - } - - const lines = [""]; - const charMap = [[]]; - let line = 0; - let run = 0; - let maxWidth = 0; - - const commitLineWidth = () => { - if (run > maxWidth) { - maxWidth = run; - } - }; - - const newLine = () => { - commitLineWidth(); - line += 1; - lines[line] = ""; - charMap[line] = []; - run = 0; - }; - - const appendChar = (char, sourceIndex) => { - const width = getCharWidth(char); - const renderedChar = char === "\t" ? " " : char; - lines[line] += renderedChar; - charMap[line].push(sourceIndex); - run += width; - if (run > maxWidth) { - maxWidth = run; - } - }; - - if (!wordWrap) { - for (let idx = 0; idx < text.length; idx += 1) { - const char = text[idx]; - if (char === "\r") continue; - if (char === "\n") { - newLine(); - continue; - } - appendChar(char, idx); - } - } else { - const tokens = []; - let idx = 0; - while (idx < text.length) { - const char = text[idx]; - if (char === "\r") { - idx += 1; - continue; - } - if (char === "\n") { - tokens.push({ type: "newline", indices: [idx] }); - idx += 1; - continue; - } - if (char === " " || char === "\t") { - let textBuffer = ""; - const indices = []; - while (idx < text.length) { - const c = text[idx]; - if (c !== " " && c !== "\t") break; - textBuffer += c; - indices.push(idx); - idx += 1; - } - tokens.push({ type: "whitespace", text: textBuffer, indices }); - continue; - } - let textBuffer = ""; - const indices = []; - while (idx < text.length) { - const c = text[idx]; - if (c === "\n" || c === " " || c === "\t" || c === "\r") break; - textBuffer += c; - indices.push(idx); - idx += 1; - } - if (textBuffer.length > 0) { - tokens.push({ type: "word", text: textBuffer, indices }); - } - } - - tokens.forEach((token) => { - if (token.type === "newline") { - newLine(); - return; - } - - if (token.type === "whitespace") { - for (let i = 0; i < token.text.length; i += 1) { - const char = token.text[i]; - const sourceIndex = token.indices[i]; - const width = getCharWidth(char); - if (run > 0 && run + width > bounds) { - newLine(); - } - appendChar(char, sourceIndex); - } - return; - } - - if (token.type === "word") { - const chars = token.text.split(""); - const widths = chars.map((char) => getCharWidth(char)); - const wordWidth = widths.reduce((acc, value) => acc + value, 0); - - if (bounds > 0 && wordWidth > bounds) { - chars.forEach((char, charIdx) => { - const width = widths[charIdx]; - if (run > 0 && run + width > bounds) { - newLine(); - } - appendChar(char, token.indices[charIdx]); - }); - return; - } - - if (run > 0 && run + wordWidth > bounds) { - newLine(); - } - - chars.forEach((char, charIdx) => { - appendChar(char, token.indices[charIdx]); - }); - } - }); - } - - commitLineWidth(); - - const lineHeightGap = 1 * absScale; - const finalBlockHeight = blockHeight + lineHeightGap; - - if (lines.length >= 1 && pos.center && pos.center.indexOf("y") !== -1) { - pos.y = - $activePaintApi.screen.height / 2 - - (lines.length * finalBlockHeight) / 2 + - finalBlockHeight / 2 + - (pos.y || 0); - } - - const height = lines.length * finalBlockHeight; - - const lineWidths = lines.map((lineText, index) => { - if (!lineText) return 0; - const indices = charMap[index] || []; - let width = 0; - for (let i = 0; i < lineText.length; i += 1) { - const sourceIndex = indices[i]; - if (typeof sourceIndex === "number" && sourceIndex >= 0 && sourceIndex < text.length) { - width += getCharWidth(text[sourceIndex]); - } else { - width += getCharWidth(lineText[i]); - } - } - return width; - }); - - const maxLineWidth = lineWidths.reduce((acc, value) => Math.max(acc, value), 0); - - const box = { x: pos.x, y: pos.y, width: maxLineWidth, height }; - - return { pos, box, lines, lineWidths, lineHeight: finalBlockHeight, charMap }; - }, - }, - num: { - add: num.add, - wrap: num.wrap, - even: num.even, - odd: num.odd, - clamp: num.clamp, - wave: num.wave, - rand: num.rand, - randInt: num.randInt, - randInd: num.randInd, - randIntArr: num.randIntArr, - randIntRange: num.randIntRange, - rangedInts: num.rangedInts, - multiply: num.multiply, - perlin: num.perlin, - dist: num.dist, - dist3d: num.dist3d, - radians: num.radians, - degrees: num.degrees, - lerp: num.lerp, - map: num.map, - arrMax: num.arrMax, - arrCompress: num.arrCompress, - Track: num.Track, - timestamp: num.timestamp, - p2: num.p2, - midp: num.midp, - number: num.number, - intersects: num.intersects, - signedCeil: num.signedCeil, - signedFloor: num.signedFloor, - vec2: vec2, - vec3: vec3, - vec4: vec4, - mat3: mat3, - mat4: mat4, - quat: quat, - parseColor: num.parseColor, - findColor: num.findColor, - saturate: num.saturate, - desaturate: num.desaturate, - shiftRGB: num.shiftRGB, - rgbToHexStr: num.rgbToHexStr, - hexToRgb: num.hexToRgb, - blend: num.blend, - rgbToHsl: num.rgbToHsl, - hslToRgb: num.hslToRgb, - rainbow: num.rainbow, - zebra: num.zebra, - resetZebraCache: num.resetZebraCache, - }, - geo: { - Box: geo.Box, - DirtyBox: geo.DirtyBox, - Grid: geo.Grid, - Circle: geo.Circle, - linePointsFromAngle: geo.linePointsFromAngle, - pointFrom: geo.pointFrom, - Race: geo.Race, - Quantizer: geo.Quantizer, - }, - ui: { - Button: ui.Button, - Slider: ui.Slider, - TextButton: ui.TextButton, - TextButtonSmall: ui.TextButtonSmall, - TextInput: TextInput, - }, - help: { - choose: help.choose, - flip: help.flip, - repeat: help.repeat, - every: help.every, - any: help.any, - anyIndex: help.anyIndex, - anyKey: help.anyKey, - resampleArray: help.resampleArray, - each: help.each, - shuffleInPlace: help.shuffleInPlace, - serializePainting: (painting) => { - if (!painting) return; - const pixels = uint8ArrayToBase64(painting.pixels); - return { width: painting.width, height: painting.height, pixels }; - }, - deserializePainting: (painting) => { - if (!painting) return; - const pixels = base64ToUint8Array(painting.pixels); - return { width: painting.width, height: painting.height, pixels }; - }, - }, - gizmo: { Hourglass: gizmo.Hourglass, EllipsisTicker: gizmo.EllipsisTicker, Ticker: gizmo.Ticker }, - rec: new Recorder(), - robo: new Robo(), - net: { - signup: () => { - send({ type: "signup" }); - }, - login: () => { - store.delete("handle"); - send({ type: "login" }); - }, // { email } - logout: () => { - store.delete("handle"); - send({ type: "logout" }); - $commonApi.broadcast("logout:success"); - chatClient.system?.server?.send("logout"); - // Send a "logout" message here to the chat server. - - // TODO: And probably the session server as well in - // the future. 24.05.23.21.27 - }, - pieces: `${(() => { - // In spider mode, always use main aesthetic.computer domain - if (typeof window !== 'undefined' && window.acSPIDER) { - return 'https://aesthetic.computer'; - } - const { protocol, hostname } = getSafeUrlParts(); - return `${protocol}//${hostname}`; - })()}/aesthetic.computer/disks`, - parse, // Parse a piece slug. - // lan: // Set dynamically. - // host: // Set dynamically. - // loadFailureText: // Set dynamically. - // Make a user authorized / signed request to the api. - // Used both in `motd` and `handle`. - requestDocs: async () => { - if (typeof docs === "object") return Promise.resolve(docs); - return fetch("/docs.json") - .then((response) => { - if (response.status !== 200) { - throw new Error("Network failure: " + response.status); - } - return response.json(); - }) - .then((d) => { - docs = d; - return docs; - }) - .catch((err) => console.error("🔴 📚 Couldn't get docs:", err)); - }, - // Get the authorization token for the current user (for streaming/custom requests) - getToken: async () => { - try { - return await $commonApi.authorize(); - } catch (error) { - // Only log actual errors, not null (which just means not logged in) - if (error) console.warn("🔑 getToken:", error); - return null; - } - }, - userRequest: async (method, endpoint, body) => { - try { - const token = await $commonApi.authorize(); // Get user token. - if (!token) throw new Error("🧖 Not logged in."); - - const headers = { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }; - - const options = { method, headers }; - if (body) options.body = JSON.stringify(body); - const response = await fetch(endpoint, options); - - if (response.status === 500) { - try { - const json = await response.json(); - return { status: response.status, ...json }; - } catch (e) { - return { status: response.status, message: response.statusText }; - } - } else { - const clonedResponse = response.clone(); - try { - return { - ...(await clonedResponse.json()), - status: response.status, - }; - } catch { - return { status: response.status, body: await response.text() }; - } - } - } catch (error) { - console.error("🚫 Error:", error); - return { message: "unauthorized" }; - } - }, - // Loosely connect the UDP receiver. - udp: (receive) => { - udpReceive = receive; - return udp; - }, - hiccup: (hiccupIn = 5, outageSeconds = 5) => { - console.log("😵 Hiccuping in:", hiccupIn, "seconds."); - clearTimeout(hiccupTimeout); - hiccupTimeout = setTimeout(() => { - console.log("😶‍🌫️ Hiccup!"); - chatClient.system?.server.kill(outageSeconds); // Disconnect from chat. - socket?.kill(outageSeconds); // Diconnect from socket session. - udp?.kill(outageSeconds); // Disconnect from UDP. - }, hiccupIn * 1000); - }, - // Remote debugging: Send log messages to session server for debugging on any device - log: function(levelOrFilename, ...args) { - // Handle both old API (filename, content) and new API (level, ...args) - if (levelOrFilename.startsWith('/tmp/') || levelOrFilename.includes('.log')) { - // Old API: filename, content - const filename = levelOrFilename; - const content = args[0]; - - // Send to session server if socket is available (removed verbose console log) - if (socket && socket.send) { - socket.send("dev-log", { - level: "INFO", - message: `${filename}: ${content}`, - device: navigator.userAgent || "unknown", - timestamp: Date.now() - }); - } - } else { - // New API: level, ...args - const level = levelOrFilename; - - // Serialize objects and create single-line message - const serializedArgs = args.map(arg => { - if (typeof arg === 'object' && arg !== null) { - try { - return JSON.stringify(arg); - } catch (e) { - return String(arg); - } - } - return String(arg); - }); - - const message = serializedArgs.join(" "); - - // Always log locally first - if (level === "warn") { - console.warn(...args); - } else if (level === "error") { - console.error(...args); - } else { - console.log(...args); - } - - // Send to session server if socket is available - if (socket && socket.send) { - socket.send("dev-log", { - level: level.toUpperCase(), - message: message, - device: navigator.userAgent || "unknown", - timestamp: Date.now() - }); - } - } - }, - }, - needsPaint: () => { - noPaint = false; - if (system === "nopaint") { - $commonApi.system.nopaint.needsPresent = true; - } - }, // TODO: Does "paint" needs this? - - store, - pieceCount: -1, // Incs to 0 when the first piece (usually the prompt) loads. - // Increments by 1 each time a new piece loads. - debug, - nopaintPerf, -}; - -// Convenience methods for different log levels -$commonApi.net.log.info = (...args) => $commonApi.net.log("info", ...args); -$commonApi.net.log.warn = (...args) => $commonApi.net.log("warn", ...args); -$commonApi.net.log.error = (...args) => $commonApi.net.log("error", ...args); - -// 🎬 Recording UI overlays - UI elements drawn on screen but NOT captured in tape recordings -// These are stored per-frame and cleared after sending to the main thread -let pendingRecordingUIOverlays = {}; - -// Add recording UI API to $commonApi -$commonApi.recordingUI = { - // Add an overlay that will be visible on screen but not recorded in tapes - // name: unique identifier for the overlay - // painting: a painting buffer created with $api.painting() - // x, y: position to draw the overlay - // opacity: optional opacity (0-1) - add: function(name, painting, x, y, opacity = 1) { - if (!painting || !painting.pixels) return; - pendingRecordingUIOverlays[name] = { - x: x, - y: y, - opacity: opacity, - img: { - width: painting.width, - height: painting.height, - pixels: painting.pixels, - }, - }; - }, - // Clear all recording UI overlays - clear: function() { - pendingRecordingUIOverlays = {}; - }, - // Get pending overlays (used internally when sending frame data) - _getPending: function() { - return pendingRecordingUIOverlays; - }, - // Clear pending overlays (used internally after sending) - _clearPending: function() { - pendingRecordingUIOverlays = {}; - }, -}; - -chatClient.$commonApi = $commonApi; // Add a reference to the `Chat` module. - -const nopaintAPI = $commonApi.system.nopaint; - -// 🎨 Start painting change monitoring for cross-tab sync -startPaintingChangeMonitoring(); - -// Broadcast to other tabs in the same origin. -const channel = new BroadcastChannel("aesthetic.computer"); - -channel.onmessage = (event) => { - processMessage(event.data); -}; - -async function processMessage(msg) { - if (logs.messaging) console.log(`🗼 Processing broadcast: ${msg}`); - - // 🎨 Handle painting updates (both new JSON format and legacy string format) - if (msg.startsWith("painting:") || (msg.startsWith("{") && msg.includes('"type":"painting:updated"'))) { - const isNopaintActive = $commonApi.system?.nopaint?.is?.("painting"); - if (isNopaintActive) { - console.log(`🚫 CROSS-TAB INTERFERENCE: Receiving painting update during nopaint operation - this may disrupt live preview!`, { - msgPreview: msg.substring(0, 100) + '...', - nopaintState: 'painting' - }); - } - await handlePaintingUpdate(msg); - return; - } - - if (msg.startsWith("handle:updated")) { - // 👰‍♀️ Update the user handle if it changed. - const newHandle = msg.split(":").pop(); - HANDLE = "@" + newHandle; - if (typeof window !== 'undefined') window.acHANDLE = HANDLE; // Expose for UDP identity - if (typeof window !== 'undefined' && window.acBootCanvas?.setHandle) window.acBootCanvas.setHandle(HANDLE); - send({ type: "handle", content: HANDLE }); - store["handle:received"] = true; - store["handle"] = newHandle; - // store.persist("handle"); - return; - } - - // Refresh the window if we logged in or out from another tab. - if (msg === "login:success" && !USER) { - $commonApi.net.refresh(); - return; - } - - if (msg === "logout:success" && USER) { - $commonApi.net.refresh(); // 🗒️ This should always be fast enough? - return; - } -} - -// 🎨 Handle painting update messages from other tabs -async function handlePaintingUpdate(msg) { - try { - // Parse the JSON message - const data = JSON.parse(msg); - - if (data.type !== "painting:updated") return; - - // Skip if this message is from the current tab - if (data.tabId === $commonApi._tabId) { - console.log(`🎨 SKIPPED: Own message (${data.action})`); - return; - } - - if (!$commonApi.system) { - console.log(`🎨 SKIPPED: No system available`); - return; - } - - // 📐 Handle resize events specifically - if ((data.action === "resized" && data.metadata?.source === "screen_resize") || - (data.action === "updated" && (data.source === "resize" || data.source === "crop"))) { - const width = data.width || data.metadata?.width; - const height = data.height || data.metadata?.height; - console.log(`📐 DIMENSION CHANGE EVENT: ${width}x${height} (source: ${data.source || data.metadata?.source})`); - - // For dimension change events, we need to wait for a "storage_complete" event - $commonApi._processingResize = true; - $commonApi._awaitingResizeStorage = { width, height, timestamp: data.timestamp }; - - console.log(`📐 Waiting for storage completion for ${data.source} ${width}x${height}...`); - return; // Don't continue with regular painting sync for dimension change events - } - - // 💾 Handle storage completion events - if (data.action === "storage_complete" && (data.source === "resize" || data.source === "crop")) { - const awaitingResize = $commonApi._awaitingResizeStorage; - console.log(`🪝 RECEIVED storage_complete:`, { - source: data.source, - timestamp: data.timestamp, - awaitingResize: awaitingResize, - timestampMatch: awaitingResize && data.timestamp === awaitingResize.timestamp - }); - - if (awaitingResize && data.timestamp === awaitingResize.timestamp) { - console.log(`📐 Storage completed for ${data.source} ${awaitingResize.width}x${awaitingResize.height}, applying...`); - - try { - const storedPainting = await store.retrieve("painting", "local:db"); - if (storedPainting) { - // Update system painting with the stored version - $commonApi.system.painting = { - width: storedPainting.width, - height: storedPainting.height, - pixels: new Uint8ClampedArray(storedPainting.pixels) - }; - - // Store the painting data - store["painting"] = { - width: storedPainting.width, - height: storedPainting.height, - pixels: storedPainting.pixels - }; - - // Update painting hash - lastPaintingHash = generatePaintingHash($commonApi.system.painting); - - // Update KidLisp with new painting reference - const kidlispInstance = getGlobalKidLisp(); - if (kidlispInstance) { - kidlispInstance.setAPI($commonApi); - console.log(`🎯 KidLisp API refreshed with ${data.source} painting: ${storedPainting.width}x${storedPainting.height}`); - } - - // Force repaint with new dimensions - if ($commonApi.system.nopaint) { - $commonApi.system.nopaint.needsPresent = true; - } - $commonApi.needsPaint(); - - console.log(`📐 Screen updated after ${data.source}: ${storedPainting.width}x${storedPainting.height}`); - } - } catch (error) { - console.error(`📐 ERROR applying ${data.source} after storage completion:`, error); - } - - // Clean up - delete $commonApi._awaitingResizeStorage; - $commonApi._processingResize = false; - return; - } - } - - // Set processing flag to prevent feedback loops - $commonApi._processingBroadcast = true; - - try { - // Load painting from storage (lightweight messages don't contain pixel data) - const storedPainting = await store.retrieve("painting", "local:db"); - if (storedPainting) { - $commonApi.system.painting = { - width: storedPainting.width, - height: storedPainting.height, - pixels: new Uint8ClampedArray(storedPainting.pixels) - }; - - // Store only the pixel data, not the full painting object - store["painting"] = { - width: storedPainting.width, - height: storedPainting.height, - pixels: storedPainting.pixels - }; - - // Update painting hash to prevent false positives - lastPaintingHash = generatePaintingHash($commonApi.system.painting); - - // 🎯 Update KidLisp with the new painting reference - const kidlispInstance = getGlobalKidLisp(); - if (kidlispInstance) { - kidlispInstance.setAPI($commonApi); - console.log(`🎯 KidLisp API refreshed with updated painting`); - } - - // Force repaint - if ($commonApi.system.nopaint) { - $commonApi.system.nopaint.needsPresent = true; - } - $commonApi.needsPaint(); - - // 🆕 Force nopan (reset transform) for clear/new actions or explicit flag - if (((data.action === "cleared" || data.action === "new") || data.resetTransform) && $commonApi.system.nopaint) { - console.log(`🎯 ${data.action?.toUpperCase() || 'TRANSFORM_RESET'}: Forcing nopan (reset transform) on receiver`); - $commonApi.system.nopaint.resetTransform({ - system: $commonApi.system, - screen: $commonApi.screen - }); - } - - console.log(`🎨 PAINTING SYNCED: ${storedPainting.width}x${storedPainting.height}`, { - hash: generatePaintingHash($commonApi.system.painting)?.substr(0, 8), - fromHash: data.hash || 'unknown', - firstPixels: Array.from($commonApi.system.painting.pixels.slice(0, 12)) // First 3 pixels (RGBA) - }); - } else { - // No painting found in storage - } - } catch (storageError) { - console.error(`🎨 ERROR loading from storage:`, storageError); - } - - } catch (error) { - console.error(`🎨 ERROR handling painting update:`, error); - - // Fallback to old string-based format - if (typeof msg === "string" && msg.startsWith("painting:")) { - const action = msg.split(":")[1]; - console.log(`🎨 FALLBACK: Processing legacy format ${action}`); - - // Legacy handling - load from storage - try { - const storedPainting = await store.retrieve("painting", "local:db"); - if (storedPainting) { - $commonApi.system.painting = { - width: storedPainting.width, - height: storedPainting.height, - pixels: new Uint8ClampedArray(storedPainting.pixels) - }; - - // Store only the pixel data, not the full painting object - store["painting"] = { - width: storedPainting.width, - height: storedPainting.height, - pixels: storedPainting.pixels - }; - - const kidlispInstance = getGlobalKidLisp(); - if (kidlispInstance) { - kidlispInstance.setAPI($commonApi); - } - - if ($commonApi.system.nopaint) { - $commonApi.system.nopaint.needsPresent = true; - } - $commonApi.needsPaint(); - } - } catch (storageError) { - console.error(`🎨 ERROR in fallback storage load:`, storageError); - } - } - } finally { - // Clear processing flag regardless of success or error - setTimeout(() => { - $commonApi._processingBroadcast = false; - }, 50); - } -} - -$commonApi.broadcast = (msg) => { - processMessage(msg); // Process locally. - channel.postMessage(msg); -}; - -// 🎨 Broadcast painting updates to other tabs -$commonApi.broadcastPaintingUpdate = (action, data = {}) => { - if (!$commonApi.system?.painting) return; - - // Check if we're in a nopaint operation and log it - const isNopaintActive = $commonApi.system?.nopaint?.is?.("painting"); - if (isNopaintActive) { - console.log(`🚫 NOPAINT INTERFERENCE: Attempting to broadcast "${action}" during nopaint operation - this may disrupt live preview!`, { - action, - source: data.source, - nopaintState: 'painting' - }); - } - - // Throttle broadcasts to prevent excessive messages - const now = Date.now(); - if (now - lastBroadcastTime < broadcastThrottleDelay) { - return; - } - lastBroadcastTime = now; - - // Generate unique tab ID to prevent self-processing - if (!$commonApi._tabId) { - $commonApi._tabId = Math.random().toString(36).substr(2, 9); - } - - // Create lightweight notification message (no pixel data) - const message = { - type: "painting:updated", - action, - tabId: $commonApi._tabId, - timestamp: now, - width: $commonApi.system.painting.width, - height: $commonApi.system.painting.height, - ...data - }; - - // Only send to other tabs (exclude self) - channel.postMessage(JSON.stringify(message)); -}; - -// 🚀 Immediate painting broadcast - bypasses throttling for instant sync -$commonApi.broadcastPaintingUpdateImmediate = (action, data = {}) => { - if (!$commonApi.system?.painting || $commonApi._processingBroadcast) return; - - // Check for nopaint interference - const isNopaintActive = $commonApi.system?.nopaint?.is?.("painting"); - if (isNopaintActive) { - console.log(`🚫 IMMEDIATE NOPAINT INTERFERENCE: Attempting immediate broadcast "${action}" during nopaint operation!`, { - action, - source: data.source, - nopaintState: 'painting' - }); - } - - // Performance: Skip if we just broadcasted very recently (debounce) - const now = Date.now(); - if (now - (lastBroadcastTime || 0) < 16) { // ~60fps max broadcast rate - return; - } - - // Generate unique tab ID if needed - if (!$commonApi._tabId) { - $commonApi._tabId = Math.random().toString(36).substr(2, 9); - } - - const paintingHash = generatePaintingHash($commonApi.system.painting); - const message = { - type: "painting:updated", - action, - tabId: $commonApi._tabId, - timestamp: now, - width: $commonApi.system.painting.width, - height: $commonApi.system.painting.height, - hash: paintingHash?.substr(0, 8), - immediate: true, - ...data - }; - - // Immediate broadcasting silently - - // Store painting immediately (async to not block) - setTimeout(() => { - store["painting"] = { - width: $commonApi.system.painting.width, - height: $commonApi.system.painting.height, - pixels: $commonApi.system.painting.pixels, - }; - store.persist("painting", "local:db"); - - // 🪝 Fire storage completion hook for resize and crop events - if (data.source === "resize" || data.source === "crop") { - const storageCompleteMessage = { - type: "painting:updated", - action: "storage_complete", - source: data.source, - tabId: message.tabId, - timestamp: message.timestamp, - width: $commonApi.system.painting.width, - height: $commonApi.system.painting.height - }; - - channel.postMessage(JSON.stringify(storageCompleteMessage)); - console.log(`🪝 STORAGE COMPLETE hook fired for ${data.source} ${$commonApi.system.painting.width}x${$commonApi.system.painting.height}`, { - timestamp: message.timestamp, - tabId: message.tabId.substr(0, 4) + '...' - }); - } - - }, 0); - - // Update hash to prevent duplicate detection - lastPaintingHash = paintingHash; - lastBroadcastTime = now; - - channel.postMessage(JSON.stringify(message)); -}; - -// 🎨 Painting change detection system -function generatePaintingHash(painting) { - if (!painting?.pixels) return null; - - // Simple hash using width, height, and sample of pixels - let hash = painting.width * 31 + painting.height * 37; - - // Sample every 100th pixel to create a lightweight hash - for (let i = 0; i < painting.pixels.length; i += 100) { - hash = ((hash << 5) - hash + painting.pixels[i]) & 0xffffffff; - } - - return hash.toString(36); -} - -function startPaintingChangeMonitoring() { - if (paintingChangeCheckInterval) return; // Already monitoring - - // Painting change monitoring initialized silently - - // Initialize hash baseline if painting exists - if ($commonApi.system?.painting) { - lastPaintingHash = generatePaintingHash($commonApi.system.painting); - } - - // Enable frame-based monitoring for immediate detection - enableFrameBasedMonitoring(); - - // Keep interval-based monitoring as backup (lower frequency) - paintingChangeCheckInterval = setInterval(() => { - if (!$commonApi.system?.painting || frameBasedMonitoring) return; // Skip if frame monitoring is active - - const currentHash = generatePaintingHash($commonApi.system.painting); - - // Only broadcast if hash actually changed AND we're not currently processing a broadcast - if (lastPaintingHash !== null && - currentHash !== lastPaintingHash && - !$commonApi._processingBroadcast) { - - // Set flag to prevent feedback loops during broadcast processing - $commonApi._processingBroadcast = true; - - // Store updated painting - store["painting"] = { - width: $commonApi.system.painting.width, - height: $commonApi.system.painting.height, - pixels: $commonApi.system.painting.pixels, - }; - store.persist("painting", "local:db"); - - // Broadcast the change - $commonApi.broadcastPaintingUpdate("updated", { - source: "interval_monitor", - hash: currentHash.substr(0,8) - }); - - // Clear the processing flag after a brief delay - setTimeout(() => { - $commonApi._processingBroadcast = false; - }, 100); - } - - lastPaintingHash = currentHash; - }, 1000); // Slower backup monitoring (1 second) -} - -function stopPaintingChangeMonitoring() { - frameBasedMonitoring = false; // Stop frame-based monitoring - if (paintingChangeCheckInterval) { - clearInterval(paintingChangeCheckInterval); - paintingChangeCheckInterval = null; - } -} - -// Spawn a session backend for a piece. -async function session(slug, forceProduction = false, service) { - let endPoint = "/session/" + slug; - const params = { service }; - if (forceProduction) params.forceProduction = 1; - endPoint += "?" + new URLSearchParams(params); - - const req = await fetch(endPoint); - - let session; - if (req.status === 200 || req.status === 304) { - session = await req.text().then((text) => { - try { - return JSON.parse(text); - } catch (e) { - return text; - } - }); - } else { - session = await req.text(); - } - - if (typeof session === "string") return session; - - // Session server is always ready (no Jamsocket polling needed) - // Just return the session object with URLs - return session; -} - -// Just for "update". -const $updateApi = {}; - -// 🖼 Painting - -// Pre-fab models: -const QUAD = { - type: "quad", - positions: [ - // Triangle 1 (Left Side) - [-1, -1, 0, 1], // Bottom Left - [-1, 1, 0, 1], // Top Left - [1, 1, 0, 1], // Top Right - // Triangle 2 (Right Side) - [-1, -1, 0, 1], // Bottom Left - [1, -1, 0, 1], // Bottom Right - [1, 1, 0, 1], // Top Right - ], - indices: [ - // These are not re-used for now. - // One - 0, 1, 2, - //Two - 3, 4, 5, - ], -}; - -// A cube of lines. -const CUBEL = { - type: "line", - positions: [ - // Back - [-0.5, -0.5, 0.5, 1], // Down - [-0.5, 0.5, 0.5, 1], - - [-0.5, 0.5, 0.5, 1], // Across - [0.5, 0.5, 0.5, 1], - - [0.5, 0.5, 0.5, 1], // Up - [0.5, -0.5, 0.5, 1], - - [0.5, -0.5, 0.5, 1], // Back - [-0.5, -0.5, 0.5, 1], - // Front - [-0.5, -0.5, -0.5, 1], // Down - [-0.5, 0.5, -0.5, 1], - - [-0.5, 0.5, -0.5, 1], // Across - [0.5, 0.5, -0.5, 1], - - [0.5, 0.5, -0.5, 1], // Up - [0.5, -0.5, -0.5, 1], - - [0.5, -0.5, -0.5, 1], // Back - [-0.5, -0.5, -0.5, 1], - // Bars (back to front) - [-0.5, -0.5, 0.5, 1], // Top Left - [-0.5, -0.5, -0.5, 1], - - [-0.5, 0.5, 0.5, 1], // Bottom Left - [-0.5, 0.5, -0.5, 1], - - [0.5, 0.5, 0.5, 1], // Up - [0.5, 0.5, -0.5, 1], - - [0.5, -0.5, 0.5, 1], // Back - [0.5, -0.5, -0.5, 1], - ], -}; - -const ORIGIN = { - type: "line", - positions: [ - [-0.5, 0, 0, 1], // Horizontal X - [0.5, 0, 0, 1], - [0, 0, -0.5, 1], // Horizontal Z - [0, 0, 2, 1], - [0, -0.5, 0, 1], // Vertical - [0, 0.5, 0, 1], - ], - colors: [ - [255, 0, 0, 255], - [255, 0, 0, 255], - [0, 255, 0, 255], - [0, 255, 0, 255], - [0, 0, 255, 255], - [0, 0, 255, 255], - ], -}; - -const TRI = { - type: "triangle", - positions: [ - [-1, -1, 0, 1], // Bottom Left - [0, 1, 0, 1], // Top Left - [1, -1, 0, 1], // Top Right - // Triangle 2 (Right Side) - ], - indices: [0, 1, 2], -}; - -const LINE = { - type: "line", - positions: [ - [0, 0, 0, 1], // Bottom - [0, 1, 0, 1], // Top - ], - indices: [0, 1], -}; - -// Inputs: (r, g, b), (r, g, b, a) or an array of those. -// (rgb) for grayscale or (rgb, a) for grayscale with alpha. -// Or hex with "#000000" or "0x000000" or 0x000000. -// TODO: Add `erase` anc all css color alpha support. 23.07.20.14.45 -// TODO: Add transparency and short hex to hex support. -// TODO: Add better hex support via: https://stackoverflow.com/a/53936623/8146077 - -function ink() { - const foundColor = graph.findColor(...arguments); - if (inkFloodLoggingEnabled()) { - console.log( - `${inkFloodLogPrefix()}🖍️ INK DEBUG`, - { - args: cloneArgsForLog(arguments), - resolved: cloneColorForLog(foundColor) - } - ); - } - const result = graph.color(...foundColor); - if (inkFloodLoggingEnabled()) { - console.log( - `${inkFloodLogPrefix()}🖍️ INK APPLIED`, - { - color: cloneColorForLog(result) - } - ); - } - return result; -} - -function ink2() { - if (arguments[0] === null) { - return graph.color2(null); - } else { - return graph.color2(...graph.findColor(...arguments)); - } -} - -// 🎯 Global KidLisp Instance Management -function initializeGlobalKidLisp(api) { - if (!globalKidLispInstance) { - // console.log("🚀 Initializing global KidLisp instance"); - if (typeof lisp.ensureGlobalInstance === "function") { - globalKidLispInstance = lisp.ensureGlobalInstance(); - } else if (typeof lisp.getGlobalInstance === "function") { - globalKidLispInstance = lisp.getGlobalInstance() || new lisp.KidLisp(); - } else { - globalKidLispInstance = new lisp.KidLisp(); - } - globalKidLispInstance.setAPI(api); - // Expose to window for bios.mjs acCLEAR_BAKE_LAYERS - if (typeof window !== 'undefined') { - window.__acGlobalKidLispInstance = globalKidLispInstance; - } - // Enable performance HUD if PERF_MODE is active - if (PERF_MODE) { - globalKidLispInstance.startPerformanceMonitoring(); - } - // Enable auto-density scaling if AUTO_SCALE_MODE is active - if (AUTO_SCALE_MODE) { - globalKidLispInstance.enableAutoDensity(); - } - } - return globalKidLispInstance; -} - -function getGlobalKidLisp() { - return globalKidLispInstance; -} - -// 🎵 Update KidLisp audio globals (safe for worker contexts) -function updateKidLispAudio(audioData) { - const payload = - audioData && typeof audioData === "object" - ? { ...audioData, __source: audioData.__source || "embedded:paintApi" } - : audioData; - if (globalKidLispInstance && globalKidLispInstance.updateAudioGlobals) { - globalKidLispInstance.updateAudioGlobals(payload); - } else if (payload?.amp > 0) { - console.warn("🔊 updateKidLispAudio: no instance!", !!globalKidLispInstance, !!globalKidLispInstance?.updateAudioGlobals); - } -} - -// 🔎 PAINTAPI -const $paintApi = { - // 1. Composite functions (that use $activePaintApi) - // (Must be unwrapped) - // Prints a line of text using the default / current global font. - // Argument options: - // text, pos: {x, y, center}, bg (optional) - - // Parameters: - // text, x, y, options, wordWrap, customTypeface - // text, pos, bg, bounds, wordWrap = true, customTypeface - write: function () { - let text = arguments[0], - pos, - bg, - bounds, - wordWrap = true, - customTypeface = null, - rotation = 0; - - if (text === undefined || text === null || text === "" || !tf) - return $activePaintApi; // Fail silently if no text. - - text = - typeof text === "object" && text !== null - ? JSON.stringify(text) - : text.toString(); - - // Assume: text, x, y, options, wordWrap, customTypeface - if (typeof arguments[1] === "number") { - pos = { x: arguments[1], y: arguments[2] }; - const options = arguments[3]; - bg = options?.bg; - bounds = options?.bounds; - wordWrap = options?.wordWrap === undefined ? wordWrap : options.wordWrap; - customTypeface = options?.typeface; - rotation = options?.rotation ?? options?.angle ?? 0; - } else { - pos = arguments[1]; - bg = arguments[2]; - bounds = arguments[3]; - wordWrap = arguments[4] === undefined ? wordWrap : arguments[4]; - customTypeface = arguments[5]; - // Extract rotation from pos object if it exists - rotation = pos?.rotation ?? pos?.angle ?? 0; - } - - if (customTypeface) { - const resolvedTypeface = resolveTypefaceInstance(customTypeface); - if (resolvedTypeface) { - ensureTypefaceLoaded(resolvedTypeface); - customTypeface = resolvedTypeface; - } - } - - // 🎨 Color code processing - const hasColorCodes = textContainsColorCodes(text); - - if (hasColorCodes) { - - - // Remember the current ink color to restore it later - const originalColor = $activePaintApi.inkrn(); - - // Process color codes into per-character color array - let cleanText = ""; - let charColors = []; - let currentColor = null; - - // Split text by color codes and process each segment - COLOR_CODE_MATCH_REGEX.lastIndex = 0; - const segments = text.split(COLOR_CODE_MATCH_REGEX); - - for (let i = 0; i < segments.length; i++) { - if (i % 2 === 0) { - // This is regular text - const segment = segments[i]; - for (let j = 0; j < segment.length; j++) { - cleanText += segment[j]; - charColors.push(currentColor); - - - } - } else { - // This is a color name or RGB value (from the captured group) - const colorStr = segments[i]; - - if (!colorStr) { - continue; - } - - const normalized = colorStr.trim(); - const lower = normalized.toLowerCase(); - - if (!normalized) { - continue; - } - - if (lower === "reset" || lower === "default" || lower === "base") { - currentColor = null; - continue; - } - - if (normalized.includes(",")) { - // RGB/RGBA format like "255,0,0" or "255,0,0,128" - const parts = normalized.split(",").map((n) => { - const parsed = parseInt(n.trim(), 10); - return Number.isFinite(parsed) ? parsed : 0; - }); - while (parts.length < 3) parts.push(0); - if (parts.length === 3) parts.push(255); - currentColor = parts.slice(0, 4); - } else if (lower === "transparent" || lower === "clear") { - currentColor = [0, 0, 0, 0]; - } else { - const resolved = graph.findColor(normalized); - if (Array.isArray(resolved)) { - currentColor = resolved.slice(); - } else if (resolved && typeof resolved === "object") { - currentColor = { ...resolved }; - } else if (resolved !== undefined) { - currentColor = resolved; - } else { - currentColor = null; - } - } - - - } - } - - COLOR_CODE_MATCH_REGEX.lastIndex = 0; - - // Check if we have any actual text to display after removing color codes - if (cleanText.trim().length === 0) { - return $activePaintApi; // Exit silently if no text content remains - } - - - - // Render with colors - same logic as original text processing - const scale = pos?.size || 1; - - if (bounds) { - const tb = $commonApi.text.box(cleanText, pos, bounds, scale, wordWrap, customTypeface); - if (!tb || !tb.lines) { - return $activePaintApi; // Exit silently if text.box fails - } - - const charMap = tb.charMap || []; - - tb.lines.forEach((lineText, index) => { - const renderedLine = typeof lineText === "string" ? lineText : lineText?.join?.(" ") || ""; - const sourceIndices = charMap[index] || []; - const lineColors = []; - - for (let i = 0; i < renderedLine.length; i++) { - const sourceIndex = sourceIndices[i]; - if ( - typeof sourceIndex === "number" && - sourceIndex >= 0 && - sourceIndex < charColors.length - ) { - lineColors.push(charColors[sourceIndex]); - } else { - lineColors.push(null); - } - } - - // Add rotation to tb.pos - const posWithRotation = { ...tb.pos, rotation }; - - (customTypeface || tf)?.print( - $activePaintApi, - posWithRotation, - index, - renderedLine, - bg, - lineColors, - ); - }); - } else { - // Break on `\n` and handle separate lines - if (cleanText.indexOf("\n") !== -1) { - const lines = cleanText.split("\n"); - const lineHeightGap = 2; - let charIndex = 0; - - lines.forEach((line, index) => { - const lineColors = charColors?.slice( - charIndex, - charIndex + line.length, - ); - (customTypeface || tf)?.print( - $activePaintApi, - { - x: pos?.x, - y: pos - ? pos.y + index * (customTypeface || tf).blockHeight + lineHeightGap - : undefined, - rotation, - }, - 0, - line, - bg, - lineColors, - ); - charIndex += line.length + 1; // +1 for the newline character - }); - } else { - // Add rotation to pos - const posWithRotation = { ...pos, rotation }; - (customTypeface || tf)?.print($activePaintApi, posWithRotation, 0, cleanText, bg, charColors); - } - } - - const typefaceName = typeof customTypeface === "string" ? customTypeface : customTypeface?.name; - if (typefaceName === "MatrixChunky8" || typefaceName === "unifont") { - const hasColorAssignments = charColors?.some((color) => { - if (!color) return false; - if (Array.isArray(color)) { - return color.some((component) => component !== null && component !== undefined); - } - if (typeof color === "object") { - return Object.keys(color).length > 0; - } - return typeof color === "string"; - }); - - if (!hasColorAssignments) { - const snippet = cleanText.length > 120 ? `${cleanText.slice(0, 117)}…` : cleanText; - if (lastMatrixChunkyWriteDiagnosticLog !== snippet) { - lastMatrixChunkyWriteDiagnosticLog = snippet; - console.warn("🎨 MatrixChunky8 HUD charColors missing", { - snippet, - charColorsLength: charColors?.length, - cleanTextLength: cleanText.length, - }); - } - } - } - - // Restore the original ink color - $activePaintApi.ink(...originalColor); - return $activePaintApi; - } - - // 🎁 Original code for text without color codes - // See if the text length is greater than the bounds, and if it is then - // print on a new line. - const scale = pos?.size || 1; - - if (bounds) { - const tb = $commonApi.text.box(text, pos, bounds, scale, wordWrap, customTypeface); // TODO: Get the current ink color, memoize it, and make it static here. - // 23.10.12.22.04 - tb.lines.forEach((lineText, index) => { - const renderedLine = typeof lineText === "string" ? lineText : lineText?.join?.(" ") || ""; - // Add rotation to tb.pos - const posWithRotation = { ...tb.pos, rotation }; - (customTypeface || tf)?.print($activePaintApi, posWithRotation, index, renderedLine, bg); - }); - } else { - // Break on `\n` and handle separate lines - if (text.indexOf("\n") !== -1) { - const lines = text.split("\n"); // Split text on new line characters - const lineHeightGap = 2; - lines.forEach((line, index) => { - (customTypeface || tf)?.print( - $activePaintApi, - { - x: pos?.x, - y: pos - ? pos.y + index * (customTypeface || tf).blockHeight + lineHeightGap - : undefined, - rotation, - }, - 0, - line, - bg, - ); - // Adjust `lineHeight` as needed based on your text spacing - }); - } else { - //if (text === "POW") console.log($activePaintApi.screen); 24.12.10.07.26 - Get write working with deferred rendering and page. - - const actualFont = customTypeface || tf; - // Add rotation to pos - const posWithRotation = { ...pos, rotation }; - actualFont?.print($activePaintApi, posWithRotation, 0, text, bg); // Or print a single line. - } - } - - return $activePaintApi; - }, - // 2. Image Utilities - clonePixels: graph.cloneBuffer, - colorsMatch: graph.colorsMatch, - color: graph.findColor, - resize: graph.resize, - // 3. 3D Classes & Objects - Camera: graph.Camera, - Form: graph.Form, - Dolly: graph.Dolly, - sign: graph.sign, - cacheSignGlyph: graph.cacheSignGlyph, - // Helper to get glyphs from a typeface for use with sign() - glyphs: (fontName = "MatrixChunky8") => { - const typeface = typefaceCache.get(fontName); - return typeface?.glyphs || {}; - }, - TRI, - QUAD, - LINE, - CUBEL, - ORIGIN, - // Project text glyphs onto a 3D plane, drawing each character's line/point - // commands through a projection function. - // Usage: ink(r,g,b,a).write3D("text", { origin, right, down, project, typeface }, scale) - // origin: [x,y,z] — top-left of the first character in world space - // right: [x,y,z] — unit-ish direction for text flow (gets scaled by glyph size) - // down: [x,y,z] — unit-ish direction for descending lines - // project: ([x,y,z]) => [screenX, screenY] — world-to-screen projection - // typeface: optional typeface name (e.g. "MatrixChunky8") — defaults to active tf - // scale: multiplier for glyph coordinate spacing (default 1) - write3D: function (text, plane, scale = 1) { - if (!text || !plane) return $activePaintApi; - const { origin, right, down, project } = plane; - if (!origin || !right || !down || !project) return $activePaintApi; - - // Resolve typeface: use specified one from cache, or fall back to active tf - let face = tf; - if (plane.typeface && typefaceCache.has(plane.typeface)) { - face = typefaceCache.get(plane.typeface); - } - if (!face) return $activePaintApi; - - const blockW = face.blockWidth || face.data?.glyphWidth || 6; - const fontData = face.data || {}; - const advances = fontData.advances || {}; - let cursorX = 0; - - for (let ci = 0; ci < text.length; ci++) { - const char = text[ci]; - const glyph = face.getGlyph?.(char) || face.glyphs?.[char]; - const advance = advances[char] || face.getAdvance?.(char) || blockW; - - if (glyph) { - // Apply glyph offset (baselineOffset preferred, then offset) - let offX = 0, offY = 0; - if (glyph.baselineOffset) { - offX = glyph.baselineOffset[0] * scale; - offY = glyph.baselineOffset[1] * scale; - } else if (glyph.offset) { - offX = glyph.offset[0] * scale; - offY = glyph.offset[1] * scale; - } - - // Compute projected pixel size once for this glyph - // by measuring the screen distance of one glyph-pixel step - const projectPt = (gx, gy) => { - const px = cursorX + (gx * scale + offX); - const py = gy * scale + offY; - const wx = origin[0] + px * right[0] + py * down[0]; - const wy = origin[1] + px * right[1] + py * down[1]; - const wz = origin[2] + px * right[2] + py * down[2]; - return project([wx, wy, wz]); - }; - - // Measure projected pixel size (distance for 1 glyph-pixel in each axis) - const [refX, refY] = projectPt(0, 0); - const [refRX, refRY] = projectPt(1, 0); - const [refDX, refDY] = projectPt(0, 1); - const pxW = Math.sqrt((refRX - refX) ** 2 + (refRY - refY) ** 2) || 1; - const pxH = Math.sqrt((refDX - refX) ** 2 + (refDY - refY) ** 2) || 1; - const pxSize = Math.max(1, Math.round((pxW + pxH) / 2)); - - // Optional per-pixel callback (lets caller vary ink per pixel) - const pixelCb = plane.pixelCallback; - - // Render each "on" pixel as a box at its projected center - const renderPixel = (gx, gy) => { - const [sx, sy] = projectPt(gx + 0.5, gy + 0.5); - const bx = Math.floor(sx - pxSize / 2); - const by = Math.floor(sy - pxSize / 2); - if (pixelCb) pixelCb(bx, by, gx, gy, ci); - $activePaintApi.box(bx, by, pxSize, pxSize); - }; - - // BDF pixel-based glyphs (MatrixChunky8, etc.) - if (glyph.pixels) { - for (let row = 0; row < glyph.pixels.length; row++) { - const pixelRow = glyph.pixels[row]; - if (!Array.isArray(pixelRow)) continue; - for (let col = 0; col < pixelRow.length; col++) { - if (pixelRow[col] === 1) renderPixel(col, row); - } - } - } - - // Vector command-based glyphs (font_1, etc.) - if (glyph.commands) { - for (const { name, args } of glyph.commands) { - if (name === "point") { - renderPixel(args[0], args[1]); - } else if (name === "line") { - // Render pixel boxes at both endpoints + line between - renderPixel(args[0], args[1]); - renderPixel(args[2], args[3]); - const [s1x, s1y] = projectPt(args[0], args[1]); - const [s2x, s2y] = projectPt(args[2], args[3]); - $activePaintApi.line( - Math.floor(s1x), Math.floor(s1y), - Math.floor(s2x), Math.floor(s2y), - ); - } - } - } - } - cursorX += advance * scale; - } - return $activePaintApi; - }, -}; - -// TODO: Eventually move this to `num`. 24.07.23.18.52 -function normalizeAngle(angle) { - return ((angle % 360) + 360) % 360; -} - -let turtleAngle = 270; -let turtleDown = false; -let turtlePosition = { x: 0, y: 0 }; - -// This is where I map the API functions that anyone can use, to the internal -// code that represents them... - -// Rendering of 3D forms. - -const formsToClear = []; -let backgroundColor3D = [0, 0, 0, 255]; -let formsSent = {}; // TODO: This should be cleared more often... - -// `cpu: true` enabled software rendering -function form( - forms, - cam = $commonApi.system.fps.doll.cam, - { cpu, background } = { - cpu: true, - keep: true, - background: backgroundColor3D, - }, -) { // Exit silently if no forms are present. - if (forms == null || forms?.length === 0) return; - - if (cpu === true) { - if (formReframing) { - cam.resize(); - formReframing = false; - } - if (Array.isArray(forms)) - forms.filter(Boolean).forEach((form) => form.graph(cam)); - else forms.graph(cam); - } else { - // GPU forms. - if (!Array.isArray(forms)) forms = [forms]; - - // Clear out any forms that need deleting. - formsToClear.forEach((id) => delete formsSent[id]); - formsToClear.length = 0; - - // Build a list of forms to send, ignoring already sent ones by UID. - const formsToSend = []; - - forms.filter(Boolean).forEach((form) => { - // Clear out any trash in `formsSent` that do not have IDs left in forms. - //if (formsSent[forms.uid]) - - // A. If the form has not been sent yet... - if (formsSent[form.uid] === undefined && form.vertices.length > 0) { - // Set the form to expire automatically if keep is false. - formsToSend.push(form); - formsSent[form.uid] = form; - //console.log("Forms sent:", Object.keys(formsSent).length); - form.gpuVerticesSent = form.vertices.length; - form.gpuReset = false; - } else { - // B. If the form has been sent, but the form has changed and - // needs a partial state update or is simply being redrawn. - let msgCount = 0; - - if (form.gpuRecolored === true) { - formsToSend.push({ - update: "form:color", - uid: form.uid, - color: form.color, - }); - msgCount += 1; - form.gpuRecolored = false; - } - - // Transform the geometry. - if (form.gpuTransformed === true) { - formsToSend.push({ - update: "form:transform", - uid: form.uid, - rotation: form.rotation, - position: form.position, - scale: form.scale, - }); - form.gpuTransformed = false; - msgCount += 1; - } - - if (form.vertices.length > form.gpuVerticesSent || form.gpuReset) { - // Add vertices to buffered forms. - formsToSend.push({ - update: "form:buffered:add-vertices", - uid: form.uid, - reset: form.gpuReset, - vertices: form.vertices.slice(form.gpuVerticesSent), - length: form.vertices.length, // TODO: These aren't being used anymore / they are generated from the GPU. - pastLength: form.gpuVerticesSent, - }); - - // Update form state now that we are sending the message. - // TODO: Put these both under a "gpu" object in form. - //console.log(form.gpuReset); - form.gpuReset = false; - form.gpuVerticesSent = form.vertices.length; - msgCount += 1; - } - - if (msgCount === 0) { - // Simply tell the system we are still drawing the form... otherwise - // it will get cleared. - formsToSend.push({ - update: "form:touch", - uid: form.uid, - }); - } - } - }); - - if (formsToSend.length === 0) return; - - // console.log("Sending form...", performance.now()) - - // Only send a background update if the value changed. - if (background !== backgroundColor3D) { - send({ - type: "gpu-event", - content: { - type: "background-change", - content: background, - }, - }); - backgroundColor3D = background; - } - - send({ - type: "forms", - content: { - forms: formsToSend, - cam: { - position: cam.position, - rotation: cam.rotation, - scale: cam.scale, - fov: cam.fov, - near: cam.near, - far: cam.far, - }, - color: graph.color(), - }, - }); - - // paintFormsResolution?.(); - // return new Promise((resolve) => { - // paintFormsResolution = resolve; - // }); - } -} - -// 🔑 Code resolution cache for #code -> @handle/slug lookups -const codeCache = { - memory: new Map(), // code -> @handle/slug resolution cache - reverseMemory: new Map(), // @handle/slug -> code reverse lookup cache - - async resolve(code) { - // Return cached resolution if available - if (this.memory.has(code)) { - return this.memory.get(code); - } - - // Query painting-code API which returns slug and handle - const response = await fetch(`/api/painting-code?code=${code}`); - if (!response.ok) { - throw new Error(`Code #${code} not found`); - } - - const data = await response.json(); - - // Build the path format: @handle/slug or just slug for anonymous - let path; - if (data.handle && data.handle !== 'anon') { - path = `@${data.handle}/${data.slug}`; - } else { - path = data.slug; - } - - this.memory.set(code, path); - this.reverseMemory.set(path, code); - return path; - }, - - // Store a mapping from timestamp to code (called by profile.mjs and other pieces) - storeCode(slug, handle, code) { - if (!slug || !code) return; - - // Normalize the key format to match what we use elsewhere - const normalizedHandle = handle?.replace(/^@+/, ''); - const key = normalizedHandle ? `@${normalizedHandle}/${slug}` : slug; - - this.reverseMemory.set(key, code); - this.memory.set(code, key); - }, - - // Get code from timestamp+handle - getCode(slug, handle) { - const normalizedHandle = handle?.replace(/^@+/, ''); - const key = normalizedHandle ? `@${normalizedHandle}/${slug}` : slug; - return this.reverseMemory.get(key); - } -}; - - -// 🔑 Expose code cache debug utility -if (typeof window !== "undefined") { - window.acCodeCacheStats = () => { - console.log("🔑 Code resolution cache stats:", { - cacheSize: codeCache.memory.size, - codes: Array.from(codeCache.memory.entries()).map(([code, path]) => ({ - code: `#${code}`, - resolvedTo: path - })) - }); - }; -} - -// Used by `paste` and `stamp` to prefetch bitmaps of the network as needed. -// Occurs also when loading a piece's source code. -async function prefetchPicture(code) { - const originalCode = code; - - // Strip # prefix if present to get the actual code - let actualCode = code; - if (code.startsWith('#')) { - actualCode = code.slice(1); - } - - if (paintings[code] === "fetching") return; - - // 🖼️ Check multi-level cache first - // Level 1: Check if already in paintings cache - if (paintings[code] && paintings[code] !== "fetching") { - return; - } - - // Level 2: Check in-memory image cache - if (imageCache.has(code)) { - paintings[code] = imageCache.get(code); - return; - } - - // Level 3: Check persistent storage (IndexedDB) - if (imageCache.isCacheable(code)) { - const cachedImage = await imageCache.loadFromPersistent(code); - if (cachedImage) { - // console.log("🖼️ Loaded from cache:", code); - paintings[code] = cachedImage; - return; - } - } - - // Level 3.5: Check for embedded painting bitmaps (pack mode / bundled HTML) - const globalScope = (function() { - if (typeof globalThis !== 'undefined') return globalThis; - if (typeof self !== 'undefined') return self; - if (typeof window !== 'undefined') return window; - return {}; - })(); - - if (globalScope.acEMBEDDED_PAINTING_BITMAPS) { - // Check with various key formats - const embeddedBitmap = globalScope.acEMBEDDED_PAINTING_BITMAPS[code] || - globalScope.acEMBEDDED_PAINTING_BITMAPS['#' + actualCode] || - globalScope.acEMBEDDED_PAINTING_BITMAPS[actualCode]; - if (embeddedBitmap) { - console.log("🖼️ Loaded from embedded bundle:", code); - paintings[code] = embeddedBitmap; - return; - } - } - - // Level 4: Fetch from network - // console.log("🖼️ Prefetching from network...", code); - paintings[code] = "fetching"; - - const cacheAndStore = async (img) => { - paintings[code] = img; - // Only cache images from aesthetic.computer media backend - if (imageCache.isCacheable(code)) { - await imageCache.set(code, img); - } - }; - - if (actualCode.startsWith("http")) { - $commonApi.get - .picture(actualCode) - .then(({ img }) => cacheAndStore(img)) - .catch(() => { - delete paintings[code]; - }); - } else if (actualCode.includes("/")) { - // Format: @handle/timestamp or handle/timestamp - const [author, paintingSlug] = actualCode.split("/"); - $commonApi.get - .painting(paintingSlug) - .by(author) - .then(({ img }) => cacheAndStore(img)) - .catch(() => { - delete paintings[code]; - }); - } else if (actualCode.match(/^\d{4}\.\d{2}\.\d{2}\.\d{2}\.\d{2}\.\d{2}\.\d{3}$/)) { - // Timestamp format without handle (e.g., "2025.11.01.23.55.14.266") - // This shouldn't happen - timestamps need a handle - console.warn(`⚠️ Painting timestamp without handle: ${actualCode}`); - delete paintings[code]; - } else if (actualCode.length === 3 && actualCode.match(/^[a-zA-Z0-9]{3}$/)) { - // 3-letter code format (e.g., "mzc", "qkm") - // Need to look up the handle/slug first via the painting-code API - try { - const response = await fetch(`/api/painting-code?code=${actualCode}`); - if (!response.ok) { - throw new Error(`Painting code not found: ${actualCode}`); - } - const data = await response.json(); - - // Now load using handle/slug - const handle = data.handle || 'anon'; - $commonApi.get - .painting(data.slug) - .by(handle) - .then(({ img }) => cacheAndStore(img)) - .catch(() => { - delete paintings[code]; - }); - } catch (err) { - console.error(`Failed to lookup painting code ${actualCode}:`, err); - delete paintings[code]; - } - } else { - // Unknown format - try as direct code anyway - $commonApi.get - .painting(actualCode) - .by() // No author needed - endpoint resolves it - .then(({ img }) => cacheAndStore(img)) - .catch(() => { - delete paintings[code]; - }); - } -} - -const $paintApiUnwrapped = { - // Turtle graphics: 🐢 crawl, left, right, up, down, goto, face - // Move the turtle forward based on angle, wrapping at screen edges. - crawl: (steps = 1) => { - const w = screen.width, h = screen.height; - const x2 = turtlePosition.x + steps * cos(num.radians(turtleAngle)); - const y2 = turtlePosition.y + steps * sin(num.radians(turtleAngle)); - const wx2 = ((x2 % w) + w) % w; - const wy2 = ((y2 % h) + h) % h; - // Only draw if we didn't cross a screen edge (avoids diagonal wrap lines). - const wrapped = Math.abs(wx2 - x2) > 0.5 || Math.abs(wy2 - y2) > 0.5; - if (turtleDown && !wrapped) { - graph.line(turtlePosition.x, turtlePosition.y, wx2, wy2); - } - turtlePosition.x = wx2; - turtlePosition.y = wy2; - return { x: turtlePosition.x, y: turtlePosition.y }; - }, - // Turn turtle left n degrees. - left: (d = 1) => { - turtleAngle = normalizeAngle(turtleAngle - d); - return turtleAngle; - }, - // Turn turtle right n degrees. - right: (d = 1) => { - turtleAngle = normalizeAngle(turtleAngle + d); - return turtleAngle; - }, - // Turtle pen up. - up: () => { - turtleDown = false; - // console.log("🐢 Up"); - }, - // Turtle pen down. - down: () => { - turtleDown = true; - // console.log("🐢 Down"); - }, - // Teleport the turtle position (wraps to screen bounds). - goto: (x = screen.width / 2, y = screen.height / 2) => { - const w = screen.width, h = screen.height; - const wx = ((x % w) + w) % w; - const wy = ((y % h) + h) % h; - if (turtleDown) { - graph.line(turtlePosition.x, turtlePosition.y, wx, wy); - } - turtlePosition.x = wx; - turtlePosition.y = wy; - return { x: turtlePosition.x, y: turtlePosition.y }; - }, - face: (angle = 0) => { - turtleAngle = normalizeAngle(angle); - return turtleAngle; - }, - // Shortcuts - // l: graph.line, - // i: ink, - // Defaults - blend: graph.blendMode, - setEraseTarget: graph.setEraseTarget, - page: function () { - if (arguments[0]?.api) { - // console.log("New paint api?", arguments[0].api); - // $activePaintApi = arguments[0].api; - } - - const buf = arguments[0]; - - // If this is the main screen object (by identity), restore its true state. - // Brushes like smear call page(system.painting) which corrupts screen.pixels/ - // width/height. When they later call page(screen), we recognise the original - // screen object and restore from the frame-start snapshot so subsequent code - // (including other brushes and the nopaint pre-processing restore at line ~12788) - // sees correct dimensions immediately rather than at end-of-frame. - if (buf === _mainScreenObject && _trueScreenPixels) { - buf.pixels = _trueScreenPixels; - buf.width = _trueScreenWidth; - buf.height = _trueScreenHeight; - } else { - // Switching to a sub-buffer: propagate its dimensions into screen so that - // code reading screen.width/height while on this buffer gets correct values. - $activePaintApi.screen.width = buf.width; - $activePaintApi.screen.height = buf.height; - $activePaintApi.screen.pixels = buf.pixels; - } - - graph.setBuffer(buf); - }, - edit: graph.changePixels, // Edit pixels by pasing a callback. - // Color - ink: function () { - const out = ink(...arguments); - twoDCommands.push(["ink", ...out]); - }, - ink2: function () { - const out = ink2(...arguments); - twoDCommands.push(["ink2", ...(out || [])]); - }, - // inkrn: () => graph.c.slice(), // Get current inkColor. - // 2D - wipe: function () { - const cc = graph.c.slice(0); - - // Preserve fade alpha during wipe operations to prevent clearing it - const preserveFadeAlpha = getPreserveFadeAlpha?.() || false; - if (!preserveFadeAlpha && typeof setPreserveFadeAlpha === 'function') { - setPreserveFadeAlpha(true); - } - - // Default to white if no arguments provided - if (arguments.length === 0) { - ink(255, 255, 255); - } else { - ink(...arguments); - } - - // 🎨 WebGPU clear if enabled - if ($commonApi.webgpu.enabled) { - send({ - type: "webgpu-command", - content: { - type: "clear", - color: graph.c.slice(0) - } - }); - ink(...cc); - if (!preserveFadeAlpha && typeof setPreserveFadeAlpha === 'function') { - setPreserveFadeAlpha(false); - } - return; - } - - // 🚀 Use optimized clear() which writes directly to pixels array - // clear() already handles fade colors and uses copyWithin doubling for solid colors - // This is much faster than box() which goes through plot/blend per pixel - graph.clear(); - - twoDCommands.push(["wipe", ...graph.c]); - ink(...cc); - - // Restore previous preservation state - if (!preserveFadeAlpha && typeof setPreserveFadeAlpha === 'function') { - setPreserveFadeAlpha(false); - } - - // 🍞 LAYER 0: Also clear layer 0 if it exists (for KidLisp) - // This ensures wipe() clears the persistent layer, not just the screen - if (this.kidlispInstance?.layer0) { - this.kidlispInstance.layer0.pixels.fill(0); - } - }, - // Set background fill color for reframe operations (especially for KidLisp pieces) - backgroundFill: function (color) { - // This function should fill transparent areas with the background color - // without clearing existing painted content. - // - // For now, we use a simple approach: only do a full wipe during initial boot, - // but skip it during reframe to preserve content. - - const cc = graph.c.slice(0); // Save current ink color - - if (arguments.length === 0) { - ink(255, 255, 255); // Default to white - } else { - ink(...arguments); - } - - // TODO: Implement proper transparent-area-only filling - // For now, always do a full clear - graph.clear(); - twoDCommands.push(["backgroundFill", ...graph.c]); - ink(...cc); // Restore previous ink color - }, - // Erase the screen. - clear: function () { - const cc = graph.c.slice(0); - ink(0, 0); - graph.clear(); - ink(...cc); - }, - copy: graph.copy, - paste: function paste() { - if (typeof arguments[0] === "string") { - // Check to see if the bitmap has been cached by this piece already. - const code = arguments[0]; - if (paintings[code] && paintings[code] !== "fetching") { - graph.paste(paintings[code], ...[...arguments].slice(1)); - } else if (paintings[code] !== "fetching") { - prefetchPicture(code); - } - } else { - graph.paste(...arguments); - } - }, - // Similar to paste, but always draws from the center of x, y. - // Has partial support for {center, bottom}. 24.02.15.12.19 - stamp: function stamp() { - let params; - // Parse the parameters and lay out the stamp. - function makeLayout() { - if (typeof params[0] === "object") { - const layout = params[0]; - if (layout.center === "x") { - params[0] = $activePaintApi.screen.width / 2; - } else { - params[0] = 0; - } - if (layout.bottom !== undefined) { - params[1] = - $activePaintApi.screen.height - - layout.bottom - - paintings[code].height / 2; - } else { - params[1] = 0; - } - } - } - if (typeof arguments[0] === "string") { - // Check to see if the bitmap has been cached by this piece already. - const code = arguments[0]; - params = [...arguments].slice(1); - if (paintings[code] && paintings[code] !== "fetching") { - makeLayout(); - graph.stamp(paintings[code], ...params); - } else if (paintings[code] !== "fetching") { - prefetchPicture(code); - } - } else { - params = [...arguments].slice(1); - if (params.length === 0) params = [0, 0]; - makeLayout(); - graph.stamp(arguments[0], ...params); - } - }, - pixel: graph.pixel, - plot: function () { - if (arguments.length === 1) { - graph.plot(arguments[0].x, arguments[0].y); - } else { - graph.plot(...arguments); - } - }, // TODO: Should this be renamed to set? - flood: graph.flood, - compositeLayers: graph.compositeLayers, // GPU-accelerated multi-layer compositing - batchedEffects: graph.batchedEffects, // GPU-accelerated batched effects (zoom+scroll+contrast+brightness in one pass) - point: function () { - const out = graph.point(...arguments); - twoDCommands.push(["point", ...out]); - }, - line: function() { - // 🎨 WebGPU line if enabled - if ($commonApi.webgpu.enabled && arguments.length >= 4) { - const [x1, y1, x2, y2] = arguments; - const color = graph.c.slice(0); - send({ - type: "webgpu-command", - content: { - type: "line", - x1, y1, x2, y2, - color - } - }); - return; - } - return graph.line(...arguments); - }, - lineAngle: graph.lineAngle, - pline: graph.pline, - // Set the alpha of every non-transparent pixel in a buffer to a uniform value. - // Useful for per-stroke alpha — must be called after drawing on the buffer. - setBufferAlpha: function (buffer, alpha) { - if (!buffer || !buffer.pixels) return; - const target = Math.round(alpha * 255); - const px = buffer.pixels; - for (let i = 3; i < px.length; i += 4) { - if (px[i] > 0) px[i] = target; - } - }, - pppline: graph.pixelPerfectPolyline, - oval: graph.oval, - circle: graph.circle, - pie: graph.pie, - tri: graph.tri, - poly: graph.poly, - box: graph.box, - shape: graph.shape, - grid: graph.grid, - draw: graph.draw, - setShowClippedWireframes: graph.setShowClippedWireframes, - clearWireframeBuffer: graph.clearWireframeBuffer, - drawBufferedWireframes: graph.drawBufferedWireframes, - getRenderStats: graph.getRenderStats, - printLine: graph.printLine, // TODO: This is kind of ugly and I need a state machine for type. - form, - pan: graph.pan, - unpan: graph.unpan, - savepan: graph.savepan, - loadpan: graph.loadpan, - mask: graph.mask, - unmask: graph.unmask, - steal: graph.steal, - putback: graph.putback, skip: graph.skip, - scroll: graph.scroll, - flip: graph.flip, - spin: graph.spin, - sort: graph.sort, - zoom: graph.zoom, - suck: graph.suck, - blur: function(radius = 1) { - // 🔧 FIX: Ensure blur operates on current buffer context - // When called from within a painting() context, the graph module - // should already have the correct buffer set via setBuffer() - return graph.blur(radius); - }, - sharpen: function(strength = 1) { - // Apply sharpening filter to enhance edges and details - // When called from within a painting() context, the graph module - // should already have the correct buffer set via setBuffer() - return graph.sharpen(strength); - }, - invert: function() { - // Invert RGB colors (255 - value) while preserving alpha - // When called from within a painting() context, the graph module - // should already have the correct buffer set via setBuffer() - return graph.invert(); - }, - contrast: graph.contrast, - shear: graph.shear, - resetScrollState: function() { - // Reset scroll accumulator - used by burn to ensure clean state - return graph.resetScrollState(); - }, - noise16: graph.noise16, - noise16DIGITPAIN: graph.noise16DIGITPAIN, - noise16Aesthetic: graph.noise16Aesthetic, - noise16Sotce: graph.noise16Sotce, - noiseTinted: graph.noiseTinted, - // 🎨 Alpha-blended paste for crossfade compositing - pasteWithAlpha: function pasteWithAlpha(source, x, y, alpha) { - if (!source || !source.pixels || alpha <= 0) return; - const dst = $activePaintApi.screen; - if (!dst || !dst.pixels) return; - const srcPixels = source.pixels; - const dstPixels = dst.pixels; - const sw = source.width, sh = source.height; - const dw = dst.width, dh = dst.height; - if (alpha >= 255) { - $activePaintApi.paste(source, x, y); - return; - } - const alphaFactor = alpha / 255.0; - for (let sy = 0; sy < sh; sy++) { - const dy = sy + y; - if (dy < 0 || dy >= dh) continue; - for (let sx = 0; sx < sw; sx++) { - const dx = sx + x; - if (dx < 0 || dx >= dw) continue; - const si = (sy * sw + sx) * 4; - const di = (dy * dw + dx) * 4; - let srcA = srcPixels[si + 3]; - if (srcA === 0 && (srcPixels[si] || srcPixels[si + 1] || srcPixels[si + 2])) { - srcA = 255; // Promote opaque RGB with zero alpha - } - if (srcA === 0) continue; - const ea = (srcA * alphaFactor + 1) | 0; - const inv = 256 - ea; - dstPixels[di] = (ea * srcPixels[si] + inv * dstPixels[di]) >> 8; - dstPixels[di + 1] = (ea * srcPixels[si + 1] + inv * dstPixels[di + 1]) >> 8; - dstPixels[di + 2] = (ea * srcPixels[si + 2] + inv * dstPixels[di + 2]) >> 8; - dstPixels[di + 3] = Math.min(255, dstPixels[di + 3] + ea); - } - } - }, - // 🎯 Simplified KidLisp integration using global singleton instance - kidlisp: function kidlisp(x = 0, y = 0, width, height, source, options = {}) { - // Initialize global instance if needed - if (!globalKidLispInstance) { - initializeGlobalKidLisp($activePaintApi); - } - - // Default dimensions to screen size if not provided - if (!width) width = $activePaintApi.screen.width; - if (!height) height = $activePaintApi.screen.height; - - // console.log(`🎯 Simple kidlisp call: ${width}x${height} at (${x},${y})`); - - // Extract options - const { noCache = false, accumulate: forceAccumulate = false, noPaste = false } = options; - - try { - // Initialize persistent paintings cache if needed - if (!globalKidLispInstance.persistentPaintings) { - globalKidLispInstance.persistentPaintings = new Map(); - } - - // For dollar codes, check if we already have a resolved version cached - let resolvedSource = source; - if (source && source.startsWith && source.startsWith('$') && source.length > 1) { - const cacheId = source.slice(1); - - // Use a singleton-specific cache to avoid conflicts with normal prompt loading - if (!globalKidLispInstance.singletonDollarCodeCache) { - globalKidLispInstance.singletonDollarCodeCache = new Map(); - } - - // Track loading states to prevent concurrent requests - if (!globalKidLispInstance.loadingDollarCodes) { - globalKidLispInstance.loadingDollarCodes = new Set(); - } - - // First check if we have a painting with the resolved source already - if (globalKidLispInstance.singletonDollarCodeCache.has(cacheId)) { - resolvedSource = globalKidLispInstance.singletonDollarCodeCache.get(cacheId); - // Note: regionKey will be set later after accumulation detection - - // If we already have this resolved painting, we'll check after regionKey is set - // console.log(`🎯 Using cached source: ${resolvedSource}`); - } else if (globalKidLispInstance.loadingDollarCodes.has(cacheId)) { - // Already loading this dollar code, wait for it to complete - // console.log(`🎯 ${source} already loading, waiting...`); - return null; - } else { - // Start loading and mark as loading - console.log(`🎯 Loading ${source} for first time...`); - globalKidLispInstance.loadingDollarCodes.add(cacheId); - - getCachedCodeMultiLevel(cacheId).then(loadedSource => { - if (loadedSource) { - console.log(`🎯 ✅ Loaded source for ${cacheId}: ${loadedSource.length} chars`); - globalKidLispInstance.singletonDollarCodeCache.set(cacheId, loadedSource); - } else { - console.warn(`❌ Could not load source for ${cacheId}`); - } - }).catch(error => { - console.error(`❌ Error loading ${cacheId}:`, error); - }).finally(() => { - // Remove from loading set when done (success or failure) - console.log(`🎯 Finished loading attempt for ${cacheId}`); - globalKidLispInstance.loadingDollarCodes.delete(cacheId); - }); - - // Return early - no painting this frame, wait for cache - return null; - } - } - - // Guard: if no resolved source, bail early (e.g., disk-based piece, not raw KidLisp) - if (!resolvedSource || typeof resolvedSource !== "string") { - return null; - } - - // 🎨 AUTO-DETECT ACCUMULATION: Check if source starts with a color word or forced via option - const colorWords = ['red', 'blue', 'green', 'yellow', 'purple', 'orange', 'cyan', 'magenta', 'black', 'white', 'gray', 'grey', 'brown', 'pink']; - const firstWord = resolvedSource.trim().split(/\s+/)[0]?.toLowerCase(); - const startsWithColor = colorWords.includes(firstWord); - - // Auto-generate accumulation settings (can be forced via option) - const accumulate = forceAccumulate || startsWithColor; - const accumulateKey = accumulate ? `auto_${x}_${y}_${width}_${height}_${firstWord}` : null; - - // Create persistent painting key with accumulation support - let regionKey; - // Build a dimension-independent content key for finding previous paintings after resize - let contentKey; - if (accumulate && accumulateKey) { - regionKey = `${x},${y},${width},${height}:ACCUMULATE:${accumulateKey}`; - contentKey = `ACCUMULATE:auto_${x}_${y}_${firstWord}`; - } else { - regionKey = `${x},${y},${width},${height}:${resolvedSource}`; - contentKey = resolvedSource; - } - - // 🔄 RESIZE RESILIENCE: If no cached painting at current dimensions, - // look for a previous painting with the same source at different dimensions. - // This preserves animation continuity when the screen resizes. - let resizedPreviousPainting = null; - let resizedPreviousKey = null; - if (!globalKidLispInstance.persistentPaintings.has(regionKey)) { - for (const [key, val] of globalKidLispInstance.persistentPaintings) { - // Match keys that share the same content suffix but differ in dimensions - if (key !== regionKey && key.endsWith(`:${contentKey}`)) { - resizedPreviousPainting = val; - resizedPreviousKey = key; - break; - } - } - } - - // Check if the code contains frame-dependent randomization commands - const hasFrameDependentCommands = /\(\s*ink\s*\)|\(\s*color\s*\)|\(\s*rand\s*\)/.test(resolvedSource); - - // Check if we need to reset (resolved source contains 'wipe') - const shouldReset = resolvedSource.includes('wipe') && !accumulate; // Don't reset in accumulation mode - - // Check if source contains animation-related commands that need fresh execution - const animationCommands = ['rainbow', 'zebra', 'time', 'random', 'noise', 'clock', 'scroll', 'zoom', 'flip', 'contrast', 'fade']; - const hasTimingCommands = /\d+\.?\d*s\b/.test(resolvedSource); // Detect timing like "0.15s", "1s", etc. - const hasAnimationCommands = animationCommands.some(cmd => resolvedSource.includes(cmd)); - const isDollarCode = source && source.startsWith && source.startsWith('$'); // Dollar codes should always refresh - // Don't force fresh execution for timing commands - they need to run continuously - // In accumulate mode, prefer building on existing surface unless explicitly forced - const needsFreshExecution = !accumulate && (noCache || (isDollarCode && !hasTimingCommands) || (hasAnimationCommands && !hasTimingCommands)); - - let painting; - - // Create fresh painting if: reset needed, no cached version exists, or animation commands need updating - if (shouldReset || needsFreshExecution || !globalKidLispInstance.persistentPaintings.has(regionKey)) { - // Create fresh painting (first time, after wipe, or for animations) - const reason = shouldReset ? 'wipe command' : needsFreshExecution ? 'animation content' : 'first time'; - // console.log(`🎨 Creating fresh painting for key: ${regionKey.slice(0, 50)}... (${reason})`); - - // For animations, start with previous frame if available (unless wiping) - // Also check for resized previous painting from a different dimension - const previousPainting = !shouldReset && globalKidLispInstance.persistentPaintings.has(regionKey) - ? globalKidLispInstance.persistentPaintings.get(regionKey) - : (!shouldReset ? resizedPreviousPainting : null); - - // Clean up the old dimension key if we found a resized previous - if (resizedPreviousKey && resizedPreviousPainting) { - globalKidLispInstance.persistentPaintings.delete(resizedPreviousKey); - } - - painting = $activePaintApi.painting(width, height, (api) => { - // Paste previous frame to maintain animation continuity (including across resizes) - if (previousPainting && needsFreshExecution && !shouldReset) { - api.paste(previousPainting); - } - - globalKidLispInstance.setAPI(api); - - // Add basic timing support for KidLisp commands - if (!api.clock) { - api.clock = { time: () => new Date() }; - } - // Override zoom and scroll functions to implement actual effects in painting context - const originalZoom = api.zoom; - const originalScroll = api.scroll || $activePaintApi.scroll; - - api.zoom = (...args) => { - // console.log(`🔍 Zoom called in painting context: args=${JSON.stringify(args)}`); - // Just call the original zoom function - it will operate on the current buffer - if (originalZoom && typeof originalZoom === 'function') { - return originalZoom(...args); - } - }; - - api.scroll = (dx, dy) => { - // console.log(`📜 Scroll called in painting context: dx=${dx}, dy=${dy}`); - // Call the real scroll function from the main paint API - if (originalScroll && typeof originalScroll === 'function') { - return originalScroll(dx, dy); - } else if ($activePaintApi.scroll && typeof $activePaintApi.scroll === 'function') { - return $activePaintApi.scroll(dx, dy); - } - }; - // Add randomization support for ink() and other commands - if (!api.num) { - api.num = $activePaintApi.num || { - random: () => Math.random(), - randInt: (min, max) => Math.floor(Math.random() * (max - min + 1)) + min, - rainbow: num.rainbow, // Use the actual rainbow function from num.mjs - zebra: num.zebra // Use the actual zebra function from num.mjs - }; - } - // Add color support for proper ink randomization - if (!api.color) { - api.color = $activePaintApi.color || { - random: () => [ - Math.floor(Math.random() * 256), - Math.floor(Math.random() * 256), - Math.floor(Math.random() * 256) - ] - }; - } - - // 🎨 Add CSS color functions to make unquoted color words work - // Import CSS colors from num.mjs and add them as functions to the API - const cssColors = num.cssColors; - if (cssColors) { - Object.keys(cssColors).forEach(colorName => { - if (!api[colorName]) { - api[colorName] = () => cssColors[colorName]; - } - }); - } - - // Add rainbow and zebra as top-level functions for unquoted usage - if (!api.rainbow) { - api.rainbow = () => num.rainbow(); - } - if (!api.zebra) { - api.zebra = () => num.zebra(); - } - // Reset KidLisp color state to prevent cross-contamination between regions - globalKidLispInstance.currentInk = null; - - // Save original state before configuring execution mode - const originalInEmbedPhase = globalKidLispInstance.inEmbedPhase; - const originalIsNestedInstance = globalKidLispInstance.isNestedInstance; - const originalEmbeddedLayers = globalKidLispInstance.embeddedLayers; - const originalIsEmbeddedContext = globalKidLispInstance.isEmbeddedContext; - - // 🎯 CRITICAL: Set isEmbeddedContext=true so effects (zoom, blur, scroll, etc.) - // execute immediately instead of being deferred to postCompositeCommands - // This aligns embedded kidlisp() with the main KidLisp paint behavior - globalKidLispInstance.isEmbeddedContext = true; - - // Check if source contains timing expressions that need proper scheduling - const hasTimingExpressions = /\d+\.?\d*s(\.\.\.|!)?/.test(resolvedSource); - // Check if source contains scroll/zoom/flip that needs deferred execution - const hasScrollZoom = /\(\s*(scroll|zoom|flip)\s/.test(resolvedSource); - - - - if (hasTimingExpressions) { - // console.log(`🎯 Detected timing expressions, preserving normal execution flow`); - // Don't force immediate execution for timing expressions - globalKidLispInstance.embeddedLayers = null; // Still clear embedded layers - } else if (hasScrollZoom) { - // console.log(`🎯 Detected scroll/zoom/flip, allowing deferred execution`); - // Allow scroll/zoom/flip to use deferred execution but clear embedded layers - globalKidLispInstance.inEmbedPhase = false; // Allow deferring for scroll/zoom/flip - globalKidLispInstance.isNestedInstance = false; // Allow deferring for scroll/zoom/flip - globalKidLispInstance.embeddedLayers = null; // Clear any leftover embedded layers - } else { - // console.log(`🎯 No timing/scroll/zoom/flip detected, forcing immediate execution`); - // Force immediate execution mode for simplified kidlisp() calls without timing or scroll/zoom/flip - globalKidLispInstance.inEmbedPhase = true; // Prevent deferring - globalKidLispInstance.isNestedInstance = true; // Enable immediate execution - globalKidLispInstance.embeddedLayers = null; // Clear any leftover embedded layers - } - - executeLispCode(resolvedSource, api, false); // false = not accumulating, fresh painting - - // Restore original state to avoid interfering with other KidLisp operations - globalKidLispInstance.inEmbedPhase = originalInEmbedPhase; - globalKidLispInstance.isNestedInstance = originalIsNestedInstance; - globalKidLispInstance.embeddedLayers = originalEmbeddedLayers; - globalKidLispInstance.isEmbeddedContext = originalIsEmbeddedContext; - - globalKidLispInstance.setAPI($activePaintApi); - }); - // Always cache the painting for next frame continuity (even for animations) - globalKidLispInstance.persistentPaintings.set(regionKey, painting); - } else { - // Build on existing painting (accumulate effects) - const existingPainting = globalKidLispInstance.persistentPaintings.get(regionKey); - // console.log(`🎨 Accumulating on existing painting for key: ${regionKey.slice(0, 50)}...`); - - painting = $activePaintApi.painting(width, height, (api) => { - // Paste previous state first - api.paste(existingPainting); - // Then add new effects on top - globalKidLispInstance.setAPI(api); - - // Add basic timing support for KidLisp commands - if (!api.clock) { - api.clock = { time: () => new Date() }; - } - // Add scroll and zoom support for animations - const originalZoom = api.zoom; - const originalScroll = api.scroll || $activePaintApi.scroll; - - api.zoom = (...args) => { - // console.log(`🔍 Zoom called in accumulation context: args=${JSON.stringify(args)}`); - if (originalZoom && typeof originalZoom === 'function') { - return originalZoom(...args); - } - }; - - api.scroll = (dx, dy) => { - // console.log(`📜 Scroll called in accumulation context: dx=${dx}, dy=${dy}`); - if (originalScroll && typeof originalScroll === 'function') { - return originalScroll(dx, dy); - } else if ($activePaintApi.scroll && typeof $activePaintApi.scroll === 'function') { - return $activePaintApi.scroll(dx, dy); - } - }; - // Add randomization support for ink() and other commands - if (!api.num) { - api.num = $activePaintApi.num || { - random: () => Math.random(), - randInt: (min, max) => Math.floor(Math.random() * (max - min + 1)) + min, - rainbow: num.rainbow, // Use the actual rainbow function from num.mjs - zebra: num.zebra // Use the actual zebra function from num.mjs - }; - } - // Add color support for proper ink randomization - if (!api.color) { - api.color = $activePaintApi.color || { - random: () => [ - Math.floor(Math.random() * 256), - Math.floor(Math.random() * 256), - Math.floor(Math.random() * 256) - ] - }; - } - - // 🎨 Add CSS color functions to make unquoted color words work - // Import CSS colors from num.mjs and add them as functions to the API - const cssColors = num.cssColors; - if (cssColors) { - Object.keys(cssColors).forEach(colorName => { - if (!api[colorName]) { - api[colorName] = () => cssColors[colorName]; - } - }); - } - - // Add rainbow and zebra as top-level functions for unquoted usage - if (!api.rainbow) { - api.rainbow = () => num.rainbow(); - } - if (!api.zebra) { - api.zebra = () => num.zebra(); - } - // Reset KidLisp color state to prevent cross-contamination between regions - globalKidLispInstance.currentInk = null; - - // Save original state before configuring execution mode - const originalInEmbedPhase = globalKidLispInstance.inEmbedPhase; - const originalIsNestedInstance = globalKidLispInstance.isNestedInstance; - const originalEmbeddedLayers = globalKidLispInstance.embeddedLayers; - const originalIsEmbeddedContext = globalKidLispInstance.isEmbeddedContext; - - // 🎯 CRITICAL: Set isEmbeddedContext=true so effects (zoom, blur, scroll, etc.) - // execute immediately instead of being deferred to postCompositeCommands - // This aligns embedded kidlisp() with the main KidLisp paint behavior - globalKidLispInstance.isEmbeddedContext = true; - - // Check if source contains timing expressions that need proper scheduling - const hasTimingExpressions = /\d+\.?\d*s(\.\.\.|!)?/.test(resolvedSource); - // Check if source contains scroll/zoom/flip that needs deferred execution - const hasScrollZoom = /\(\s*(scroll|zoom|flip)\s/.test(resolvedSource); - - // Only log occasionally to reduce console spam - - - if (hasTimingExpressions) { - // console.log(`🎯 Detected timing expressions in accumulation, preserving normal execution flow`); - // Don't force immediate execution for timing expressions - globalKidLispInstance.embeddedLayers = null; // Still clear embedded layers - } else if (hasScrollZoom) { - // console.log(`🎯 Detected scroll/zoom/flip in accumulation, allowing deferred execution`); - // Allow scroll/zoom/flip to use deferred execution but clear embedded layers - globalKidLispInstance.inEmbedPhase = false; // Allow deferring for scroll/zoom/flip - globalKidLispInstance.isNestedInstance = false; // Allow deferring for scroll/zoom/flip - globalKidLispInstance.embeddedLayers = null; // Clear any leftover embedded layers - } else { - // console.log(`🎯 No timing/scroll/zoom/flip in accumulation, forcing immediate execution`); - // Force immediate execution mode for simplified kidlisp() calls without timing or scroll/zoom/flip - globalKidLispInstance.inEmbedPhase = true; // Prevent deferring - globalKidLispInstance.isNestedInstance = true; // Enable immediate execution - globalKidLispInstance.embeddedLayers = null; // Clear any leftover embedded layers - } - - executeLispCode(resolvedSource, api, true); // true = accumulating on existing painting - - // Restore original state to avoid interfering with other KidLisp operations - globalKidLispInstance.inEmbedPhase = originalInEmbedPhase; - globalKidLispInstance.isNestedInstance = originalIsNestedInstance; - globalKidLispInstance.embeddedLayers = originalEmbeddedLayers; - globalKidLispInstance.isEmbeddedContext = originalIsEmbeddedContext; - - globalKidLispInstance.setAPI($activePaintApi); - }); - // Update the cache with the new accumulated state (unless noCache is true or animation content) - if (!noCache || !needsFreshExecution) { - globalKidLispInstance.persistentPaintings.set(regionKey, painting); - } - } - - // Paste the painting to the specified location (unless noPaste is set) - if (!noPaste && $activePaintApi.paste && painting) { - // console.log(`🎯 Pasting painting to (${x},${y})`); - $activePaintApi.paste(painting, x, y); - } - - return painting; - - } catch (error) { - console.error("🚫 Simple KidLisp error:", error); - - // Draw error directly to main screen (skip in projection/embed mode like kidlisp.com) - if (!projectionMode) { - const originalInk = $activePaintApi.ink(); - $activePaintApi.ink(255, 0, 0); - if ($activePaintApi.write) { - $activePaintApi.write("KidLisp Error", x, y); - } - $activePaintApi.ink(originalInk); - } - - return null; - } - }, - // 🎵 Update KidLisp audio globals (accessible to all pieces) - updateKidLispAudio: updateKidLispAudio, - // glaze: ... -}; - -// 🎨 Expose wipe function globally for KidLisp reframe operations -if (typeof globalThis !== "undefined") { - globalThis.$paintApiUnwrapped = $paintApiUnwrapped; - globalThis.wipe = $paintApiUnwrapped.wipe; -} - -// Helper function to execute KidLisp code -function executeLispCode(source, api, isAccumulating = false) { - try { - // Check if trace is enabled (set by kidlisp.com visualization) - const shouldTrace = $commonApi?.kidlispEnableTrace; - if (shouldTrace) { - enableKidlispTrace(); - clearExecutionTrace(); - } else { - disableKidlispTrace(); - } - - // Parse and evaluate the source directly without going through module lifecycle - // console.log(`🔍 Parsing KidLisp source:`, source); - - // Clear previous first-line color state for fresh detection - - globalKidLispInstance.firstLineColor = null; - - // Store source for error location lookup - globalKidLispInstance.currentSource = source; - - globalKidLispInstance.parse(source); - // console.log(`🔍 Generated AST:`, globalKidLispInstance.ast); - - if (globalKidLispInstance.ast) { - // Detect first-line color but only apply it if not accumulating - globalKidLispInstance.detectFirstLineColor(); - - if (globalKidLispInstance.firstLineColor && !isAccumulating) { - // console.log(`🎨 Applying first-line color background: ${globalKidLispInstance.firstLineColor}`); - api.wipe(globalKidLispInstance.firstLineColor); - } - - // Execute the parsed AST using the main evaluate method - // console.log(`🔍 Executing AST using main evaluate method...`); - // console.log(`🔍 AST contains:`, globalKidLispInstance.ast.map(expr => Array.isArray(expr) ? expr[0] : expr)); - - // Set up proper timing environment by ensuring clock and frameCount are available - if (!api.clock) { - api.clock = { time: () => new Date() }; - } - - // Increment frame count for timing expressions - if (typeof globalKidLispInstance.frameCount !== 'number') { - globalKidLispInstance.frameCount = 0; - } - globalKidLispInstance.frameCount++; - - // Normal KidLisp evaluation (dollar codes already resolved) - const result = globalKidLispInstance.evaluate(globalKidLispInstance.ast, api, globalKidLispInstance.localEnv); - // console.log(`🔍 Evaluation result:`, result); - - // Post execution trace if enabled (only on first frame to avoid spam) - if (shouldTrace && globalKidLispInstance.frameCount === 1) { - postExecutionTrace(); - } - } else { - // console.log(`⚠️ No AST generated from source`); - } - } catch (evalError) { - console.error("🚫 KidLisp evaluation error:", evalError); - // Draw error indicator (skip in projection/embed mode like kidlisp.com) - if (!projectionMode) { - api.wipe(60, 0, 0); - api.ink(255, 255, 255); - if (api.write) { - api.write("KidLisp Eval Error", 5, 15); - } - } - } -} - -// TODO: Eventually restructure this a bit. 2021.12.16.16.0 -// Should global state like color and transform be stored here? - -let $activePaintApi; - -let paintingAPIid = 0n; - -const twoDCommands = []; -graph.twoD(twoDCommands); // Set a global for passing 2d commands here. - -class Painting { - #layers = []; - #layer = 0; - api = {}; - inkrn; - - // panrn; // In order for this feature to work, translation needs to be stored outside of graph / captured differently? - - constructor() { - Object.assign(this.api, $paintApi); - const p = this; - - p.api.index = paintingAPIid; - paintingAPIid += 1n; - - p.inkrn = graph.c.slice(); // Init global state machine read-outs. - p.pagern = graph.getBuffer(); - // p.panrn = graph.getPan(); - - // Filter for and then wrap every rendering behavior of $paintApi into a - // system to be deferred in groups, using layer. - // ⛓️ This wrapper also makes the paint API chainable. - - function globals(k, args) { - if (k === "page") p.pagern = args[0]; - // TODO: 😅 Add other state globals like line thickness? 23.1.25 - } - - for (const k in $paintApiUnwrapped) { - if (typeof $paintApiUnwrapped[k] === "function") { - // Wrap and then transfer to #api. - p.api[k] = function () { - if (k === "ink") { - $paintApiUnwrapped[k](...arguments); - p.inkrn = graph.c.slice(); - } else { - globals(k, arguments); // Keep track of global state, like page, via `pagern`. - } - // Create layer if necessary. - if (notArray(p.#layers[p.#layer])) p.#layers[p.#layer] = []; - const callArgs = arguments; - // Add each deferred paint api function to the layer, to be run - // all at once in `paint` on each frame update. - p.#layers[p.#layer].push([ - k, - () => { - if (k === "ink") { - $paintApiUnwrapped[k](...callArgs); - p.inkrn = graph.c.slice(); - } else { - globals(k, callArgs); // Update globals again on chainable calls. - $paintApiUnwrapped[k](...callArgs); - } - }, - ]); - return p.api; - }; - } - } - - // Allows grouping & composing painting order using an AofA (Array of Arrays). - // n: 0-n (Cannot be negative.) - // fun: A callback that contains $paintApi commands or any other code. - this.api.layer = function (n) { - p.#layer = n; - // TODO: ❤️‍🔥 Current layer needs to be set on each API state...! - return p.api; - }; - - // Creates a new pixel buffer with its own layering wrapper / context - // on top of the base painting API. - this.api.painting = function painting() { - const oldActivePaintApi = $activePaintApi; - const painting = new Painting(); - $activePaintApi = painting.api; - // Mock out the screen here using the arguments. - $activePaintApi.screen = { - width: arguments[0], - height: arguments[1], - // pix gets added in the makeBuffer... - }; - const pix = graph.makeBuffer(...arguments, painting, $activePaintApi); - $activePaintApi = oldActivePaintApi; - return pix; - }; - - this.api.pixel = function () { - return graph.pixel(...arguments); - }; - - this.api.inkrn = () => this.inkrn; // Return current ink color. - // this.api.panrn = graph.getPan; // Return current panTranslation. - this.api.pagern = () => this.pagern; // Return current page buffer. - - // This links to abstract, solitary graph functions that do not need - // to be wrapped or deferred for rendering. - // TODO: Maybe these functions should be under a graphics algorithms label? - this.api.abstract = { bresenham: graph.bresenham }; - } - - // Paints every layer. - //async paint(immediate = false) { - paint(immediate = false) { - for (let layer of this.#layers) { - layer ||= []; // Evaporate empty layers. - for (const paint of layer) { - // if (immediate) { - // console.log("Label:", paint[0]); - paint[1](); - // } else { - // await paint(); - // } - } - } - this.#layers.length = 0; - this.#layer = 0; - } -} - -const painting = new Painting(); - -let glazeAfterReframe; - -// *** Resolution *** -// Accepts width, height and gap either as numbers or as -// an object with those keys. -// -// Usage: resolution(64); -// resolution(320, 240); -// resolution(display); // "display" is a global object whose width -// and height matches the hardware display -// hosting aesthetic.computer. -let lastGap = 8; -$commonApi.resolution = function (width, height = width, gap = 8) { - if (typeof width === "object") { - const props = width; - height = props.height; - width = props.width || props.height; - gap = props.gap === 0 ? 0 : props.gap || 8; - } - - if (typeof width === "number" && typeof height === "number") { - width = round(width); - height = round(height); - } - - // Don't do anything if there is no change and no gap update. - if (screen.width === width && screen.height === height && gap === lastGap) - return; - - lastGap = gap; - - // width = width || currentDisplay.innerWidth; - // height = height || currentDisplay.innerHeight; - - // TODO: Paint anything that needs to be painted before resizing... - // TODO: Does this even work right now? - painting.paint(); - - if (width === undefined && height === undefined) { - // 1. Generate a new width and height. - width = round(currentDisplay.width / currentDisplay.subdivisions); - height = round(currentDisplay.height / currentDisplay.subdivisions); - // Build a reframe request that will be sent to the main thread, mirroring this. - reframe = { - width: undefined, - height: undefined, - gap, - }; - } else { - // 2. Manually set the width and height. - reframe = { width, height, gap }; - } - - // console.log( - // "🖼 Reframe to:", - // width, - // height, - // "from", - // screen.width, - // screen.height, - // ); - - // 3. Assign the generated or manual width and height. - const oldScreen = { - width: screen.width, - height: screen.height, - pixels: screen.pixels, - }; - - screen.width = width; - screen.height = height; - - // Reset / recreate the depth buffer. (This is only used for the 3D software renderer in `graph`) - graph.depthBuffer.length = screen.width * screen.height; - graph.depthBuffer.fill(Number.MAX_VALUE); - graph.writeBuffer.length = 0; //screen.width * screen.height; - // graph.writeBuffer.fill(Number.MAX_VALUE); - - screen.pixels = new Uint8ClampedArray(screen.width * screen.height * 4); - screen.pixels.fill(255); - - graph.setBuffer(screen); - graph.paste({ - painting: oldScreen, - crop: new geo.Box(0, 0, oldScreen.width, oldScreen.height), - }); - - // 🎨 Fill any new pixels with background color after screen expansion - if (width > oldScreen.width || height > oldScreen.height) { - const persistentColor = getPersistentFirstLineColor(); - console.log("🎨 Resolution function: Screen expansion detected"); - console.log("🎨 Resolution function: Old screen:", oldScreen.width, "x", oldScreen.height); - console.log("🎨 Resolution function: New screen:", width, "x", height); - console.log("🎨 Resolution function: Persistent color found:", persistentColor); - - let fillSpec = resolveBackgroundFillSpec(persistentColor); - if (!fillSpec && typeof globalKidLispInstance?.getBackgroundFillColor === "function") { - const fallbackColor = globalKidLispInstance.getBackgroundFillColor(); - console.log("🎨 Post-reframe: Fallback background candidate:", fallbackColor); - fillSpec = resolveBackgroundFillSpec(fallbackColor); - } - - if (fillSpec) { - if (fillSpec.type === "fade") { - console.log("🎨 Post-reframe: Applying fade background to expansions"); - fillExpandedWithFadePixels( - screen, - width, - height, - oldScreen.width, - oldScreen.height, - fillSpec.fadeInfo, - ); - } else { - console.log("🎨 Post-reframe: Filling expansions with solid color", fillSpec.rgba); - fillExpandedWithSolidPixels( - screen, - width, - height, - oldScreen.width, - oldScreen.height, - fillSpec.rgba, - ); - } - } else { - console.log("🎨 Post-reframe: No background fill spec available for expansion"); - } - } else { - console.log("🎨 Resolution function: No screen expansion needed"); - } -}; - -// Add new content to the DOM. -// (Requires `send`) -class Content { - nodes = []; - #id = 0; - - constructor() {} - - // Make a request to add new content to the DOM. - add(content) { - // if (debug) console.log("📃 Adding content:", content); - this.nodes.push({ id: this.#id }); - this.#id = this.nodes.length - 1; - send({ type: "content-create", content: { id: this.#id, content } }); - return this.nodes[this.nodes.length - 1]; - } - - remove() { - send({ type: "content-remove" }); - this.nodes = []; - this.#id = 0; - } - - receive({ id, response }) { - this.nodes[id].response = response; - } - - //update({ id, msg }) { - // send({ type: "content-update", content: { id, msg } }); - //} -} - -// 🔈 Sound - -// Microphone State (Audio Input) -class Microphone { - amplitude = 0; - waveform = []; - pitch = 0; - connected = false; // Flips to true on a callback message from `bios`. - recording = false; - recordingPromise; - permission = ""; - recordingBuffer = null; // Live recording buffer for preview - - // Note: can send `{monitor: true}` in `options` for audio feedback. - connect(options) { - send({ type: "microphone", content: options }); - return this; - } - - disconnect() { - send({ type: "microphone", content: { detach: true } }); - } - - poll() { - send({ type: "get-microphone-amplitude" }); - send({ type: "get-microphone-waveform" }); - send({ type: "get-microphone-pitch" }); - if (this.recording) { - send({ type: "get-microphone-recording-buffer" }); - } - } - - // Start recording. - rec() { - this.recording = true; - send({ type: "microphone-record" }); - } - - // Stop recording. - cut() { - const prom = new Promise((resolve, reject) => { - this.recordingPromise = { resolve, reject }; - }); - send({ type: "microphone-cut" }); - this.recording = false; - return prom; - } -} - -class Speaker { - waveforms = { left: [], right: [] }; - amplitudes = { left: [], right: [] }; - frequencies = { left: [], right: [] }; - beat = { detected: false, strength: 0, timestamp: 0 }; // Add beat detection data - - poll() { - send({ type: "get-waveforms" }); - send({ type: "get-amplitudes" }); - send({ type: "get-frequencies" }); - } -} - -let sound, - // soundClear, // Used by receivedBeat and defined in first frame update. - soundId = 0n, // Increment each sound / give it an id in the `bios`. - soundTime; // Used by `$sound.synth` for global timing. - -sound = { - bpm: undefined, - sounds: [], - bubbles: [], - kills: [], -}; - -const speaker = new Speaker(); -const microphone = new Microphone(); - -// 🎮 Game Boy Emulator API -const gameboy = { - frame: null, // Current frame data (Uint8ClampedArray) - width: 160, // Game Boy screen width - height: 144, // Game Boy screen height - romName: null, // Currently loaded ROM name - isPlaying: false // Whether emulator is running -}; - -// Resolve a user code (e.g. "ac25namuc") to a handle via the permahandle API. -// Returns the handle string (e.g. "jeffrey") or null if not a user code / not found. -async function resolveUserCode(slug) { - if (!slug || slug.length !== 9 || !slug.startsWith("ac")) return null; - if (!/^ac[0-9]{2}[a-z]{5}$/.test(slug)) return null; - try { - const res = await fetch(`/api/permahandle/${slug}`); - if (!res.ok) return null; - const data = await res.json(); - return data.handle || null; - } catch { - return null; - } -} - -// 2. ✔ Loading the disk. -let originalHost; -let firstLoad = true; - -let notice, noticeTimer, noticeColor, noticeOpts; // Renders a full-screen notice on piece-load if present. - -async function load( - parsed, // If parsed is not an object, then assume it's source code. - fromHistory = false, - alias = false, - devReload = false, - loadedCallback, - forceKidlisp = false, // Force interpretation as kidlisp even without prefix -) { - const loadFunctionStartTime = performance.now(); - diskTimings.loadStarted = Math.round(loadFunctionStartTime - diskTimingStart); - // Silent: load() started - - let fullUrl, source; - let params, - search, - colon, - hash, - path, - host = originalHost, - text, - slug, - clockShortcode = null; // Track original *code for URL display - - // 🕷️ SPIDER MODE: Always log what piece we're loading - if (typeof window !== 'undefined' && window.acSPIDER) { - console.log("🕷️ SPIDER: Starting load() with:", { - parsed, - fromHistory, - alias, - devReload, - forceKidlisp - }); - } - - // Check for stuck loading state before proceeding - checkLoadingTimeout(); - - if (loading === false) { - loading = true; - loadingStartTime = Date.now(); - } else { - // TODO: If the piece is different, then there should be a way to abort - // or ignore a current load. - console.warn( - "Coudn't load:", - parsed.path || parsed.name, - "(Already loading.)", - ); - return true; - } - - // Reload a previously sideloaded piece on subsequent loads. - if ( - !parsed.source && - store["publishable-piece"] && - parsed.piece === store["publishable-piece"].slug - ) { - parsed.source = store["publishable-piece"].source; - parsed.name = store["publishable-piece"].slug; - parsed.ext = store["publishable-piece"].ext; - } - - // 🕸️ Loading over the network from a parsed path object with no source code. - if (!parsed.source) { - params = parsed.params; - path = parsed.path; - search = parsed.search; - colon = parsed.colon; - hash = parsed.hash; - host = parsed.host; - slug = parsed.text; - - if (typeof window !== 'undefined' && window.acSPIDER) { - console.log("🕷️ SPIDER: Parsed slug/path:", { - slug, - path, - parsedText: parsed.text, - parsedPath: parsed.path, - hash, - params - }); - } - - // 👱 Route to the `profile` piece if we are just hitting an empty - // username. - if (slug.startsWith("@") && slug.indexOf("/") === -1) { - params = [slug, ...params]; // Rewrite all params for `@user` slug urls. - //slug = "profile"; // Go to `profile` instead of the `@user`. - const hiddenSlug = "profile"; - // Rewrite path to `profile`. - console.log("Profile Path:", path); - path = [...path.split("/").slice(0, -1), hiddenSlug].join("/"); - } - - // ⏰ Normalize *clock shortcodes to the clock piece and let clock.mjs fetch - // the stored melody by code. This avoids intercepting later in the loader - // and ensures the correct module is fetched. - if (slug && slug.startsWith("*") && slug.length > 1) { - clockShortcode = slug; // Preserve original *code for URL - const cacheId = slug.slice(1); - const clockParam = `*${cacheId}`; - params = [clockParam, ...(params || [])]; - slug = "clock"; - path = "aesthetic.computer/disks/clock"; - parsed.text = slug; - parsed.path = path; - parsed.params = params; - } - - // if (debug) console.log(debug ? "🟡 Development" : "🟢 Production"); - if (host === "") host = originalHost; - loadFailure = undefined; - host = host.replace(/\/$/, ""); // Remove any trailing slash from host. - // Note: This fixes a preview bug on teia.art. 2022.04.07.03.00 if (path === "") path = ROOT_PIECE; // Set bare path to what "/" maps to. - // if (path === firstPiece && params.length === 0) params = firstParams; - - // Check if the path already has a language extension and use it directly, otherwise default to .mjs - // Handle sandboxed environments where location.protocol might be "null:" - // For aesthetic.computer pieces, determine the correct server - const { protocol, hostname } = getSafeUrlParts(); - - // If we're loading an aesthetic.computer piece, choose the appropriate server - let baseUrl; - if (path.startsWith('aesthetic.computer/')) { - // Check if we're in OBJKT mode - use local bundled files - if (typeof window !== 'undefined' && window.acSPIDER) console.log("🕷️ getPackMode():", getPackMode(), "window.acPACK_MODE:", window.acPACK_MODE, "checkPackMode():", checkPackMode()); - if (getPackMode()) { - // In OBJKT mode, use relative paths to load bundled pieces - baseUrl = "."; - } else { - // Check if we're in a development environment (localhost with port) - baseUrl = getBuiltInPieceBaseUrl(); - } - } else { - baseUrl = `${protocol}//${hostname}`; - } - - if (typeof window !== 'undefined' && window.acSPIDER) console.log("�️ SPIDER MODE Debug:", { protocol, hostname, baseUrl, isSandboxed: isSandboxed(), path, isDevelopment: hostname === 'localhost' && typeof location !== 'undefined' && location.port }); - - // Check if path already includes the hostname to avoid double paths - let resolvedPath = path; - if (getPackMode() && path.startsWith('aesthetic.computer/')) { - // In OBJKT mode, keep the full path since files are bundled with directory structure - resolvedPath = path; - } else if (baseUrl === 'https://aesthetic.computer' && path.startsWith('aesthetic.computer/')) { - // Strip "aesthetic.computer/" prefix when using production domain to avoid doubling - resolvedPath = path.substring('aesthetic.computer/'.length); - } - - // if (debug) console.log("🔍 Debug path resolution:", { originalPath: path, resolvedPath, hostname, baseUrl }); - - if (path.endsWith('.lisp') || path.endsWith('.lua')) { - if (getPackMode()) { - // In OBJKT mode, use absolute path from iframe origin - fullUrl = "/" + resolvedPath + "?v=" + Date.now(); - } else { - fullUrl = baseUrl + "/" + resolvedPath + "?v=" + Date.now(); - } - } else { - if (getPackMode()) { - // In OBJKT mode, navigate up from lib directory to aesthetic.computer root, then to target - const relativePath = resolvedPath.startsWith('aesthetic.computer/') - ? resolvedPath.substring('aesthetic.computer/'.length) - : resolvedPath; - fullUrl = "../" + relativePath + ".mjs" + "?v=" + Date.now(); - } else { - fullUrl = baseUrl + "/" + resolvedPath + ".mjs" + "?v=" + Date.now(); - } - } - // The query param `v` busts the cache so that the environment is - // reset if a disk is re-entered while the system is running. - // Note: Hash fragments (#) don't bust browser cache, only query strings (?) do. - // if (debug) console.log("🕸", fullUrl); - } else { - // 📃 Loading with provided source code. - // This could either be JavaScript or LISP. - - if ( - devReload === true && - parsed.codeChannel && - parsed.codeChannel !== codeChannel - ) { - console.warn( - "🙅 Not reloading, code channel invalid:", - codeChannel || "N/A", - ); - return; - } - // console.log("📃 Loading from source:", JSON.stringify(parsed)); - // console.log("📃 Source content to run:", JSON.stringify(parsed.source)); - source = parsed.source; - params = parsed.params; - search = parsed.search; - colon = parsed.colon || []; - hash = parsed.hash; // tood: these probably don't work? 24.07.09.23.46 - host = parsed.host; - slug = parsed.name; // not 'text' for this. - - // ⏰ Normalize *clock shortcodes for source-based loads too. - if (slug && slug.startsWith("*") && slug.length > 1) { - clockShortcode = slug; // Preserve original *code for URL - const cacheId = slug.slice(1); - const clockParam = `*${cacheId}`; - params = [clockParam, ...(params || [])]; - slug = "clock"; - if (slug !== "(...)" && !path) path = "aesthetic.computer/disks/clock"; - parsed.name = slug; - parsed.path = path || "aesthetic.computer/disks/clock"; - parsed.params = params; - } - - // 🔍 Enable tracing for kidlisp.com visualization if requested - if (parsed.enableTrace) { - enableKidlispTrace(); - } else { - disableKidlispTrace(); - } - - if (slug !== "(...)") path = parsed.path; //"aesthetic.computer/disks/" + slug; - // 📓 Might need to fill in hash, path, or slug here. 23.06.24.18.49 - } - - let prefetches; // Will be acted on after `hotSwap`. - - // 🅱️ Load the piece. - - // const moduleLoadTime = performance.now(); - - let blobUrl, sourceCode, originalCode; - try { - // If this is a reload (with no source change) then just create a new - // blobURL off the old source. - // TODO: Cache piece code locally / in an intelligent way, - // and then receive socket updates when it changes on the server? - if ( - slug?.split("~")[0] === currentText?.split("~")[0] && - sourceCode == currentCode && - !devReload - ) { - const blob = new Blob([currentCode], { type: "application/javascript" }); - blobUrl = URL.createObjectURL(blob); - sourceCode = currentCode; - originalCode = sourceCode; - } else { - let sourceToRun; - let fetchStartTime = performance.now(); // Initialize timing at the start - - // 💾 Check if this is a cached kidlisp code (starts with $ followed by a cache ID) - if (slug && slug.startsWith("$") && slug.length > 1) { - const cacheId = slug.slice(1).split(":")[0]; // Remove $ prefix and any :fps colon params - - // Clear author/hits immediately to prevent stale data showing during load - currentHUDAuthor = null; - currentHUDHits = null; - - // First check if we have this in objktKidlispCodes (offline bundle) - const globalScope = (function () { - if (typeof window !== 'undefined') return window; - if (typeof globalThis !== 'undefined') return globalThis; - if (typeof global !== 'undefined') return global; - if (typeof self !== 'undefined') return self; - return {}; - })(); - - if (globalScope.objktKidlispCodes && globalScope.objktKidlispCodes[cacheId]) { - sourceToRun = globalScope.objktKidlispCodes[cacheId]; - currentOriginalCodeId = slug; - currentHUDAuthor = null; // No author info in offline bundles - currentHUDHits = null; - } else { - console.log("💾 Loading cached kidlisp code:", cacheId); - try { - console.log("🔍 Fetching cached code from API..."); - sourceToRun = await getCachedCodeMultiLevel(cacheId); - if (!sourceToRun) { - throw new Error(`Cached code not found: ${cacheId}`); - } - // Track the original $code identifier for sharing - currentOriginalCodeId = slug; // Keep the full $code format - console.log("✅ Successfully loaded cached code:", cacheId, `(${sourceToRun.length} chars)`); - - // 👤 Fetch author metadata in background (for logging only) - fetchKidlispMetadata(cacheId).then(meta => { - if (meta) { - // Note: author/hits are fetched but not displayed for KidLisp pieces - console.log(`👤 Author: ${meta.handle || 'anonymous'}, Hits: ${meta.hits}`); - } - }).catch(err => { - console.warn("⚠️ Failed to fetch KidLisp metadata:", err); - }); - } catch (error) { - console.error("❌ Failed to load cached code:", cacheId, error); - throw new Error(`Failed to load cached code: ${cacheId}`); - } - } - } else if (fullUrl) { - // 🎄 Check if piece code is cached (for merry preloading) - const pieceSlug = slug?.split("~")[0]; - const cachedPiece = pieceSlug ? pieceCodeCache.get(pieceSlug) : null; - - if (cachedPiece) { - console.log(`🎄 Using cached code for: ${pieceSlug}`); - sourceToRun = cachedPiece.code; - // Skip fetch, use cached code directly - } else { - // In OBJKT mode, try direct import first for .mjs files to avoid CSP issues - // Extract the filename from URL, handling ./ prefix and hash fragments - const urlWithoutHash = fullUrl.split('#')[0]; - const filename = urlWithoutHash.split('/').pop(); - - if (getPackMode() && filename.endsWith('.mjs')) { - // In OBJKT mode, skip dynamic import and use fetch directly since files are bundled locally - // Will proceed to fetch() below - } - - let response; - if (logs.loading) console.log("📥 Loading from url:", fullUrl); - // if (debug) console.log("🔍 Debug: Constructed fullUrl:", fullUrl); - - // Notify boot progress - const fetchStartTime = performance.now(); - send({ - type: "boot-log", - content: `fetching: ${path}` - }); - - // Use cache: 'no-store' to force fresh fetch, especially important for LAN IP access - // Retry on network failure (bad connections drop fetches) - { - const maxAttempts = 3; - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - response = await fetch(fullUrl, { cache: 'no-store' }); - break; - } catch (fetchErr) { - if (attempt >= maxAttempts) throw fetchErr; - console.warn(`🔄 Piece fetch attempt ${attempt}/${maxAttempts} failed, retrying in ${attempt}s...`); - await new Promise(r => setTimeout(r, 1000 * attempt)); - } - } - } - const fetchEndTime = performance.now(); - diskTimings.fetchComplete = Math.round(fetchEndTime - diskTimingStart); - // Silent: fetch completed - if (response.status === 404 || response.status === 403) { - const extension = path.endsWith('.lisp') - ? '.lisp' - : path.endsWith('.lua') - ? '.lua' - : '.mjs'; - // Handle sandboxed environments for anon URL construction - const { protocol } = getSafeUrlParts(); - const anonUrl = - protocol + - "//" + - "art.aesthetic.computer" + - "/" + - path.split("/").pop() + - extension + - "#" + - Date.now(); - if (logs.loading) - console.log("🧑‍🤝‍🧑 Attempting to load piece from anon url:", anonUrl); - response = await fetch(anonUrl, { cache: 'no-store' }); - if (response.status === 404 || response.status === 403) - throw new Error(response.status); - } - sourceToRun = await response.text(); - } - } else { - sourceToRun = source; - } - - // 🔥 Idea - // One should be able to drag a piece in, then be able to load the piece - // go back to the prompt, and return to it and it should still load - // the modded code! // Then refresh should be able to function as well? - // ⚠️ Detect if we are running `kidlisp` or JavaScript syntax. - // Note: This may not be the most reliable way to detect `kidlisp`? - // 🚗 Needs to know if the source was from a prompt with a lisp module. - // console.log("🔍 Checking if kidlisp source:", JSON.stringify(sourceToRun)); - if ( - sourceToRun.startsWith("(") || - sourceToRun.startsWith(";") || - forceKidlisp || - slug === "(...)" || - path === "(...)" || - (path && path.endsWith(".lisp")) || - (slug && slug.startsWith("$") && slug.length > 1) || // Cached codes are always kidlisp - // Pack mode KidLisp pieces - but NOT if acPACK_COLOPHON explicitly says isKidLisp: false - (typeof window !== 'undefined' && window.acPACK_PIECE && slug === window.acPACK_PIECE && - window.acPACK_COLOPHON?.piece?.isKidLisp !== false) - ) { - // Only use basic detection, not the broader isKidlispSource function - // which can incorrectly detect JavaScript as kidlisp, unless forceKidlisp is true - // or this came from parse function as kidlisp (slug/path === "(...)") - // Assume lisp. - // console.log( - // "🐍 Lisp piece detected (slug:", - // slug, - // "path:", - // path, - // "forceKidlisp:", - // forceKidlisp, - // ")", - // ); - sourceCode = sourceToRun; - originalCode = sourceCode; - - // 🔒 KidLisp pieces are always restricted (safe by design) - pieceMetadata = { - code: slug || "kidlisp", - trustLevel: "kidlisp", - anonymous: true - }; - - const isPackMode = - (typeof window !== "undefined" && window.acPACK_MODE) || - (typeof globalThis !== "undefined" && globalThis.acPACK_MODE); - if (!isPackMode) { - log.lisp.log("Initializing KidLisp piece..."); - } - - // Notify boot progress - const compileStartTime = performance.now(); - const fetchElapsed = Math.round(compileStartTime - fetchStartTime); - // console.log("📢 Disk sending boot-log: compiling kidlisp"); - // Ensure send is available before using it - if (typeof send === 'function') { - send({ - type: "boot-log", - content: `compiling kidlisp (fetch: ${fetchElapsed}ms)` - }); - } else { - log.lisp.warn("send function not available for boot-log"); - } - - // Initialize persistent cache for $codes (only needs to be done once) - initPersistentCache(store); - - // Initialize persistent image cache for paste/stamp (only needs to be done once) - imageCache.init(store); - - loadedModule = lisp.module(sourceToRun, path && path.endsWith(".lisp")); - - const compileEndTime = performance.now(); - const compileElapsed = Math.round(compileEndTime - compileStartTime); - diskTimings.compileComplete = Math.round(compileEndTime - diskTimingStart); - // Silent: KidLisp compiled - if (!isPackMode) { - log.lisp.success(`KidLisp module loaded (${compileElapsed}ms)`); - } - - // Notify boot progress - // console.log("📢 Disk sending boot-log: kidlisp compiled"); - send({ - type: "boot-log", - content: `kidlisp compiled (${compileElapsed}ms)` - }); - - // Send source file to boot canvas for display - send({ - type: "boot-file", - content: { filename: path, source: sourceCode.slice(0, 8000) } - }); - - if (devReload) { - store["publishable-piece"] = { - slug, - source: sourceToRun, - ext: "lisp", - }; - if (logs.loading) - console.log("💌 Publishable:", store["publishable-piece"]); - } - } else if ( - parsed?.ext === "lua" || - (path && path.endsWith(".lua")) || - (fullUrl && fullUrl.includes(".lua")) || - (sourceToRun.trim().startsWith("--") && - /(?:^|\n)\s*function\s+(setup|draw)\s*\(/.test(sourceToRun)) - ) { - sourceCode = sourceToRun; - originalCode = sourceCode; - pieceMetadata = { - code: slug || "l5", - trustLevel: "l5", - anonymous: true, - }; - - const compileStartTime = performance.now(); - const fetchElapsed = Math.round(compileStartTime - fetchStartTime); - send({ - type: "boot-log", - content: `compiling lua (fetch: ${fetchElapsed}ms)` - }); - - loadedModule = await l5.module(sourceToRun); - - const compileEndTime = performance.now(); - const compileElapsed = Math.round(compileEndTime - compileStartTime); - diskTimings.compileComplete = Math.round(compileEndTime - diskTimingStart); - - send({ - type: "boot-log", - content: `lua compiled (${compileElapsed}ms)` - }); - - send({ - type: "boot-file", - content: { filename: path, source: sourceCode.slice(0, 8000) } - }); - - if (devReload) { - store["publishable-piece"] = { - slug, - source: sourceToRun, - ext: "lua", - }; - if (logs.loading) - console.log("💌 Publishable:", store["publishable-piece"]); - } - } else { - if (devReload) { - store["publishable-piece"] = { slug, source: sourceToRun }; - if (logs.loading) - console.log("💌 Publishable:", store["publishable-piece"].slug); - } - - // Initialize persistent image cache for paste/stamp (also for .mjs pieces) - imageCache.init(store); - - // 🔒 Determine piece trust level - // Built-in disk pieces (aesthetic.computer/disks/*) are always trusted - const isBuiltInDisk = path && path.includes("aesthetic.computer/disks/"); - if (isBuiltInDisk) { - pieceMetadata = { code: slug || "system", trustLevel: "trusted", anonymous: false }; - } else if (slug && !devReload) { - try { - // Clear permissions from previous piece - if (pieceMetadata?.code) { - clearPermissions(pieceMetadata.code); - } - - pieceMetadata = await fetchPieceMetadata(slug); - if (logs.loading) { - console.log(`🔒 Piece metadata:`, { - code: pieceMetadata.code, - trustLevel: pieceMetadata.trustLevel, - anonymous: pieceMetadata.anonymous - }); - } - } catch (err) { - console.warn(`⚠️ Failed to fetch piece metadata, defaulting to untrusted:`, err); - pieceMetadata = { code: slug, trustLevel: "untrusted", anonymous: true }; - } - } else { - // Dev reload or no slug - default to untrusted - pieceMetadata = { code: slug || "unknown", trustLevel: "untrusted", anonymous: true }; - } - - originalCode = sourceToRun; - const updatedCode = updateCode(sourceToRun, host, debug); - - prefetches = updatedCode - .match(/"(@\w[\w.]*\/[^"]*)"/g) - ?.map((match) => match.slice(1, -1)); // for "@name/code". - - const blob = new Blob([updatedCode], { - type: "application/javascript", - }); - - blobUrl = URL.createObjectURL(blob); - - // Notify boot progress - const importStartTime = performance.now(); - const importFetchElapsed = Math.round(importStartTime - fetchStartTime); - // console.log("📢 Disk sending boot-log: importing module"); - send({ - type: "boot-log", - content: `importing module (fetch: ${importFetchElapsed}ms)` - }); - - sourceCode = updatedCode; - // Retry dynamic import on network failures (sub-dependency fetches can fail on bad connections) - { - const maxImportAttempts = 3; - for (let attempt = 1; attempt <= maxImportAttempts; attempt++) { - try { - loadedModule = await import(blobUrl); - break; - } catch (importErr) { - const isNetworkError = importErr.toString().includes("Failed to fetch"); - if (attempt < maxImportAttempts && isNetworkError) { - console.warn(`🔄 Module import attempt ${attempt}/${maxImportAttempts} failed, retrying in ${1.5 * attempt}s...`); - URL.revokeObjectURL(blobUrl); - blobUrl = URL.createObjectURL(new Blob([updatedCode], { type: "application/javascript" })); - await new Promise(r => setTimeout(r, 1500 * attempt)); - } else { - throw importErr; - } - } - } - } - - const importEndTime = performance.now(); - const importElapsed = Math.round(importEndTime - importStartTime); - diskTimings.compileComplete = Math.round(importEndTime - diskTimingStart); - // Silent: JS module imported - if (logs.loading) console.log(`✅ Module imported (${importElapsed}ms)`); - send({ - type: "boot-log", - content: `module imported (${importElapsed}ms)` - }); - - // Send source file to boot canvas for display - send({ - type: "boot-file", - content: { filename: path, source: sourceCode.slice(0, 8000) } // Limit size - }); - } - } - } catch (err) { - const moduleLoadErrorTime = performance.now(); - // console.log(`⏰ Module load error caught at ${moduleLoadErrorTime}ms`); - console.log("🟡 Error loading mjs module:", err); - - // Determine if this was a fetch failure (404/403) vs a module import/compile error. - // Only try language fallbacks if this was a missing .mjs path. - const isFetchError = err.message === "404" || err.message === "403"; - const isNetworkImportError = err.toString().includes("Failed to fetch dynamically imported module"); - const isModuleImportError = isNetworkImportError || - err.toString().includes("Unexpected token") || - err.toString().includes("SyntaxError") || - err.toString().includes("Cannot use import"); - - // If the .mjs file was fetched but failed to import (JS error), don't try fallbacks. - if (fullUrl && !fullUrl.includes('.lisp') && !fullUrl.includes('.lua') && isFetchError && !isModuleImportError) { - try { - const fallbackExts = [".lua", ".lisp"]; - let loadedFromFallback = false; - let fallbackError = null; - - for (const fallbackExt of fallbackExts) { - let fallbackUrl = fullUrl.replace(".mjs", fallbackExt); - if (logs.loading) { - console.log(`📥 Loading ${fallbackExt.slice(1)} from url:`, fallbackUrl); - } - - let response = await fetch(fallbackUrl, { cache: 'no-store' }); - - if (response.status === 404 || response.status === 403) { - // Handle sandboxed environments for anon URL construction - const { protocol } = getSafeUrlParts(); - const anonUrl = - protocol + - "//" + - "art.aesthetic.computer" + - "/" + - path.split("/").pop() + - fallbackExt + - "#" + - Date.now(); - if (logs.loading) { - console.log("🧑‍🤝‍🧑 Attempting to load piece from anon url:", anonUrl); - } - response = await fetch(anonUrl, { cache: 'no-store' }); - - if (response.status === 404 || response.status === 403) { - fallbackError = new Error(response.status); - continue; - } - } - - sourceCode = await response.text(); - originalCode = sourceCode; - - if (fallbackExt === ".lua") { - pieceMetadata = { - code: slug || "l5", - trustLevel: "l5", - anonymous: true, - }; - loadedModule = await l5.module(sourceCode); - if (devReload) { - store["publishable-piece"] = { slug, source: sourceCode, ext: "lua" }; - } - } else { - pieceMetadata = { - code: slug || "kidlisp", - trustLevel: "kidlisp", - anonymous: true - }; - loadedModule = lisp.module(sourceCode, true); - if (devReload) { - store["publishable-piece"] = { slug, source: sourceCode, ext: "lisp" }; - } - } - - loadedFromFallback = true; - break; - } - - if (!loadedFromFallback) { - throw fallbackError || new Error("404"); - } - - if (devReload && logs.loading) { - console.log("💌 Publishable:", store["publishable-piece"]); - } - } catch (err) { - // Continue with current module if one has already loaded. - console.error( - `😡 "${path}" load failure:`, - err, - "💾 First load:", - firstLoad, - ); - loadFailure = err; - $commonApi.net.loadFailureText = err.message + "\n" + sourceCode; - loading = false; - - // Only return a 404 if the error type is correct. - if (firstLoad && (err.message === "404" || err.message === "403")) { - // Check if slug is a user code (e.g. "ac25namuc") and redirect to their profile. - const handle = await resolveUserCode(slug); - if (handle) { - $commonApi.jump(`@${handle}`); - } else { - $commonApi.jump(`404~${slug}`); - } - } else { - $commonApi.notice(":(", ["red", "yellow"]); - } - return false; - } - } else { - // Module import/compile error - the .mjs file exists but has errors - // Don't try language fallbacks, just show the error - if (isNetworkImportError) { - console.error( - `😡 "${path}" module import failed (network error loading dependencies after ${3} retries):`, - err, - ); - } else if (isModuleImportError) { - console.error( - `😡 "${path}" module import failed (JS error in the piece):`, - err, - ); - } else { - console.error( - `😡 "${path}" load failure:`, - err, - "💾 First load:", - firstLoad, - ); - } - loadFailure = err; - $commonApi.net.loadFailureText = err.message + "\n" + (sourceCode || ""); - loading = false; - - // Only return a 404 if the error type is correct. - if (firstLoad && (err.message === "404" || err.message === "403")) { - // Check if slug is a user code (e.g. "ac25namuc") and redirect to their profile. - const handle = await resolveUserCode(slug); - if (handle) { - $commonApi.jump(`@${handle}`); - } else { - $commonApi.jump(`404~${slug}`); - } - } else { - $commonApi.notice(":(", ["red", "yellow"]); - } - return false; - } - } - - // console.log("Module load time:", performance.now() - moduleLoadTime, module); - // 🧨 Fail out if no module is found. - const moduleCheckTime = performance.now(); - // console.log(`⏰ Module check at ${moduleCheckTime}ms, module loaded: ${loadedModule !== undefined}`); - - if (loadedModule == null) { - loading = false; - leaving = false; - return false; - } - - // 🧩 Piece code has been loaded... - // Now we can instantiate the piece. - - pieceHistoryIndex += fromHistory === true ? 0 : 1; // Adjust the history. - - if (!debug && !firstLoad) { - // console.clear(); - headers($commonApi.dark); // Clear console and re-print headers if we are in production. - } - - // console.log("🧩", path, "🌐", host); - - $commonApi.net.devReload = devReload; // Expose to the piece if it was - // reloaded by the developer, for special logic per piece. - - // Add debug to the common api. - $commonApi.debug = debug; - - // Add reload to the common api. - $commonApi.reload = function reload({ - piece, - name, - source, - codeChannel, - createCode, - codeId, // The $code identifier (e.g., "inz") from kidlisp.com - authToken, // Auth token from kidlisp.com login - enableTrace, // Enable execution trace for kidlisp.com visualization - language, // Optional source language (e.g., "lua") - ext, // Optional source extension hint (e.g., "lua") - liveName, // Optional stable live piece name - } = {}) { - // console.log("⚠️ Reloading:", piece, name, source); - - // Check for global loading timeout first - if (checkLoadingTimeout()) { - console.log("🔴 Recovered from stuck loading state"); - } - - if (loading && source && !name && !piece) { - // If a piece is loading and we have new source code, queue the reload - // But only queue up to 10 times (1 second max wait), then force through - const queueCount = (reload._queueCount || 0) + 1; - reload._queueCount = queueCount; - - if (queueCount > 10) { - // Too many retries - force reset loading state and proceed - console.warn("🔴 Reload queue timeout - forcing loading reset"); - loading = false; - loadingStartTime = null; - reload._queueCount = 0; - } else { - console.log("🟡 Queueing reload until current load completes..."); - setTimeout( - () => - reload({ - source, - codeId, - createCode, - authToken, - enableTrace, - language, - ext, - liveName, - }), - 100, - ); - return; - } - } else { - // Reset queue counter when not queuing - reload._queueCount = 0; - } - - if (loading) { - console.log("🟡 A piece is already loading."); - return; - } - if (piece === "*refresh*") { - // padding: 8px; border-radius: 2px;console.log("💥️ Restarting system..."); - send({ type: "refresh" }); // Refresh the browser. - } else if (piece === "*piece-reload*") { - // console.log("🎨 Reloading current piece..."); - // Reload the current piece without refreshing the entire page - $commonApi.load( - { - path: currentPath, - host: currentHost, - search: currentSearch, - colon: currentColon, - params: currentParams, - hash: currentHash, - text: currentText, - }, - true, // fromHistory - don't add to history stack - alias, - true, // devReload - ); - } else if (source && !name && !piece) { - // Live reload with new source code (e.g., from kidlisp.com editor) - const isLuaReload = language === "lua" || ext === "lua"; - - // Just pass the source directly without path/text - currentText = isLuaReload ? (liveName || "l5-live") : source; - currentPath = isLuaReload ? (liveName || "l5-live") : source; - - if (isLuaReload) { - $commonApi.load( - { - source, - name: liveName || "l5-live", - ext: "lua", - search: currentSearch, - colon: currentColon, - params: currentParams, - hash: currentHash, - }, - true, // fromHistory - don't add to history stack - alias, - true, // devReload - ); - return; - } - - // Store the $code identifier for screenshots/sharing - if (codeId) { - currentOriginalCodeId = `$${codeId}`; - } - - // Store createCode flag globally so kidlisp can access it - if (createCode) { - $commonApi.kidlispCreateCode = true; - } else { - // console.log('⚠️ createCode flag is false or undefined:', createCode); - $commonApi.kidlispCreateCode = false; - } - - // Store auth token globally so kidlisp can use it for saving - if (authToken) { - $commonApi.kidlispAuthToken = authToken; - } - - // Store trace flag globally for kidlisp visualization - $commonApi.kidlispEnableTrace = enableTrace || false; - - // Reset frameCount so trace is posted on first frame of new code - if (globalKidLispInstance) { - globalKidLispInstance.frameCount = 0; - } - - $commonApi.load( - { - source: source, // Pass as source so it's used directly - name: "kidlisp-live", // Give it a name so slug won't be undefined - search: currentSearch, - colon: currentColon, - params: currentParams, - hash: currentHash, - enableTrace: enableTrace, // Pass trace flag - }, - true, // fromHistory - don't add to history stack - alias, - true, // devReload - undefined, // loadedCallback - true, // forceKidlisp - always treat as KidLisp code - ); - } else if (name && source) { - // TODO: Check for existence of `name` and `source` is hacky. 23.06.24.19.27 - // TODO: 🔥 This should somehow keep current commands or params, etc. - - // console.log( - // "🪷 Current: params:", - // currentParams, - // "text:", - // currentText, - // "path:", - // currentPath, - // ); - - // Note: This is used for live development via the socket server. - $commonApi.load({ source, name, codeChannel }, false, false, true); // Load source code. - } /*if (piece === "*" || piece === undefined /*|| currentText === piece*/ /*) {*/ else { - // console.log("💾️ Reloading:", piece, "Params:", currentParams); - // $commonApi.pieceCount = -1; // Reset pieceCount on developer reload. - // (This can be disabled while testing pieces - // that rely on pieceCount increments) - // ❤️‍🔥 TODO: Reloading the same piece should not effect piece count. - - $commonApi.load( - { - path: currentPath, - host: currentHost, - search: currentSearch, - colon: currentColon, - params: currentParams, - hash: currentHash, - text: currentText, - }, - // Use the existing contextual values when live-reloading in debug mode. - true, // (fromHistory) ... never add any reload to the history stack - alias, - true, // devReload - ); - } //else if (piece !== undefined) { - //console.log("Reloading pieceeeeeeee:", piece, parse(piece)); - //$commonApi.load(parse(piece), false, false, true); - //} - }; - - // Start the socket server - // TODO: Before we load the disk, in case of needing to reload remotely on failure? 23.01.27.12.48 - let receiver; // Handles incoming messages from the socket. - const forceProd = false; // For testing prod socket servers in development. - // TOOD: Hoist this to an environment variable? - - // Requests a session-backend and connects via websockets. - function startSocket() { - // Skip socket connections in OBJKT mode or SPIDER mode - if (getPackMode() || (typeof window !== 'undefined' && window.acSPIDER)) { - return; - } - - // 📡 Set up remote logging for LAN Dev mode - setupRemoteLogging(); - - if ( - //parsed.search?.startsWith("preview") || - //parsed.search?.startsWith("icon") - previewOrIconMode - ) { - console.log("🧦 Sockets disabled, just grabbing screenshots. 😃"); - return; - } - // Never open socket server in icon / preview mode. - // if (debug && logs.session) console.log("🫂 Finding session server..."); - socket = new Socket(debug, send); // Then redefine and make a new socket. - - const monolith = "monolith"; // "monolith"; // or `undefined` for horizontal scaling via - // jamstack - - session(slug, forceProd, monolith) - .then(async (sesh) => { - if (typeof sesh === "string") throw new Error(sesh); // Cancel if error. - const url = new URL(sesh.url); // Parse the url. - const udpUrl = new URL(sesh.udp); // Parse the udp url. - - // console.log("Session URL:", url); - - // 🩰 Fetch dev-info to get TURN host (for local dev with Docker) - // Only attempt this in dev mode (localhost) - not in production - let turnHost = null; - const isLocalDev = url.hostname === "localhost" || url.hostname.endsWith(".local"); - if (isLocalDev) { - try { - const devInfoRes = await fetch(`${sesh.url}/dev-info`); - if (devInfoRes.ok) { - const devInfo = await devInfoRes.json(); - if (devInfo.ip) { - turnHost = devInfo.ip; - log.socket.debug("Got TURN host from dev-info:", turnHost); - } - } - } catch (e) { - log.socket.debug("Could not fetch dev-info for TURN host:", e.message); - } - } - - // 🩰 UDP... (via `bios`) - // Use the protocol from the session response (http for localhost, https for prod) - log.socket.debug("Sending udp:connect to BIOS:", { - url: `${udpUrl.protocol}//${udpUrl.hostname}`, - port: udpUrl.port || (udpUrl.protocol === 'https:' ? '443' : '80'), - turnHost, - }); - send({ - type: "udp:connect", - content: { - url: `${udpUrl.protocol}//${udpUrl.hostname}`, - port: udpUrl.port || (udpUrl.protocol === 'https:' ? '443' : '80'), - turnHost, // Pass TURN host separately for ICE servers - }, - }); - - let slugBroadcastInterval; - - // 🕸️ Web Sockets - socket?.connect( - url.host + url.pathname, - (id, type, content) => { - // Globally receivable messages... - // (There are also some messages handled in `Socket`) - // 😱 Scream at everyone who is connected! - if (type === "scream" && socket?.id !== id) { - console.log("😱 Scream:", content, "❗"); - scream = content; - } - // 🎯 Jump to a specific piece! - if (type === "jump") { - $commonApi.jump(content.piece); - } - // 📱 Handle dev identity from session server (LAN Dev mode) - if (type === "dev:identity") { - devIdentity = content; - remoteLogSocket = socket; // Store socket reference for remote logging - flushRemoteLogQueue(); // Send any queued logs - $commonApi?.needsPaint?.(); - return; - } - // 🧩 Pieces get all other messages not caught in `Socket`. - receiver?.(id, type, content); // Run the piece receiver. - }, - $commonApi.reload, - "wss", - () => { - // 🔩 Connected! (Post-connection logic.) - if (USER) socket?.send("login", { user: USER }); - - // Broadcast current location. - if (HANDLE) { - // console.log("🗼 Broadcasting slug:", currentText, "for:", HANDLE); - socket?.send("location:broadcast", { - handle: HANDLE, - slug: currentText, - user: USER, // Include user for identity resolution - }); - slugBroadcastInterval = setInterval(() => { - socket?.send("location:broadcast", { - handle: HANDLE, - slug: "*keep-alive*", - user: USER, // Include user for identity resolution - }); - }, 2500); - } - - // Subscribe to code-channel as needed. - if (codeChannel) socket?.send("code-channel:sub", codeChannel); - - updateHUDStatus(); - $commonApi.needsPaint(); - codeChannelAutoLoader?.(); - // setTimeout(function () { - // currentHUDStatusColor = undefined; - // }, 250); - }, - () => { - // 💔 Disconnected! (Post-disconnection logic.) - updateHUDStatus(); - clearInterval(slugBroadcastInterval); - }, - ); - }) - .catch((err) => { - console.error("Session connection error:", err); - }); - } - - // End the socket connection before switching pieces if one exists. - // socket?.kill(); - // udp?.kill(); - // socket = undefined; - - // Delay session server by .75 seconds in order to prevent redundant - // connections being opened as pieces are quickly re-routing and jumping. - clearTimeout(socketStartDelay); - socket?.kill(); // Kill any already open socket from a previous disk. - udp?.kill(); - socket = undefined; - socketStartDelay = setTimeout(() => startSocket(), 250); - - $commonApi.net.socket = function (receive) { - receiver = receive || (() => {}); - if (!socket) { - // Just in case we init. in a `boot` before the timeout fires above. - clearTimeout(socketStartDelay); - startSocket(); - } else { - // Return the server then send an already connected message. - setTimeout(() => { - if (socket?.id) receiver(socket.id, "connected:already"); - }, 10); - } - return socket; - }; - - // ***Client Metadata Fields*** - // Set default metadata fields for SEO and sharing, - // (requires serverside prerendering, also via `index.js`). - let meta; - - if (alias === false) { - // Parse any special piece metadata. - - // Check if we're in OBJKT mode and have colophon data - let objktContext = null; - if (checkPackMode() && typeof window !== 'undefined' && window.acOBJKT_COLOPHON) { - objktContext = { author: window.acOBJKT_COLOPHON.build.author }; - } - - // Use parsed.piece for the title (just the piece name, not full slug with params) - const titleSlug = parsed.piece || slug; - - const { title, desc, ogImage, twitterImage, icon } = metadata( - location.host, // "aesthetic.computer", - titleSlug, - loadedModule.meta?.({ - ...parsed, - num: $commonApi.num, - store: $commonApi.store, - }) || inferTitleDesc(originalCode), - location.protocol, // Pass the current protocol - objktContext // Pass OBJKT context if available - ); - - meta = { - title, - desc, // Note: This doesn't auto-update externally hosted module descriptions, and may never need to? 22.07.19.06.00 - img: { - og: ogImage, - twitter: twitterImage, - icon, - }, - url: "https://aesthetic.computer/" + slug, - }; - } - - // Add meta to the common api so the data can be overridden as needed. - $commonApi.meta = (data) => send({ type: "meta", content: data }); - - // Rewrite a new URL / parameter path without affecting the history. - $commonApi.net.rewrite = (path, historical = false) => { - if (historical) $commonApi.history.push(path); - send({ type: "rewrite-url-path", content: { path, historical } }); // Jump the browser to a new url. - }; - - // Add host to the networking api. - $commonApi.net.host = host; - - // Jump the browser to a new url. - $commonApi.net.web = (url, jumpOut) => { - send({ type: "web", content: { url, blank: jumpOut } }); - }; - - $commonApi.net.refresh = () => { - send({ type: "refresh" }); - }; - - // Tell the system to wait until preloading is finished before painting. - $commonApi.net.waitForPreload = () => { - send({ type: "wait-for-preload", content: true }); - }; - - // Tell the system that all preloading is done. - $commonApi.net.preloaded = () => { - send({ type: "preload-ready", content: true }); - }; - - $commonApi.content = new Content(); - - $commonApi.dom = {}; - - $commonApi.dom.clear = () => { - $commonApi.content.remove(); - }; - - $commonApi.dom.html = (strings, ...vars) => { - const processed = defaultTemplateStringProcessor(strings, ...vars); - $commonApi.content.add(processed); - }; - - $commonApi.dom.css = (strings, ...vars) => { - const processed = defaultTemplateStringProcessor(strings, ...vars); - $commonApi.content.add(``); - }; - - $commonApi.dom.javascript = (strings, ...vars) => { - const processed = defaultTemplateStringProcessor(strings, ...vars); - $commonApi.content.add(``); - }; - // 💾 Uploading + Downloading - // Add download event to trigger a file download from the main thread. - $commonApi.download = (filename, data, modifiers) => { - send({ type: "download", content: { filename, data, modifiers } }); - }; - - // * Preload * - // Add preload to the boot api. - // Accepts paths local to the original disk server, full urls, and demos. - // Usage: preload("demo:drawings/2021.12.12.17.28.16.json") // pre-included - // preload("https://myserver.com/test.json") // remote - // preload("drawings/default.json") // hosted with disk - // Results: preload().then((r) => ...).catch((e) => ...) // via promise - - $commonApi.net.preload = async function ( - path, - parseJSON = true, - progressReport, - options = {}, - ) { - let extension; - - const rejection = (reject) => { - reject(new DOMException("Aborted", "AbortError")); - }; - - // 🎒 PACK mode: Check VFS for embedded files first (used for offline bundles) - const packModeActive = - (typeof window !== "undefined" && window.acPACK_MODE) || - (typeof globalThis !== "undefined" && globalThis.acPACK_MODE); - const packVfs = - (typeof window !== "undefined" && window.VFS) || - (typeof globalThis !== "undefined" && globalThis.VFS); - - if (packModeActive && packVfs) { - let vfsPath = typeof path === "object" ? path.path : path; - // Normalize path: strip leading slash and "aesthetic.computer/" prefix - if (vfsPath.startsWith('/')) vfsPath = vfsPath.slice(1); - if (vfsPath.startsWith('aesthetic.computer/')) vfsPath = vfsPath.slice('aesthetic.computer/'.length); - - const vfsEntry = packVfs[vfsPath]; - if (vfsEntry) { - const ext = typeof path === "object" ? path.extension : vfsPath.split(".").pop().split("?")[0]; - progressReport?.(1); // Report completion immediately for VFS files - - if (ext === "json") { - // JSON files in VFS: { content: "...", type: 'json' } or { content: "...", type: 'base64' } - let jsonStr; - if (typeof vfsEntry === 'object' && vfsEntry.content !== undefined) { - if (vfsEntry.type === 'base64') { - jsonStr = atob(vfsEntry.content); - } else { - jsonStr = vfsEntry.content; // type: 'json' stores content as string - } - } else if (typeof vfsEntry === 'string') { - jsonStr = vfsEntry; - } - if (jsonStr !== undefined) { - return parseJSON ? JSON.parse(jsonStr) : jsonStr; - } - } else if (ext === "xml") { - let xmlStr; - if (typeof vfsEntry === 'object' && vfsEntry.content !== undefined) { - xmlStr = vfsEntry.type === 'base64' ? atob(vfsEntry.content) : vfsEntry.content; - } else { - xmlStr = vfsEntry; - } - const parser = new DOMParser(); - return parser.parseFromString(xmlStr, "text/xml"); - } - // For other types, fall through to normal handling if not JSON/XML - } - } - - if (soundWhitelist.includes(path)) { - extension = "ogg"; - } else { - if (typeof path === "object") { - extension = path.extension; - path = path.path; - } else { - extension = path.split(".").pop().split("?")[0]; - } - - if (path.indexOf("/") === 0) path = path.slice(1); - - try { - const url = new URL(path); - if (url.protocol === "demo:") { - path = `/demo/${url.pathname}`; - } else if (url.protocol !== "https:") { - } - } catch { - // Handle sandboxed environments for path construction - const { protocol, hostname } = getSafeUrlParts(); - const originalPath = path; - - // Apply the same origin-aware logic as in module loading - let baseUrl; - if (path.startsWith('aesthetic.computer/')) { - // Check if we're in a development environment (localhost with port) - baseUrl = getBuiltInPieceBaseUrl(); - - // Only strip "aesthetic.computer/" if we're using the main production domain - if (baseUrl === 'https://aesthetic.computer') { - path = path.substring('aesthetic.computer/'.length); - } - } else { - baseUrl = `${protocol}//${hostname}`; - } - - path = `${baseUrl}/${path}`; - - // Removed debug log for font glyph preload - } - } - - if (extension === "xml" || extension === undefined) { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - - options.signal?.addEventListener("abort", () => { - xhr.abort(); - rejection(reject); - }); - - xhr.open("GET", path, true); - xhr.onprogress = function (event) { - const progress = min(event.loaded / event.total, 1); - if (debug && logs.download) { - console.log(`💈 XML Download: ${progress * 100}%`); - } - progressReport?.(progress); - }; - xhr.onload = function () { - if (xhr.status === 200 || xhr.status === 304) { - resolve(xhr.responseXML || xhr.responseText); - } else { - reject(xhr.status); - } - }; - xhr.onerror = reject; - xhr.send(); - }); - } else if (extension === "json") { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - - options.signal?.addEventListener("abort", () => { - xhr.abort(); - rejection(reject); - }); - - xhr.open("GET", path, true); - xhr.onprogress = function (event) { - const progress = min(event.loaded / event.total, 1); - if (debug && logs.download) { - console.log(`💈 JSON Download: ${progress * 100}%`); - } - progressReport?.(progress); - }; - xhr.onload = function () { - if (xhr.status === 200 || xhr.status === 304) { - resolve(parseJSON ? JSON.parse(xhr.response) : xhr.response); - } else { - reject(xhr.status); - } - }; - xhr.onerror = reject; - xhr.send(); - }); - } else if ( - extension === "webp" || - extension === "jpg" || - extension === "gif" || - extension === "jpeg" || - extension === "png" - ) { - return new Promise((resolve, reject) => { - if (options.signal?.aborted) { - rejection(reject); - return; - } - - send({ type: "load-bitmap", content: path }); - preloadPromises[path] = { resolve, reject }; - - options.signal?.addEventListener("abort", () => { - send({ type: "load:abort", content: path }); - rejection(reject); - }); - }); - } else if ( - extension === "m4a" || - extension === "ogg" || - extension === "webm" || - extension === "wav" || - extension === "mp3" - ) { - return new Promise((resolve, reject) => { - if (options.signal?.aborted) { - rejection(reject); - return; - } - - send({ type: "sfx:load", content: path }); - preloadPromises[path] = { resolve, reject }; - - options.signal?.addEventListener("abort", () => { - rejection(reject); - }); - }); - } else if (extension === "zip") { - return new Promise((resolve, reject) => { - if (options.signal?.aborted) { - rejection(reject); - return; - } - - send({ type: "zip:load", content: path }); - preloadPromises[path] = { resolve, reject }; - - options.signal?.addEventListener("abort", () => { - rejection(reject); - }); - }); - } - }; - - // 🎬 Load animated WebP and decode all frames - $commonApi.net.preloadAnimatedWebp = async function (url) { - return new Promise((resolve, reject) => { - send({ type: "load-animated-webp", content: url }); - preloadPromises[url] = { resolve, reject }; - }); - }; - - $commonApi.slug = slug; - $commonApi.piece = slug?.split("~")[0].split(":")[0]; - $commonApi.query = Object.fromEntries(new URLSearchParams(search)); - $commonApi.params = params || []; - $commonApi.colon = colon || []; - $commonApi.hash = hash; // Add hash support for painting codes like #2ug - - // Initialize duration tracking from query parameters - if ($commonApi.query.duration) { - const duration = parseFloat($commonApi.query.duration); - if (!isNaN(duration) && duration > 0) { - durationTotal = duration; - durationStartTime = pageLoadTime; // Start from page load time immediately - durationProgress = 0; - durationCompleted = false; - durationBlinkState = false; - console.log("⏱️ Duration parameter detected:", duration, "seconds - starting from page load time:", pageLoadTime); - } - } - - $commonApi.load = async function () { - // Load a piece, wrapping it in a leave function so a final frame - // plays back. - leaving = true; - graph.unmask(); // Clear any active mask when leaving a piece - - return new Promise((resolve) => { - leaveLoad = async () => { - const loaded = await load(...arguments); - resolve(loaded); // Resolve with `true` or `false`. - }; - }); - }; - - // 💡 Eventually this could merge with net.web so there is one command - // to either go to a piece within the system if one loads... or an entirely - // different url somehow! 23.02.07.21.21 - - $commonApi.alias = function alias(name, colon, params) { - $commonApi.jump( - name + - colon.map((c) => `:` + c).join("") + - params.map((p) => `~` + p).join(""), - true, - true, - ); - }; - - // Go back to the previous piece, or to the prompt if there is no history. - $commonApi.back = () => { - if (pieceHistoryIndex > 0) { - send({ type: "back-to-piece" }); - } else { - $commonApi.jump("prompt"); - } - }; - - if (!alias) $commonApi.pieceCount += 1; // Don't bump pieceCount if aliased. - - // Load typeface if it hasn't been yet. - // (This only has to happen when the first piece loads.) - // Skip expensive typeface loading - use on-demand loading for all glyphs - const skipTypefacePreload = true; // Always skip preload, load on-demand instead - // console.log(`⏰ Typeface skip check: skipTypefacePreload=${skipTypefacePreload}, tf exists=${!!tf}`); - - const typefaceLoadStartTime = performance.now(); - if (!tf && !skipTypefacePreload) { - tf = await new Typeface(/*"unifont"*/).load($commonApi.net.preload); - const typefaceLoadEndTime = performance.now(); - // console.log(`⏰ Typeface load: ${Math.round(typefaceLoadEndTime - typefaceLoadStartTime)}ms`); - } else if (skipTypefacePreload) { - // Create a Typeface instance and call load() to set up Proxy - tf = new Typeface(/*"unifont"*/); - // In TV/headless mode (oven captures), await font load to ensure glyphs - // are available before first paint. Otherwise load on-demand (fire-and-forget). - const isHeadless = typeof window !== "undefined" && - new URLSearchParams(window.location?.search).get("tv") === "true"; - if (isHeadless) { - try { - await tf.load($commonApi.net.preload); - if (typeof window !== "undefined") { - window.acFontsReady = true; - window.__acTypeface = tf; - } - console.log("🔤 font_1 awaited load complete (tv mode)"); - } catch (err) { - console.error("🔤 tf.load() FAILED:", err?.message || err); - if (typeof window !== "undefined") window.acFontsReady = "error"; - } - } else { - tf.load($commonApi.net.preload).then(() => { - if (typeof window !== "undefined") { - window.acFontsReady = true; - window.__acTypeface = tf; - } - }).catch(() => {}); - } - const typefaceLoadEndTime = performance.now(); - // console.log(`⏰ Typeface load skipped (on-demand mode), Proxy initialized in ${Math.round(typefaceLoadEndTime - typefaceLoadStartTime)}ms`); - } else { - // console.log(`⏰ Typeface already loaded`); - } - - $commonApi.typeface = tf; // Expose a preloaded typeface globally. - if (typeof window !== "undefined") window.__acTypeface = tf; // Expose for oven diagnostics - ui.setTypeface(tf); // Set the default `ui` typeface. - - // Initialize HUD metrics now that the primary typeface is available - currentHUDLabelBlockWidth = tf.blockWidth; - currentHUDLabelBlockHeight = tf.blockHeight; - currentHUDShareWidth = tf.blockWidth * "share ".length; - currentHUDEditWidth = tf.blockWidth; - - // Initialize MatrixChunky8 font for QR code text rendering and keep it warm across pieces - // Always register the font so kidlisp.com previews still render MatrixChunky8 labels - const matrixFontStartTime = performance.now(); - let matrixFont = typefaceCache.get("MatrixChunky8"); - - if (!matrixFont) { - matrixFont = new Typeface("MatrixChunky8"); - typefaceCache.set("MatrixChunky8", matrixFont); - } - - if (!matrixFont.__loadPromise) { - matrixFont.__loadPromise = matrixFont.load($commonApi.net.preload); - } - - // Only run the heavier glyph preload pass when auth UI is enabled; kidlisp.com preview just needs the font registered - const skipMatrixGlyphPreload = typeof window !== "undefined" && window.acNOAUTH; - - if (!skipMatrixGlyphPreload) { - await matrixFont.__loadPromise; - - if (matrixFont && !matrixFont.__preloadedCommonGlyphs) { - const glyphPreloadStartTime = performance.now(); - // Pre-load common QR code characters to avoid fallback during rendering - const commonQRChars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz$"; - - // Access each character to trigger the Proxy loading mechanism and collect promises - const glyphPromises = []; - - for (const char of commonQRChars) { - // Accessing the glyph triggers the Proxy to start loading it - const glyph = matrixFont.glyphs[char]; - - // If it's a Promise, collect it to wait for loading - if (glyph && typeof glyph.then === "function") { - glyphPromises.push(glyph); - } - } - - // Wait for all common glyphs to finish loading - if (glyphPromises.length > 0) { - try { - await Promise.all(glyphPromises); - } catch (e) { - console.warn("🔤 Some MatrixChunky8 glyphs failed to preload:", e); - } - } - - const glyphPreloadEndTime = performance.now(); - // console.log(`⏰ MatrixChunky8 glyph preload: ${Math.round(glyphPreloadEndTime - glyphPreloadStartTime)}ms`); - matrixFont.__preloadedCommonGlyphs = true; - } - - const matrixFontEndTime = performance.now(); - // console.log(`⏰ MatrixChunky8 total: ${Math.round(matrixFontEndTime - matrixFontStartTime)}ms`); - } else { - // console.log("⏰ MatrixChunky8 preload skipped (noauth mode)"); - } - - /** - * @function video - * @descrption Make a live video feed. Returns an object that links to current frame. - * @param {string} type - "camera" or "camera-update" or see below. 💡 - * @param {object} options - *unimplemented* { src, width, height } - */ - - let videoTimeout; - - $commonApi.video = function (type, options) { - // TODO: ❤️‍🔥 Prevent fast multiple taps while camera is updating... - - // TODO: Options could eventually be { src, width, height } - // const vid = video("youtube-link"); - // const vid = video("tiktok:@whistlegraph"); - // https://codepen.io/oceangermanique/pen/LqaPgO - - if (videoSwitching === false) { - if (type === "camera:update") { - lastActiveVideo = activeVideo || lastActiveVideo; - activeVideo = null; - } - - clearTimeout(videoTimeout); - videoTimeout = setTimeout(() => { - send({ type: "video", content: { type, options } }); - }, 50); - - if (type === "camera:update") videoSwitching = true; - } - - // Return an object that can grab whatever the most recent frame of - // video was. - return videoFrame; - }; - - function videoFrame(shader) { - if (activeVideo) { - const { width, pixels } = activeVideo; - - if (shader) { - for (let i = 0; i < pixels.length; i += 4) { - const c = pixels.subarray(i, i + 4); - const p = { x: (i / 4) % width, y: floor(i / 4 / width) }; - shader(p, c); - } - } - } else if (lastActiveVideo) { - // Make it all red... - const { pixels } = lastActiveVideo; - for (let i = 0; i < pixels.length; i += 4) { - pixels[i] = 255; - pixels[i + 1] = 0; - pixels[i + 2] = 0; - pixels[i + 3] = 255; - } - } - return activeVideo || lastActiveVideo; - } - - // This function actually hotSwaps out the piece via a callback from `bios` once fully loaded via the `loading-complete` message. - hotSwap = () => { - loadedCallback?.(); // Run the optional load callback. (See also: `jump`) - - $commonApi.rec.loadCallback?.(); // Start any queued tape. - $commonApi.rec.loadCallback = null; - - // 📤 Send ready signal to parent window (e.g., kidlisp.com editor) - // Note: disk.mjs runs in a Web Worker, so window may not exist - const hasWindow = typeof window !== 'undefined'; - - try { - if (hasWindow && window.parent && window.parent !== window) { - window.parent.postMessage({ type: "kidlisp-ready" }, "*"); - } - // If no window (worker context), silently skip - } catch (e) { - // Silently fail if in worker context or cross-origin - } - - // Process any queued piece-reload that arrived before piece was ready - if (pendingPieceReload && $commonApi.reload) { - log.piece.log("Processing queued piece-reload from hotSwap"); - const pending = pendingPieceReload; - pendingPieceReload = null; - // Use setTimeout to ensure all initialization is complete - setTimeout(() => { - $commonApi.reload({ - source: pending.source, - codeId: pending.codeId, - createCode: pending.createCode, - authToken: pending.authToken, - enableTrace: pending.enableTrace, - language: pending.language, - ext: pending.ext, - liveName: pending.liveName, - }); - }, 50); - } - - module = loadedModule; - - // 🧹 Reset KidLisp baked layers when returning to the prompt to avoid stale flashes - if (module?.system?.startsWith("prompt") && - globalKidLispInstance?.clearBakedLayers) { - globalKidLispInstance.clearBakedLayers(); - graph.unmask(); // Clear mask when returning to prompt from KidLisp - } - - if (!module.system?.startsWith("world")) - $commonApi.system.world.teleported = false; - - // 📚 nopaint system - if ( - !Array.isArray(module) && - (module.system?.startsWith("nopaint") || - typeof module?.brush === "function" || - typeof module?.lift === "function" || - typeof module?.filter === "function") - ) { - // If there is no painting is in ram, then grab it from the local store, - // or generate one. - const modsys = module.system || "nopaint"; - - // TODO: 🖌️💬 Integrate nopaint system with chat. - // console.log(chat); - - const chatEnabled = false; - - $commonApi.system.nopaint.bakeOnLeave = - modsys.split(":")[1] === "bake-on-leave"; // The default is to `bake` at the end of each gesture aka `bake-on-lift`. - - boot = async ($) => { - // Reset scroll state when a new piece boots - graph.resetScrollState(); - // Reset mask state to prevent masks from persisting across pieces - graph.unmask(); - - const booter = module.boot || nopaint_boot; - booter($); - if (chatEnabled) { - try { - const chatModule = await import("../disks/chat.mjs"); - chatModule.boot($); - } catch (err) { - console.log("💬 Chat disabled in OBJKT mode"); - } - } - }; - - sim = module.sim || defaults.sim; - paint = async ($) => { - let painted = false; - - if (module.paint) { - painted = module.paint($); - if ($.system?.nopaint) { - $.system.nopaint.needsPresent = true; - } - } - - // 🎨 OVERLAY SUPPORT: Call overlay function for preview rendering with painting coordinates - if (module.overlay && $.system.nopaint?.brush?.dragBox) { - // Transform painting coordinates to screen coordinates for direct screen rendering - const originalDragBox = $.system.nopaint.brush.dragBox; - const zoom = $.system.nopaint.zoomLevel; - const tx = $.system.nopaint.translation.x; - const ty = $.system.nopaint.translation.y; - - // Create screen-space dragBox - const screenDragBox = { - x: originalDragBox.x * zoom + tx, - y: originalDragBox.y * zoom + ty, - w: originalDragBox.w * zoom, - h: originalDragBox.h * zoom - }; - - // Temporarily replace the dragBox with screen coordinates - const originalBrush = $.system.nopaint.brush; - $.system.nopaint.brush = { ...originalBrush, dragBox: screenDragBox }; - - // Add color word for overlay function - $.color = $.system.nopaint.color; - - // Add mark word for overlay function (preview dragBox) - $.mark = $.system.nopaint.brush?.dragBox; - - // Render overlay directly to screen buffer - module.overlay($); - - // Restore original dragBox - $.system.nopaint.brush = originalBrush; - } - - // 📊 Always render nopaint performance HUD if enabled (regardless of module.paint result) - if (system === "nopaint" && nopaintPerf) { - nopaint_renderPerfHUD($); - // Force continuous painting for nopaint system when perf HUD is enabled - painted = true; - } - - if (painted) { - return painted; - } - - // TODO: Pass in extra arguments here that flag the wipe. - if (chatEnabled) { - try { - const chatModule = await import("../disks/chat.mjs"); - chatModule.paint($, { embedded: true }); // Render any chat interface necessary. - } catch (err) { - console.log("💬 Chat disabled in OBJKT mode"); - } - } - }; - beat = module.beat || defaults.beat; - brush = module.brush; - lift = module.lift; - filter = module.filter; - act = ($) => { - nopaint_act($); // Inherit base functionality. - - // 🎯 IMMEDIATE BAKING: Fix race condition by triggering bake immediately after lift - const np = $.system?.nopaint; - if (np?.needsBake === true && bake) { - // 📊 Trigger bake flash effect and beep sound - nopaint_triggerBakeFlash(); - - // 🔊 Microwave-style beep for bake completion (only for robo) - if (currentPath?.includes('robo')) { - $commonApi.sound.synth({ - tone: 800, // Higher pitched beep like a microwave - duration: 0.1, - attack: 0.01, - decay: 0.5, - volume: 0.3, - }); - } - - // 🎨 ELEGANT BRUSH PATTERN: Call brush or lift function for final painting - if (brush || lift) { - const finalBrushApi = { ...$ }; - // Add top-level 'color' word that maps to system.nopaint.color - finalBrushApi.color = $.system.nopaint.color; - - // Add top-level 'mark' word that maps to finalDragBox for brush - finalBrushApi.mark = $.system.nopaint.finalDragBox; - - // 🎯 Use preserved coordinates since brush is null at bake time - const preservedDragBox = $.system.nopaint.finalDragBox; - const preservedStartDrag = $.system.nopaint.finalStartDrag; - - if (preservedDragBox) { - // Create a pen object with the preserved coordinates - finalBrushApi.pen = { - dragBox: preservedDragBox, - x: preservedStartDrag?.x || preservedDragBox.x, - y: preservedStartDrag?.y || preservedDragBox.y - }; - } else { - finalBrushApi.pen = $.system.nopaint.brush; // Fallback to original (likely null) - } - - finalBrushApi.lift = true; // 🎯 Single clean flag for final brush call - - $.page($.system.painting); // Set context to painting surface - - // Call lift function if it exists, otherwise fall back to brush - if (lift) { - lift(finalBrushApi); // Call lift for final painting - } else if (brush) { - brush(finalBrushApi); // Call brush for final painting (legacy) - } - - $.page($.screen); // Reset context - } - - $.page($.system.painting); - bake($); - - // 🧹 CLEAR BUFFER: Prevent flickering by clearing buffer BEFORE presentation - $.page($.system.nopaint.buffer).wipe(255, 255, 255, 0); - - $.page($.screen); - np.present($); - np.needsBake = false; - - // 📸 Snapshot for undo after every stroke bake. - addUndoPainting($.system.painting, "stroke"); - - // 🚀 Broadcast bake completion - $commonApi.broadcastPaintingUpdateImmediate("baked", { - source: "immediate_bake", - piece: loadedModule?.meta?.()?.title || "unknown" - }); - } - - if (module.act) { - return module.act($); - } else { - return defaults.act($); - } - }; - leave = ($) => { - module.leave?.($); // Run the custom leave. - nopaint.leave($); // And the inherited default leave from nopaint. - }; - bake = module.bake || nopaint.bake; - system = "nopaint"; - } else if (module.system?.startsWith("prompt")) { - // 📖 prompt system - // Default wrap to "word" if using `prompt:character`. - const wrap = - module.wrap || - (module.system.indexOf("character") > -1 ? "word" : undefined); - - if (module.scheme) { - if ( - module.scheme.dark === undefined && - module.scheme.light === undefined - ) { - const defaultScheme = { ...module.scheme }; - module.scheme.dark = module.scheme.light = defaultScheme; - keys(module.scheme).forEach((key) => { - if (key !== "dark" && key !== "light") delete module.scheme[key]; - }); - } - } - - boot = async ($) => { - await prompt.prompt_boot( - $, - { - prompt: module.prompt, - program: { - before: module.before, - after: module.after, - }, - hint: module.system.split(":").slice(1).join(":"), // See `ask.ts`. - forgetful: module.forgetful || false, - memory: module.memory || Infinity, - gutterMax: module.gutterMax, - lineSpacing: module.lineSpacing, - }, - module.reply, - module.halt, - module.scheme, - wrap, - module.copied, - module.activated, - ); - await module.boot?.($); - }; - - sim = ($) => { - module.sim?.($); - prompt.prompt_sim($); - }; - - paint = ($) => { - let noPaint = module.paint?.($); // Carry the return. - - // Paint an illustration if it exists and `paint` is not defined. - if (!module.paint && module.illustration) { - $.wipe(...module.scheme.dark.background.slice(0, 3)); - $.stamp(module.illustration, { center: "x", bottom: 0 }); - noPaint = true; - } - - const promptPainted = prompt.prompt_paint($); - return noPaint || promptPainted; - }; - - beat = module.beat || defaults.beat; - - act = ($) => { - module.act?.($); - prompt.prompt_act($); - }; - - leave = ($) => { - module.leave?.($); - prompt.prompt_leave($); - }; - - // console.log("📨 Module exports check:", { - // hasReceive: !!module.receive, - // receiveType: typeof module.receive, - // receiveIsDefault: module.receive === defaults.receive, - // }); - - receive = module.receive || defaults.receive; - - system = "prompt"; - } else if (module.system?.startsWith("world")) { - // 🗺️ world system - boot = async ($) => { - await world.world_boot($, module.world); - await module.boot?.($); - }; - - sim = ($) => { - world.world_sim($); - module.sim?.($); - }; - - paint = ($) => { - if (!world.coversScreen($.screen)) module.background?.($); - world.world_paint($, module.paint, module.curtain); - }; - - beat = module.beat || defaults.beat; - - act = ($) => { - world.world_act($); - module.act?.($); - }; - - leave = ($) => { - world.world_leave($); - module.leave?.($); - }; - - system = "world"; - } else if (module.system?.startsWith("fps")) { - // 🧊 fps system - let doll; - - boot = ($) => { - doll = new CamDoll($.Camera, $.Dolly, { - fov: 80, - z: 0, - y: 0, - sensitivity: 0.002, - }); - $commonApi.system.fps = { doll, renderStats: graph.renderStats }; - module?.boot?.($); - }; - - sim = ($) => { - doll?.sim(); - module?.sim?.($); - }; - - act = ($) => { - doll?.act($.event); - module?.act?.($); - }; - - paint = module.paint || defaults.paint; - leave = module.leave || defaults.leave; - - system = "fps"; - } else if (module.system?.startsWith("game")) { - // 🎲 🎮 game system - console.log("game!"); - // TODO: ⚠️ Make game template. 25.06.05.09.19 - } else { - const moduleAssignStartTime = performance.now(); - // console.log(`⏰ Starting module function assignment at ${moduleAssignStartTime}ms`); - - // 🧩 piece - // Reset scroll state when a piece loads - graph.resetScrollState(); - // Reset mask state to prevent masks from persisting across pieces - graph.unmask(); - - boot = module.boot || defaults.boot; - sim = module.sim || defaults.sim; - paint = module.paint || defaults.paint; - beat = module.beat || defaults.beat; - act = module.act || defaults.act; - leave = module.leave || defaults.leave; - - // console.log("📨 Module exports check:", { - // hasReceive: !!module.receive, - // receiveType: typeof module.receive, - // receiveIsDefault: module.receive === defaults.receive - // }); - - receive = module.receive || defaults.receive; // Handle messages from BIOS - - // ⏱️ Background mode: pieces can export `background = true` to keep sim() - // running when the page is hidden (e.g. clock.mjs for audio scheduling) - pieceBackground = !!module.background; - - // 🎨 AUTO-DETECT BRUSH FUNCTIONS: If a piece exports a brush or lift function, automatically use nopaint system - system = module.system || (module.brush || module.lift ? "nopaint" : null); - - // 🎨 AUTO-GENERATE BAKE: If using brush/lift but no explicit bake function, create a default one - if ((module.brush || module.lift) && !module.bake) { - bake = ({ paste, system, page }) => { - paste(system.nopaint.buffer); - page(system.nopaint.buffer).wipe(255, 255, 255, 0); - }; - } - - const moduleAssignEndTime = performance.now(); - // console.log(`⏰ Module function assignment complete (${Math.round(moduleAssignEndTime - moduleAssignStartTime)}ms)`); - - // delete $commonApi.system.name; // No system in use. - } - - preview = module.preview || defaults.preview; // Set preview method. - icon = module.icon || defaults.icon; // Set preview method. - - // ♻️ Reset global state for this piece. - paintCount = 0n; - paintingAPIid = 0n; - simCount = 0n; - booted = false; - // initialSim = true; - activeVideo = null; - videoSwitching = false; - lastActiveVideo = null; - keys(preloadPromises).forEach((key) => preloadPromises[key].reject(key)); - preloadPromises = {}; - noPaint = false; - - // 🎞️ Reset piece-level FPS control - pieceFPS = null; - lastPaintTime = 0; - lastPaintOut = undefined; - shouldSkipPaint = false; - pieceFrameCount = 0; - // Reset acPieceReady so the signal fires again for the next piece - if (typeof window !== "undefined") { - window.acPieceReady = false; - } - - // 🎬 Piece Transition: Capture CURRENT screen pixels BEFORE clearing - // This enables piece-to-piece morphing transitions (not just noise16→piece) - // Skip entirely if TRANSITION_TYPE is "none" - if (TRANSITION_TYPE !== "none") { - // Check if buffer is valid (not detached from transfer to main thread) - try { - if (screen?.pixels && screen.width > 0 && screen.height > 0 && - screen.pixels.buffer && !screen.pixels.buffer.detached && screen.pixels.byteLength > 0) { - golTransition.overlayPixels = new Uint8ClampedArray(screen.pixels); - golTransition.width = screen.width; - golTransition.height = screen.height; - - // Initialize and START transition immediately (loading phase) - initGOLCells(golTransition.width, golTransition.height); - golTransition.generation = 0; - golTransition.active = true; - console.log(`🎬 Transition: Started ${TRANSITION_TYPE} (loading phase)`, screen.width, "x", screen.height); - } - } catch (e) { - // Buffer may be detached if pixels were transferred - skip transition - console.log("🎬 Transition: Could not capture pixels (buffer detached), skipping"); - golTransition.overlayPixels = null; - } - } - - // Note: transition state is now set above when starting - - // 🧹 Clear screen buffer when loading a new piece to prevent stale pixels - // This fixes the blank screen issue when backspacing from kidlisp to prompt - graph.clear(); - - formsSent = {}; // Clear 3D list for GPU. - currentPath = path; - currentHost = host; - currentSearch = search; - // console.log("Set currentSearch to:", search); - firstPreviewOrIcon = true; - - // Clear clock piece cached code when leaving (will be set by clock piece if it caches) - cachedClockCode = null; - - // Parse search parameters properly to check for nolabel - // Also hide label in pack mode (standalone bundles have no URL params) - hideLabel = (typeof window !== "undefined" && window.acPACK_MODE && !window.acKEEP_LABEL) || false; - if (parsed.search) { - const searchParams = new URLSearchParams(parsed.search); - if (searchParams.has("nolabel")) hideLabel = true; - } - - currentColon = colon; - currentParams = params; - currentHash = hash; - // sound = null; - glazeEnabled = null; - // soundClear = null; - hourGlasses.length = 0; - // labelBack = false; // Now resets after a jump label push. 25.03.22.21.36 - - // Parse search parameters properly to check for preview and icon - previewMode = false; - iconMode = false; - if (parsed.search) { - const searchParams = new URLSearchParams(parsed.search); - previewMode = searchParams.has("preview"); - iconMode = searchParams.has("icon"); - } - - // console.log("🔴 PREVIEW OR ICON:", PREVIEW_OR_ICON, "Preview mode:", previewMode, "Icon mode:", iconMode); - // console.log("📑 Search:", parsed.search); - // console.log("🖼️ ICON MODE:", iconMode); - - previewOrIconMode = previewMode || iconMode; - paintings = {}; // Reset painting cache. - // Reset KidLisp persistent paintings cache to avoid stale embedded content - if (globalKidLispInstance?.persistentPaintings) { - globalKidLispInstance.persistentPaintings.clear(); - } - prefetches?.forEach((p) => prefetchPicture(p)); // Prefetch parsed media. - graph.color2(null); // Remove any secondary color that was added from another piece. - // 🐢 Reset turtle state. - turtleAngle = 270; - turtleDown = false; - turtlePosition = { x: screen.width / 2, y: screen.height / 2 }; - - //$api.fps = function (newFps) { - // No longer reset global FPS when loading new pieces - // send({ type: "fps-change", content: undefined }); - //}; - - // 🪧 See if notice needs to be shown. - if ($commonApi.query.notice === "success") { - notice = "PRINTED!"; - noticeColor = ["white", "green"]; - noticeBell(cachedAPI); - } else if ($commonApi.query.notice === "cancel") { - notice = "CANCELLED"; - noticeColor = ["yellow", "red"]; - noticeBell(cachedAPI, { tone: 300 }); - } else if ($commonApi.query.notice === "email-verified") { - notice = "Email verified!"; - noticeColor = ["white", "blue"]; - noticeBell(cachedAPI, { tone: 300 }); - } else if ($commonApi.query.notice?.length > 0) { - notice = $commonApi.query.notice; - noticeColor = ["white", "green"]; - noticeBell(cachedAPI, { tone: 300 }); - } - - if (!alias) { - currentHUDTxt = slug; // Update hud if this is not an alias. - - // Special HUD label formatting for specific pieces - if (slug === "r8dio") { - // Display as "r8Dio" with "8D" in magenta - currentHUDTxt = "r\\255,0,255\\8D\\255,200,220\\io"; - currentHUDPlainTxt = "r8Dio"; - } else { - currentHUDPlainTxt = stripColorCodes(slug); - } - - // Hide HUD label for RGB-only kidlisp pieces (e.g., "255 0 0") - // since they're just background colors and don't need to be displayed - if (sourceCode && lisp?.isValidRGBString && lisp.isValidRGBString(sourceCode.trim())) { - currentHUDTxt = undefined; - currentHUDPlainTxt = undefined; - } - } - if (module.nohud || system === "prompt") { - currentHUDTxt = undefined; - currentHUDPlainTxt = undefined; - } - // Clear original code ID for non-$code pieces (unless this was a $code load) - if (!slug?.startsWith("$")) { - currentOriginalCodeId = undefined; - currentHUDAuthor = null; // Reset author for non-KidLisp pieces - currentHUDHits = null; - } - currentHUDOffset = undefined; // Always reset these to the defaults. - currentHUDTextColor = undefined; - currentHUDStatusColor = "red"; //undefined; - currentHUDButton = undefined; - currentHUDScrub = 0; - currentHUDLeftPad = 0; - currentHUDQR = null; // Reset QR code when loading new piece - currentHUDQRCells = null; - currentHUDSuperscript = null; // Reset custom superscript - forceTinyHudLabel = false; // Reset tiny label flag - // currentPromptButton = undefined; - - // Push last piece to a history list, skipping prompt and repeats. - if ( - !fromHistory && - currentText && - currentText !== "prompt" && - currentText !== $commonApi.history[$commonApi.history.length - 1] - ) { - $commonApi.history.push(currentText); - } - - currentText = slug; - currentCode = sourceCode; - - // Broadcast location change to session server - if (HANDLE && socket?.connected && slug !== "*keep-alive*") { - socket.send("location:broadcast", { - handle: HANDLE, - slug: slug, - user: USER, - }); - if (logs.socket) console.log("📍 Broadcasting location:", slug); - } - - if (screen) screen.created = true; // Reset screen to created if it exists. - - cursorCode = "precise"; // Set default cursor. - - if (firstLoad === true) { - firstLoad = false; - // firstPiece = path; - // firstParams = params; - // firstSearch = search; - } - }; - - send({ - type: "disk-loaded", - content: { - path, - host, - search, - params, - hash, - text: slug, - pieceCount: $commonApi.pieceCount, - pieceHasSound: true, // TODO: Make this an export flag for pieces that don't want to enable the sound engine. 23.07.01.16.40 - background: pieceBackground, // ⏱️ Piece wants sim() to keep running when hidden - // 📓 Could also disable the sound engine if the flag is false on a subsequent piece, but that would never really make practical sense? - fromHistory, - alias, - meta, - taping: $commonApi.rec.loadCallback !== null || $commonApi.rec.recording, // 🎏 Hacky flag. 23.09.17.05.09 - // noBeat: beat === defaults.beat, - clockShortcode, // ⏰ Original *code for URL display (null if not a clock shortcode) - }, - }); - - // Notify parent window of progress via bios relay - const loadCompleteTime = performance.now(); - // console.log("⏰ load() completed at", loadCompleteTime); - // console.log("📢 Disk sending boot-log: loaded"); - send({ - type: "boot-log", - content: `loaded: ${slug || path}` - }); - - return true; // Loaded successfully. -} - -const isWorker = typeof importScripts === "function"; -const _workerReadyTime = performance.now(); -// Only log worker ready if there was significant import time -if (_workerReadyTime - _importStart > 50) { - console.log(`📦 [DISK] Ready: ${(_workerReadyTime - _importStart).toFixed(0)}ms`); -} - -// ***Bootstrap*** -// Start by responding to a load message, then change -// the message response to makeFrame. -if (isWorker) { - // Signal to bios.mjs that disk.mjs has loaded successfully - postMessage({ type: "worker-ready" }); - - onmessage = (e) => { - // DEBUG: Log DAW messages only when debugging - if (e.data?.type?.startsWith?.("daw:") && logs.daw) { - console.log("🎹 WORKER:", e.data?.type, e.data); - } - makeFrame(e); - }; -} else { - noWorker.onMessage = (d) => makeFrame({ data: d }); -} - -// The main messaging function to comumunicate back with the main thread. -function send(data, shared = []) { - if (isWorker) { - if (shared[0] === undefined) shared = []; - postMessage(data, shared); - } else { - if (noWorker.postMessage) { - noWorker.postMessage({ data }); - } else { - console.warn("⚠️ noWorker.postMessage is not defined yet, cannot send:", data); - } - } -} - -// Used to subscribe to live coding / development reloads. -let codeChannel, codeChannelAutoLoader; - -// Queue for export events that arrive when actEvents isn't available -let pendingExportEvents = []; - -// 4. ✔ Respond to incoming messages, and probably produce a frame. -// Boot procedure: -// First `paint` happens after `boot`, then any `act` and `sim`s each frame -// before `paint`ing occurs. One `sim` always happens after `boot` and before -// any `act`. `paint` can return false to stop drawing every display frame, -// then, it must be manually restarted via `needsPaint();`). 2022.01.19.01.08 -// 🔥 -// TODO: makeFrame is no longer a great name for this function, which actually -// receives every message from the main thread, one of which renders a -// frame. -// TODO: Make simple needsPaint example. -// TODO: Try to remove as many API calls from here as possible. - -async function makeFrame({ data: { type, content } }) { - // DEBUG: Log ALL messages starting with "pedal:" or "daw:" - if (type?.startsWith?.("pedal:") || type?.startsWith?.("daw:") || type?.startsWith?.("spreadnob:")) { - console.log("🎹🎹🎹 makeFrame received:", type, JSON.stringify(content)); - } - - // Handle permission responses from bios.mjs - if (type === "permission-response") { - resolvePermissionRequest(content.requestId, content.granted); - return; - } - - // Navigate to a new piece (device.html slideshow uses this for instant transitions) - if (type === "navigate") { - const target = content?.to; - console.log("🧭 Navigate received:", target, "jump available:", !!$commonApi?.jump, "loading:", loading, "leaving:", leaving); - if (target && $commonApi?.jump) { - $commonApi.jump(target); - } else { - console.warn("🧭 Navigate failed: target=", target, "$commonApi=", !!$commonApi, "jump=", !!$commonApi?.jump); - } - return; - } - - // Warm the kidlisp code cache with pre-fetched sources from parent - if (type === "warm-kidlisp-cache") { - const codes = content?.codes; - if (codes) { - let count = 0; - for (const [codeId, source] of Object.entries(codes)) { - saveCodeToAllCaches(codeId, source); - count++; - } - console.log(`🔥 Warmed kidlisp cache with ${count} codes`); - } - return; - } - - // Runs once on boot. - if (type === "init-from-bios") { - debug = content.debug; - setDebug(content.debug); - ROOT_PIECE = content.rootPiece; - - if (content.debugHud) { - globalThis.debugHudHitbox = true; - console.log("🔍 HUD Hitbox Debug: ON (init-from-bios)"); - $commonApi.needsPaint(); - } - - // Store noauth flag for iframe embedding (kidlisp.com) - // In noauth mode, mark session as started immediately so piece loading - // doesn't wait for the session:started message (resilience when session server is down) - if (content.noauth) { - globalThis.NOAUTH_MODE = true; - sessionStarted = true; - } - - USER = content.user; - // if (USER) socket.send("login", { user: USER }); - - LAN_HOST = content.lanHost; - SHARE_SUPPORTED = content.shareSupported; - PREVIEW_OR_ICON = content.previewOrIcon; - VSCODE = content.vscode; - setPackMode( - Boolean( - content.objktMode || - content.packMode || - (typeof globalThis !== "undefined" && globalThis.acPACK_MODE), - ), - ); // Store PACK/OBJKT mode flag in worker global scope - if (content.objktKidlispCodes) { - const globalScope = (function() { - if (typeof globalThis !== 'undefined') return globalThis; - if (typeof self !== 'undefined') return self; - if (typeof global !== 'undefined') return global; - return {}; - })(); - globalScope.objktKidlispCodes = content.objktKidlispCodes; - } - - TV_MODE = content.resolution?.tv === true; - DEVICE_MODE = content.resolution?.device === true; - SOLO_MODE = content.resolution?.solo === true; - - // Parse highlight parameter - const highlightParam = content.resolution?.highlight; - if (highlightParam) { - HIGHLIGHT_MODE = true; - if (highlightParam !== true && typeof highlightParam === 'string') { - // If it's a color string, parse it - if (highlightParam.startsWith('#')) { - // Convert hex to RGB - const hex = highlightParam.slice(1); - const r = parseInt(hex.slice(0, 2), 16); - const g = parseInt(hex.slice(2, 4), 16); - const b = parseInt(hex.slice(4, 6), 16); - HIGHLIGHT_COLOR = `${r},${g},${b}`; - } else if (highlightParam.includes(',')) { - // Already RGB format - HIGHLIGHT_COLOR = highlightParam; - } else { - // Named color or other format - use CSS color parser or default - HIGHLIGHT_COLOR = highlightParam; - } - } - } else { - HIGHLIGHT_MODE = false; - } - - // Parse perf parameter for KidLisp performance HUD - PERF_MODE = content.resolution?.perf === true; - - // Parse auto-scale parameter for automatic density scaling - AUTO_SCALE_MODE = content.resolution?.autoScale === true; - SPOOF_AUDIO_MODE = content.resolution?.spoofaudio === true; - NOGAP_MODE = content.resolution?.gap === 0; - - microphone.permission = content.microphonePermission; - - $commonApi.canShare = SHARE_SUPPORTED; - $commonApi.vscode = VSCODE; // Add vscode flag to the common api. - $commonApi.net.lan = LAN_HOST; - $commonApi.user = USER; - $commonApi.net.iframe = content.iframe; - $commonApi.net.sandboxed = content.sandboxed; - $commonApi.net.tvMode = TV_MODE; // Add TV mode flag to common API - - codeChannelAutoLoader = null; - codeChannel = await store.retrieve("code-channel"); - if (!codeChannel || codeChannel?.length === 0) { - codeChannel = nanoid(); - store["code-channel"] = codeChannel; - store.persist("code-channel"); - } - - // console.log("💻 Code channel:", codeChannel); - - // Always send the codeChannel up to any parent window. - codeChannelAutoLoader = () => { - send({ - type: "post-to-parent", - content: { type: "setCode", value: codeChannel }, - }); - codeChannelAutoLoader = null; - }; - - // 🚀 Eagerly initialize GPU effects module for faster spin/blur/zoom - // Currently GPU effects are disabled due to Y-flip issues, so this is non-blocking - // TODO: Re-enable await when GPU effects Y-coordinate handling is fixed - if (typeof graph.initGpuEffects === 'function') { - graph.initGpuEffects().catch(e => { - console.warn('🎮 GPU Effects initialization failed:', e); - }); - } - - // console.log("Init:", content); - // await handle(); // Get the user's handle. - // console.log("🟢 Loading after preamble:", content.parsed); - - originalHost = content.parsed.host; - loadAfterPreamble = () => { - const loadCallStartTime = performance.now(); - // console.log("⏰ loadAfterPreamble called at", loadCallStartTime); - loadAfterPreamble = null; - load(content.parsed); // Load after some of the default frames run. - }; - - if (PREVIEW_OR_ICON) { - console.log("💬 Chat disabled, just grabbing screenshots. 😃"); - } else if (getPackMode()) { - // Skip chat connection in PACK mode - offline bundle - } else { - chatClient.connect("system"); // Connect to `system` chat. - } - - send({ type: "disk-defaults-loaded" }); - return; - } - - // Enable KidLisp-only console channel (forwarded from boot.mjs). - if (type === "kidlisp-console-enable") { - // Call the kidlisp.mjs enableKidlispConsole() to set the module-level flag - // This is needed because bios.mjs imports isKidlispConsoleEnabled() from kidlisp.mjs - enableKidlispConsole(); - return; - } - - // � Toggle HUD hitbox debug visualization from main thread - if (type === "debug:hud-hitbox:toggle") { - globalThis.debugHudHitbox = !globalThis.debugHudHitbox; - console.log("🔍 HUD Hitbox Debug:", globalThis.debugHudHitbox ? "ON" : "OFF"); - $commonApi.needsPaint(); - return; - } - - // �🛑 Watchdog ping - respond immediately with pong - if (type === "watchdog:ping") { - send({ type: "watchdog:pong", content: { timestamp: content?.timestamp } }); - return; - } - - if (type === "logout:broadcast:subscribe") { - console.log("🏃‍♂️ Broadcasting logout:", content); - socket?.send("logout:broadcast:subscribe", content); - return; - } - - // Get visualViewport update, for keyboard overlays, etc. - if (type === "viewport-height:changed" && booted) { - const $api = cachedAPI; - const data = { ...content }; - Object.assign(data, { - is: (e) => e === type, - }); - $api.event = data; - try { - act($api); - } catch (e) { - console.warn("️ ✒ Act failure...", e); - } - return; - } - - // Receive a midi input message. - if (type === "midi:keyboard" && booted) { - // console.log("🎹 Keyboard:", content.data); - const $api = cachedAPI; - const data = { ...content }; - Object.assign(data, { - device: "midi:keyboard", - is: (e) => e === type, - }); - $api.event = data; - try { - act($api); - } catch (e) { - console.warn("️ ✒ Act failure...", e); - } - return; - } - - if (type === "audio:sample-rate") { - AUDIO_SAMPLE_RATE = content; - return; - } - - // 🎹 DAW Sync messages (from Max for Live via bios.mjs) - // Use persistentDawState since $commonApi.sound gets recreated every frame - if (type === "daw:tempo") { - // Send acknowledgement back to main thread for debugging - send({ type: "daw:tempo:ack", content: { bpm: content?.bpm } }); - sound.bpm = content.bpm; - // Store in persistent state (survives frame updates) - persistentDawState.bpm = content.bpm; - return; - } - - if (type === "daw:transport") { - // Send acknowledgement back to main thread for debugging - send({ type: "daw:transport:ack", content: { playing: content?.playing } }); - // Store in persistent state (survives frame updates) - persistentDawState.playing = content.playing; - return; - } - - if (type === "daw:phase") { - // Store in persistent state (survives frame updates) - persistentDawState.time = content.time; - return; - } - - if (type === "daw:samplerate") { - // Store in persistent state (survives frame updates) - persistentDawState.sampleRate = content.rate; - return; - } - - // 🎛️ Spreadnob messages (from M4L via bios) — stored on persistentDawState - if (type === "spreadnob:note") { - persistentDawState.snNote = content.note; - return; - } - if (type === "spreadnob:target") { - persistentDawState.snTarget = content.name; - return; - } - if (type === "spreadnob:value") { - persistentDawState.snValue = content.value; - return; - } - if (type === "spreadnob:active") { - persistentDawState.snActive = content.active; - return; - } - if (type === "spreadnob:min") { - persistentDawState.snMin = content.min; - return; - } - if (type === "spreadnob:max") { - persistentDawState.snMax = content.max; - return; - } - if (type === "spreadnob:state") { - persistentDawState.snRawNote = content.raw; - persistentDawState.snNormalizedNote = content.note; - persistentDawState.snShift = content.shift; - persistentDawState.snLocked = content.locked; - persistentDawState.snAmbiguous = content.ambiguous; - return; - } - if (type === "spreadnob:qwerty-range") { - persistentDawState.snQwertyLow = content.low; - persistentDawState.snQwertyHigh = content.high; - return; - } - - // 🎸 Pedal messages (for audio effect visualization) - if (type === "pedal:peak") { - // Forward to the piece's receive function if it exists - if (typeof receive === "function") { - receive({ type: "pedal:peak", peak: content.peak }); - } - return; - } - - if (type === "pedal:envelope") { - // Forward to the piece's receive function if it exists - if (typeof receive === "function") { - receive({ type: "pedal:envelope", ...content }); - } - return; - } - - // Update the logged in user after initialization. - if (type === "session:started") { - // console.log("🟢 Session starting..."); - USER = content.user; - $commonApi.user = USER; // User will be set to "null" here - // it it doesn't exist. - - if (USER) { - // console.log("Getting handle..."); - await handle(); // Get the user's handle. - // console.log("Handle recived:", HANDLE); - const displayName = HANDLE || USER.email; - console.log(`%c🔐 auth%c Welcome back %c${displayName}`, - 'background: #ec4899; color: white; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: bold;', - 'color: inherit;', - 'color: #ec4899; font-weight: bold;' - ); - // Log tab ID for debugging - if (!$commonApi._tabId) { - $commonApi._tabId = Math.random().toString(36).substr(2, 9); - } - // Broadcast to other tabs... - $commonApi.broadcast("login:success"); - } else { - // console.log("🔐 You are not logged in."); - // Generate tab ID for debugging (anonymous user) - if (!$commonApi._tabId) { - $commonApi._tabId = Math.random().toString(36).substr(2, 9); - } - } - sessionStarted = true; - diskTimings.sessionStarted = Math.round(performance.now() - diskTimingStart); - // Silent: sessionStarted - return; - } - - // Handle live reload from kidlisp.com editor - if (type === "piece-reload") { - log.piece.log("Reloading with new code:", content.source?.substring(0, 30) + "..."); - if ($commonApi.reload) { - $commonApi.reload({ - source: content.source, - codeId: content.codeId, // The $code identifier (e.g., "inz") - createCode: content.createCode, - authToken: content.authToken, // Pass token from kidlisp.com login - enableTrace: content.enableTrace, // Enable execution trace for visualization - language: content.language, - ext: content.ext, - liveName: content.liveName, - }); - } else { - // Queue the reload request to be processed once the piece is ready - // Always update the queue with the latest code (overwrite previous) - log.piece.warn("Reload function not ready yet, queuing:", content.source?.substring(0, 20)); - pendingPieceReload = { - source: content.source, - codeId: content.codeId, - createCode: content.createCode, - authToken: content.authToken, - enableTrace: content.enableTrace, - language: content.language, - ext: content.ext, - liveName: content.liveName, - }; - } - return; - } - - // 🎚️ Slide mode update - re-evaluate code but preserve buffers/state - if (type === "piece-slide") { - if (content.source) { - // Use the exported slideUpdate from kidlisp.mjs to update the correct instance - // (The one used by lisp.module() for loaded pieces) - lisp.slideUpdate(content.source); - } - return; - } - - // 🎵 Audio data from jukebox - update KidLisp audio state - if (type === "kidlisp-audio") { - lisp.updateKidLispAudio?.(content); - return; - } - - // Confirming if the pen has been locked or unlocked by the Pointer Lock API. - if (type === "pen:locked" || type === "pen:unlocked") { - actAlerts.push(type); - return; - } - - // Capture a link from the docs system. - if (type === "docs:link") { - console.log("📚 Doc link captured:", content); - $commonApi.jump("prompt~" + content); - return; - } - - // Capture the browser scroll wheel / scroll effect. - if (type === "scroll") { - if (!cachedAPI) return; // Guard against undefined cachedAPI - const $api = cachedAPI; - const data = { ...content }; - Object.assign(data, { - device: "wheel", - is: (e) => e === type, - }); - $api.event = data; - try { - act($api); - } catch (e) { - console.warn("️ ✒ Act failure...", e); - } - return; - } - - // Jump to any piece slug from the bios. - if (type === "jump") { - let ahistorical, alias; - if (content.ahistorical === undefined) { - ahistorical = true; - } else ahistorical = content.ahistorical; - if (content.alias === undefined) { - alias = true; - } else alias = content.alias; - $commonApi.jump(content.piece, ahistorical, alias); - return; - } - - // Create a notice. - if (type === "notice") { - $commonApi.notice(content, ["white", "maroon"]); - return; - } - - if (type === "loading-complete") { - leaving = false; - hotSwap?.(); // Actually swap out the piece functions and reset the state. - loading = false; - loadingStartTime = null; - noPaint = false; // 🎨 Ensure first frame paints after piece swap - return; - } - - if (type === "udp:receive") { - udp.receive(content); - return; - } - - if (type === "udp:connected") { - console.log("🩰 DISK received udp:connected! Setting udp.connected = true"); - udp.connected = true; - updateHUDStatus(); - $commonApi.needsPaint(); - return; - } - - if (type === "udp:disconnected") { - console.log("🩰 DISK received udp:disconnected! Setting udp.connected = false"); - udp.connected = false; - updateHUDStatus(); - $commonApi.needsPaint(); - return; - } - - if (type === "microphone-disconnect") { - microphone.connected = false; - actAlerts.push("microphone-disconnect"); - return; - } - - // if (type === "hand-tracking-data") { - // $commonApi.hand = { mediapipe: content }; - // return; - // } - - // Load the source code for a dropped `.mjs` or KidLisp file. - if (type === "dropped:piece") { - // Pass forceKidlisp=true if this was detected as KidLisp (e.g., from .lisp.html bundle) - const forceKidlisp = content.isKidLisp === true; - load(content, false, false, true, undefined, forceKidlisp); - return; - } - - // Run dropped text as a prompt command (navigation / piece name). - if (type === "dropped:text") { - $commonApi.jump(content.text); - return; - } - - if (type === "dropped:bitmap") { - if (currentPath === "aesthetic.computer/disks/prompt") { - $commonApi.system.nopaint.replace( - { system: $commonApi.system, store, needsPaint: $commonApi.needsPaint }, - content.source, - ); - } else { - const $api = cachedAPI; - const data = { name: content.name, painting: content.source }; - Object.assign(data, { - device: "none", - is: (e) => e === type, - }); - $api.event = data; - try { - act($api); - } catch (e) { - console.warn("️ ✒ Act failure...", e); - } - } - return; - } - - // Handle dropped ALS files - route to piece receive function if available - if (type === "dropped:als") { - console.log("🎵 DISK.MJS: Received dropped:als message:", content); - - // First try to call the piece's receive function if it exists - if (typeof receive === "function") { - console.log("🎵 DISK.MJS: Calling piece receive function"); - try { - receive({ type, content }); - } catch (e) { - console.warn("🎵 DISK.MJS: Piece receive function error:", e); - } - } else { - console.log("🎵 DISK.MJS: No piece receive function found, triggering act event"); - // Fall back to act event pattern - const $api = cachedAPI; - if ($api) { - const data = { - name: content.name, - xmlData: content.xmlData, - type: "dropped:als" - }; - Object.assign(data, { - device: "none", - is: (e) => e === type, - }); - $api.event = data; - try { - act($api); - } catch (e) { - console.warn("️ ✒ Act failure...", e); - } - } - } - return; - } - - // Handle dropped WAV files - route to piece receive function if available - if (type === "dropped:wav") { - console.log("🎵 DISK.MJS: Received dropped:wav message:", content); - - // First try to call the piece's receive function if it exists - if (typeof receive === "function") { - console.log("🎵 DISK.MJS: Calling piece receive function for WAV"); - try { - receive({ type, content }); - } catch (e) { - console.warn("🎵 DISK.MJS: Piece receive function error for WAV:", e); - } - } else { - console.log("🎵 DISK.MJS: No piece receive function found for WAV, triggering act event"); - // Fall back to act event pattern - const $api = cachedAPI; - if ($api) { - const data = { - name: content.name || content.originalName, - id: content.id, - size: content.size, - type: "dropped:wav" - }; - Object.assign(data, { - device: "none", - is: (e) => e === type, - }); - $api.event = data; - try { - act($api); - } catch (e) { - console.warn("️ ✒ Act failure...", e); - } - } - } - return; - } - - // 🗣️ An act that fires when an utterance has ended in the Web Speech API. - if (type === "speech:completed") { - actAlerts.push("speech:completed"); - return; - } - - // 🎵 Clock piece sent its cached code for QR display - if (type === "clock:cached") { - cachedClockCode = content?.code || null; - console.log(`🎵 Clock cached code received: *${cachedClockCode}`); - return; - } - - // When inputting text into the prompt. - if ( - type === "prompt:text:replace" || - type === "prompt:text:select" || - type === "prompt:text:cursor" - ) { - const $api = cachedAPI; - const data = { ...content }; - Object.assign(data, { - device: "none", - is: (e) => e === type, - }); - $api.event = data; - try { - act($api); - } catch (e) { - console.warn("️ ✒ Act failure...", e); - } - return; - } - - // Handles: clipboard:paste:pasted, clipboard:paste:pasted:empty - if (type.startsWith("paste:pasted")) { - actAlerts.push("clipboard:" + type); - return; - } - - if (type === "paste:failed") { - actAlerts.push("clipboard:paste:failed"); - return; - } - - if (type === "copy:copied") { - actAlerts.push("clipboard:copy:copied"); - return; - } - - if (type === "aesthetic-parent:focused") { - actAlerts.push("aesthetic-parent:focused"); - return; - } - - if (type === "copy:failed") { - actAlerts.push("clipboard:copy:failed"); - return; - } - - if (type === "upload:progress") { - serverUploadProgressReporter?.(content); // Report file upload progress if needed. - - // Also forward to current piece (especially video.mjs for tape uploads) - if (cachedAPI?.piece?.receive) { - console.log(`📤 Forwarding upload:progress to piece: ${content}`); - try { - cachedAPI.piece.receive({ - type: "upload:progress", - content, - is: (name) => name === "upload:progress" - }); - } catch (error) { - console.warn("📤 Error forwarding upload:progress to piece:", error); - } - } - - return; - } - - if (type === "upload:status") { - // Forward backend upload status stages to the active piece. - if (cachedAPI?.piece?.receive) { - try { - cachedAPI.piece.receive({ - type: "upload:status", - content, - is: (name) => name === "upload:status", - }); - } catch (error) { - console.warn("📤 Error forwarding upload:status to piece:", error); - } - } - return; - } - - if (type === "focus-change") { - if (!cachedAPI) return; // Hacky... 23.04.21.14.59 - const $api = cachedAPI; - if (content !== inFocus) { - inFocus = content; - const data = {}; - Object.assign(data, { - device: "none", - is: (e) => e === (inFocus === true ? "focus" : "defocus"), - }); - $api.event = data; - try { - act($api); - } catch (e) { - console.warn("️ ✒ Act failure...", e); - } - } - } - - if (type === "visibility-change") { - // 🧨 Just in case of a regression... 23.06.02.21.12 - // Because the `bios` focus event changed from visibility behavior. - // if (!lastActAPI) return; // Hacky... 23.04.21.14.59 - // const $api = lastActAPI; // Focus change events have an empty API. - // if (content !== inFocus) { - // inFocus = content; - // const data = {}; - // Object.assign(data, { - // device: "none", - // is: (e) => e === (inFocus === true ? "focus" : "defocus"), - // }); - // $api.event = data; - // try { - // act($api); - // } catch (e) { - // console.warn("️ ✒ Act failure...", e); - // } - // } - - // Reset KidLisp timing state when becoming visible again - // This prevents animation "catch up" after tab was hidden - // Skip reset for pieces with background mode — they kept running while hidden. - if (content === true && visible === false && globalKidLispInstance && !pieceBackground) { - // Becoming visible - reset timing expressions - globalKidLispInstance.lastSecondExecutions = {}; - globalKidLispInstance.instantTriggersExecuted = {}; - if (globalKidLispInstance.sequenceCounters) { - globalKidLispInstance.sequenceCounters.clear(); - } - } - - visible = content; - } - - if (type === "before-unload") { - // This has to be synchronous (no workers) to work, and is also often unreliable. - // I should not design around using this event, other than perhaps - // sending a beacon at the end. 22.11.03.14.53 - // See also: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon - - /* - try { - leave({ store, screen, system: $commonApi.system }); // Trigger leave. - } catch (e) { - console.warn("👋 Leave failure...", e); - } - */ - return; - } - - // Get the updated device motion. - if (type === "motion:update") { - $commonApi.motion.on = true; - $commonApi.motion.current = content; - return; - } - - if (type === "motion:enabled") { - $commonApi.motion.on = true; - return; - } - - // 🔷 Tezos wallet state updates from bios.mjs - if (type === "wallet:state") { - // Update the wallet state from bios (full state broadcast) - Object.assign($commonApi.wallet._state, content); - // Clear any error after successful update - if (content.connected) delete $commonApi.wallet._state.error; - log.wallet.debug("Wallet state updated:", $commonApi.wallet._state); - return; - } - - // 🔷 Wallet pairing URI response (for mobile QR code) - if (type === "wallet:pairing-uri-response") { - if ($commonApi.wallet._pairingCallback) { - $commonApi.wallet._pairingCallback(content); - $commonApi.wallet._pairingCallback = null; - } - return; - } - - // 🔷 Wallet sign response - if (type === "wallet:sign-response") { - if ($commonApi.wallet._signCallback) { - $commonApi.wallet._signCallback(content); - $commonApi.wallet._signCallback = null; - } - return; - } - - if (type === "gpu-rendered-once") { - $commonApi.gpuReady = true; - return; - } - - if (type === "gpu-forms-removed") { - // Delete forms from the sent list that have been removed from the GPU scene. - content.forEach((id) => { - formsToClear.push(id); - }); - return; - } - - if (type === "dark-mode") { - darkMode(content.enabled); - return; - } - - if (type === "gpu-config") { - graph.setGpuConfig(content); - return; - } - - if (type === "forms:baked") { - //console.log("🍞 Forms baked:", content); - //noPaint = false; - - // if (content.pixels) { - // graph.paste(content, 0, 0, 1, true); - // } - - // paintFormsResolution?.(); - return; - } - - // Media Recorder Events - - if (type === "recorder:transcode-progress") { - console.log("📼 🚨 DISK.MJS caught transcode-progress EARLY - updating rec.printProgress:", content); - $commonApi.rec.printProgress = content; - if (content === 1) { - send({ type: "signal", content: "recorder:transcoding-done" }); - // TODO: Is this the best place for this signal to be sent? - // Maybe it should go back in the BIOS? 22.08.19.13.44 - } - // DON'T return here - let it continue to the piece forwarding logic below! - // return; // ❌ REMOVED - this was preventing messages from reaching the piece - } - - // Forward recorder export events to the current piece, but only when not in export mode - // During export (when actEvents is undefined), let events pass through directly to main thread - if ( - type === "recorder:export-status" || - type === "recorder:export-progress" || - type === "recorder:transcode-progress" || - type === "recorder:export-complete" || - type === "tape:posted" || - type === "tape:post-error" || - type === "tape:download-progress" || - type === "tape:load-progress" || - type === "tape:preloaded" || - type === "tape:preload-error" || - type === "tape:frames" || - type === "tape:audio-context-state" || - type === "tape:playback-progress" - ) { - // Check if actEvents exists without causing ReferenceError - try { - const hasActEvents = typeof actEvents !== 'undefined' && actEvents && Array.isArray(actEvents); - - // If actEvents is available, route to the piece as normal - if (hasActEvents) { - const event = { - is: (eventType) => eventType === type, - type: type, - content: content, - progress: type === "recorder:transcode-progress" ? content : undefined - }; - - actEvents.push(event); - if (debug) console.log("📼 ✅ Used actEvents for export event:", type); - return; // Don't let it pass through to main thread - } - } catch (e) { - // actEvents doesn't exist - that's fine, we'll call receive directly - } - - // If actEvents isn't available, try calling the piece's receive function directly - // But only if the piece is booted AND has a custom receive (not the default) - if (typeof receive === "function" && booted && receive !== defaults.receive) { - const event = { - is: (eventType) => eventType === type, - type: type, - content: content, - progress: type === "recorder:transcode-progress" ? content : undefined - }; - try { - if (!getPackMode()) console.log("📼 ✅ Calling receive directly for export event:", type); - receive(event); - return; // Successfully delivered to piece - } catch (e) { - console.warn("📼 ❌ Error calling receive:", e); - } - } else { - // Log why we're NOT calling receive - if (!getPackMode()) { - const reasons = []; - if (typeof receive !== "function") reasons.push("receive not a function"); - if (!booted) reasons.push("not booted"); - if (receive === defaults.receive) reasons.push("using default receive"); - console.log(`📼 ⏸️ Cannot call receive for ${type}: ${reasons.join(", ")}`); - } - } - - // If we can't deliver it (piece not booted or using default receive), queue it for later - const event = { - is: (eventType) => eventType === type, - type: type, - content: content, - progress: type === "recorder:transcode-progress" ? content : undefined - }; - pendingExportEvents.push(event); - if (!getPackMode()) { - console.log("📼 📥 Queued export event for later delivery:", type, "queue size:", pendingExportEvents.length); - } - return; - } - - if ( - type === "recorder:rolling:started" || - type === "recorder:rolling:resumed" - ) { - $commonApi.rec.recording = true; - $commonApi.rec.cleanMode = content.cleanMode || false; - $commonApi.rec.rollingCallback?.(content.time); - return; - } - - if (type === "recorder:rolling:ended") { - $commonApi.rec.recording = false; - $commonApi.rec.recorded = true; // Also cleared when a recording "slates". - // Don't reset cleanMode here - it's needed during GIF export/playback - - if ($commonApi.rec.cutCallback) { - try { - $commonApi.rec.cutCallback(); - // Clear the callback after execution to prevent duplicate calls - $commonApi.rec.cutCallback = null; - } catch (error) { - console.error(`🎬 ❌ Error in cutCallback:`, error); - console.error(`🎬 ❌ Stack trace:`, error.stack); - } - } else { - console.warn(`🎬 ⚠️ No cutCallback available when rolling ended`); - // Fallback: try to jump to video directly if no callback is set - $commonApi.jump("video"); - } - return; - } - - if (type === "recorder:printing:started") { - return; - } - - if (type === "recorder:printed") { - $commonApi.rec.printed = true; - $commonApi.rec.printCallback?.(content); - return; - } - - if (type === "recorder:presented") { - $commonApi.rec.presenting = true; - $commonApi.rec.presentProgress = 0; - $commonApi.rec.tapeProgress = 0; - return; - } - - if (type === "recorder:presented:failure") { - $commonApi.rec.presenting = false; - $commonApi.rec.presentProgress = 0; - $commonApi.rec.tapeProgress = 0; - return; - } - - if (type === "recorder:frames-response") { - // First chunk - initialize accumulation - if (content.totalChunks > 1) { - console.log(`📦 Receiving chunked frames: chunk 1/${content.totalChunks}`); - $commonApi.rec.frameChunks = [content.frames]; - $commonApi.rec.totalChunks = content.totalChunks; - $commonApi.rec.receivedChunks = 1; - $commonApi.rec.rawAudio = content.rawAudio; - } else { - // Single chunk, call callback immediately - $commonApi.rec.framesCallback?.(content); - } - return; - } - - if (type === "recorder:frames-chunk") { - // Subsequent chunks - accumulate - if ($commonApi.rec.frameChunks) { - $commonApi.rec.frameChunks.push(content.frames); - $commonApi.rec.receivedChunks++; - - console.log(`📦 Received chunk ${$commonApi.rec.receivedChunks}/${$commonApi.rec.totalChunks}`); - - // All chunks received - combine and call callback - if ($commonApi.rec.receivedChunks === $commonApi.rec.totalChunks) { - const allFrames = $commonApi.rec.frameChunks.flat(); - console.log(`✅ All ${$commonApi.rec.totalChunks} chunks received, total frames: ${allFrames.length}`); - - $commonApi.rec.framesCallback?.({ - frames: allFrames, - rawAudio: $commonApi.rec.rawAudio - }); - - // Clean up - delete $commonApi.rec.frameChunks; - delete $commonApi.rec.totalChunks; - delete $commonApi.rec.receivedChunks; - delete $commonApi.rec.rawAudio; - } - } - return; - } - - if (type === "recorder:present-progress") { - $commonApi.rec.presentProgress = content; - return; - } - - if (type === "recorder:present-playing") { - $commonApi.rec.playing = true; - return; - } - - if (type === "recorder:present-paused") { - $commonApi.rec.playing = false; - return; - } - - if (type === "recorder:unpresented") { - $commonApi.rec.presenting = false; - $commonApi.rec.presentProgress = 0; - $commonApi.rec.tapeProgress = 0; - return; - } - - if (type === "signal") { - signals.push(content); - return; - } - - if (type === "piece:act-alert") { - if (content !== undefined && content !== null) { - if (typeof content === "string") { - actAlerts.push(content); - } else if (typeof content.name === "string" && content.name.length > 0) { - const { name, ...extra } = content; - if (keys(extra).length > 0) { - actAlerts.push({ name, ...extra }); - } else { - actAlerts.push(name); - } - } - } - return; - } - - if (type === "store:retrieved") { - storeRetrievalResolutions[content.key]?.(content.data); - delete storeRetrievalResolutions[content.key]; - return; - } - - if (type === "store:deleted") { - storeDeletionResolutions[content.key]?.(content.data); - delete storeDeletionResolutions[content.key]; - return; - } - - // USB flash support (Electron only) - if (type === "usb:devices-listed") { - if (usbDeviceListResolution) { - usbDeviceListResolution(content); - usbDeviceListResolution = null; - } - return; - } - - if (type === "usb:flash-progress") { - actAlerts.push({ name: "usb:flash-progress", ...content }); - return; - } - - if (type === "usb:flash-complete") { - if (usbFlashResolution) { - usbFlashResolution({ success: true, ...content }); - usbFlashResolution = null; - } - return; - } - - if (type === "usb:flash-error") { - if (usbFlashResolution) { - usbFlashResolution({ success: false, error: content.error }); - usbFlashResolution = null; - } - return; - } - - if (type === "content-created") { - $commonApi.content.receive(content); - return; - } - - if (type === "leave") { - //const $api = {}; - console.log("🏃‍♂️ Leave:", content); - return; - } - - if (type === "sfx:killed") { - sfxKillReceivers[content.id]?.(); - return; - } - - if (type === "sfx:got-sample-data") { - sfxSampleReceivers[content.id]?.(content.data); - return; - } - - if (type === "sfx:progress:report") { - sfxProgressReceivers[content.id]?.(content); // Resolve the progress report. - return; - } - - if (type === "sfx:got-duration") { - sfxDurationReceivers[content.id]?.(content.duration); - return; - } - - // 🎵 Streaming Audio messages - forward to piece receive function - if (type === "stream:playing" || - type === "stream:paused" || - type === "stream:stopped" || - type === "stream:error" || - type === "stream:frequencies-data") { - if (typeof receive === "function") { - try { - receive({ type, content }); - } catch (e) { - console.warn("🎵 Stream receive error:", e); - } - } - return; - } - - if (type === "microphone-amplitude") { - microphone.amplitude = content; - return; - } - - if (type === "microphone-waveform") { - microphone.waveform = content; - return; - } - - if (type === "microphone-recording-buffer") { - microphone.recordingBuffer = content; - return; - } - - if (type === "waveforms") { - speaker.waveforms = content; - return; - } - - if (type === "amplitudes") { - speaker.amplitudes = content; - return; - } - - if (type === "frequencies") { - speaker.frequencies = content; - // Extract beat data if it exists in the content - if (content.beat) { - speaker.beat = content.beat; - } - return; - } - - // Handle Game Boy frame data - if (type === "gameboy:frame-data") { - // Store frame data and metadata in gameboy API object - gameboy.frame = content.pixels; - gameboy.romName = content.romName; - gameboy.title = content.title; - gameboy.isGameBoyColor = content.isGameBoyColor; - gameboy.isPlaying = true; - - return; - } - - if (type === "microphone-pitch") { - microphone.pitch = content; - return; - } - - if (type === "microphone-recording:complete") { - microphone.recordingPromise?.resolve(content); - return; - } - - if (type === "microphone-connect:success") { - microphone.connected = true; - actAlerts.push("microphone-connect:success"); - return; - } - - if (type === "microphone-connect:failure") { - microphone.connected = false; - actAlerts.push({ name: "microphone-connect:failure", ...content }); - return; - } - - // 1a. Import // One send (returns afterwards) - // Here we are receiving file data from main thread that was requested - // by $api.upload 😱. We check to see if the upload promise exists and then - // use it and/or throw it away. - if (type === "import" && fileImport) { - if (content.result === "success") { - fileImport?.resolve(content.data); - } else if (content.result === "error") { - console.error("File failed to load:", content.data); - fileImport?.reject(content.data); - } - fileImport = undefined; - return; - } - - // Resolve a web3 connection message. - if (type === "web3-connect-response" && web3Response) { - if (content.result === "success") { - web3Response?.resolve(content.id); - } else if (content.result === "error") { - web3Response?.reject("error"); - } - web3Response = undefined; - return; - } - - // Resolve Tezos wallet connection - if (type === "tezos-connect-response" && tezosConnectResponse) { - if (content.result === "success") { - tezosConnectResponse?.resolve(content.address); - } else { - tezosConnectResponse?.reject(content.error || "Connection failed"); - } - tezosConnectResponse = undefined; - return; - } - - // Resolve Tezos wallet disconnection - if (type === "tezos-disconnect-response" && tezosDisconnectResponse) { - if (content.result === "success") { - tezosDisconnectResponse?.resolve(); - } else { - tezosDisconnectResponse?.reject(content.error || "Disconnect failed"); - } - tezosDisconnectResponse = undefined; - return; - } - - // Resolve Tezos address request - if (type === "tezos-address-response" && tezosAddressResponse) { - if (content.result === "success") { - tezosAddressResponse?.resolve(content.address); - } else { - tezosAddressResponse?.reject(content.error || "Failed to get address"); - } - tezosAddressResponse = undefined; - return; - } - - // Resolve Tezos message signing - if (type === "tezos-sign-response" && tezosSignResponse) { - if (content.result === "success") { - tezosSignResponse?.resolve(content.signature); - } else { - tezosSignResponse?.reject(content.error || "Signing cancelled"); - } - tezosSignResponse = undefined; - return; - } - - // Resolve Tezos contract call - if (type === "tezos-call-response" && tezosCallResponse) { - if (content.result === "success") { - tezosCallResponse?.resolve(content.opHash); - } else { - tezosCallResponse?.reject(content.error || "Contract call failed"); - } - tezosCallResponse = undefined; - return; - } - - // Resolve a gpu message - if (type === "gpu-response" && gpuResponse) { - if (content.result === "success") { - gpuResponse?.resolve(content.data); - } else if (content.result === "error") { - gpuResponse?.reject(content.data); - } - gpuResponse = undefined; - return; - } - - // Resolve a server uploaded file. - if (type === "upload" && serverUpload) { - if (content.result === "success") { - // 🗄️ After S3 upload succeeds, create MongoDB record with short code - // Extract extension from filename to determine media type - const filename = content.data.filename || ""; - const ext = filename.split(".").pop(); - - // Only track paintings and pieces in database - if (ext === "png" || ext === "mjs" || ext === "lisp" || ext === "lua") { - try { - // Call track-media POST to create database record with short code - const trackResponse = await $commonApi.net.userRequest("POST", "/api/track-media", { - slug: content.data.slug, - ext: ext, - }); - - if (trackResponse.status === 401) { - // Session expired - trigger logout - const errorData = await trackResponse.json(); - if (errorData.code === "SESSION_EXPIRED") { - console.warn("🔒 Session expired, logging out..."); - await $commonApi.net.logout(); - console.log("✅ Logged out due to expired session"); - } - return; // Stop processing this upload - } - - if (trackResponse.status === 200) { - const trackData = await trackResponse.json(); - // Add the short code to the response data - content.data.code = trackData.code; - console.log(`✅ ${ext === "png" ? "Painting" : "Piece"} tracked: ${content.data.slug} → #${trackData.code}`); - - // Fire off ATProto sync (don't await - let it happen in background) - if (ext === "png") { - $commonApi.net.userRequest("POST", "/api/sync-painting-atproto", { - slug: content.data.slug, - code: trackData.code, - paintingId: trackData.paintingId, - bucket: content.data.bucket || "user-aesthetic-computer", - }).then(response => { - if (response.status === 200) { - console.log("✅ ATProto sync initiated for", content.data.slug); - } - }).catch(err => { - console.warn("⚠️ ATProto sync failed (non-critical):", err); - }); - } - } else { - console.warn(`⚠️ Failed to track media in database:`, trackResponse.status); - } - } catch (err) { - console.error("❌ Error tracking media in database:", err); - // Don't fail the upload if database tracking fails - } - } - - serverUpload?.resolve(content.data); - } else if (content.result === "error") { - console.error("File failed to load:", content); - serverUpload?.reject(content.data); - } - serverUpload = undefined; - return; - } - - if (type === "zipped" && zipCreation) { - if (content.result === "success") { - zipCreation.resolve(content.data); - } else if (content.result === "error") { - console.error("Zip failed to be created:", content); - zipCreation?.reject(content.data); - } - zipCreation = undefined; - return; - } - - // Run when a painting record ZIP is succesfully parsed after being - // dragged into the A.C window. - if (type === "painting:record:dropped") { - // Replace the active nopaint record with the loaded one. - // $commonApi.system.nopaint.recording = true; - $commonApi.system.nopaint.record = content; - if ($commonApi.slug !== "painting") $commonApi.jump("painting"); - return; - } - - // Resolve a locally requested file. - if (type === "file-open:response" && fileOpenRequest) { - if (content.result === "success") { - fileOpenRequest?.resolve(content.data); - } else if (content.result === "error") { - console.error("Failed to open file.", content); - fileOpenRequest?.reject(content.data); - } - fileOpenRequest = undefined; - return; - } - - // Resolve a file encoding request. - if (type === "file-encode:response" && fileEncodeRequest) { - if (content.result === "success") { - fileEncodeRequest?.resolve(content.data); - } else if (content.result === "error") { - console.error("Failed to encode file.", content); - fileEncodeRequest?.reject(content.data); - } - fileEncodeRequest = undefined; - return; - } - - // Resolve an authorization request. - if (type === "authorization:response" && authorizationRequest) { - if (content.result === "success") { - authorizationRequest?.resolve(content.data); - } else if (content.result === "error") { - authorizationRequest?.reject(content.data); - } - authorizationRequest = undefined; - return; - } - - // 1b. Video frames. - if (type === "video-frame") { - if (!videoSwitching) activeVideo = content; - return; - } - - if (type === "video-devices") { - videoDeviceCount = content; - $commonApi.cameras = videoDeviceCount; - return; - } - - if (type === "camera:updated") { - videoSwitching = false; - actAlerts.push("camera:mode:" + content); - return; - } - - if (type === "camera:denied") { - actAlerts.push("camera:denied"); - return; - } - - // 1c. Loading from History - if (type === "history-load") { - if (debug && logs.history) console.log("⏳ History:", content); - $commonApi.load(content, true); - return; - } - - // 1d. Loading Bitmaps - if (type === "loaded-bitmap-success") { - if (debug) console.log("🖼️ Bitmap loaded:", content); - preloadPromises[content.url]?.resolve(content); - delete preloadPromises[content]; - return; - } - - if (type === "loaded-bitmap-rejection") { - if (debug) console.error("🖼️ Bitmap load failure:", content); - preloadPromises[content.url]?.reject(content.url); - delete preloadPromises[content.url]; - return; - } - - // 1d2. Loading Animated WebP - if (type === "loaded-animated-webp-success") { - console.log("🎬 Animated WebP loaded:", content.frameCount, "frames"); - preloadPromises[content.url]?.resolve(content); - delete preloadPromises[content.url]; - return; - } - - if (type === "loaded-animated-webp-rejection") { - console.error("🎬 Animated WebP load failure:", content); - preloadPromises[content.url]?.reject(content.error); - delete preloadPromises[content.url]; - return; - } - - // 1e. Loading Sound Effects - if (type === "loaded-sfx-success") { - if (debug && logs.audio) console.log("Sound load success:", content); - preloadPromises[content.sfx]?.resolve(content.sfx); - delete preloadPromises[content]; - return; - } - - if (type === "loaded-sfx-rejection") { - if (debug && logs.audio) console.error("Sound load failure:", content); - preloadPromises[content.sfx]?.reject(content.sfx); - delete preloadPromises[content.sfx]; - return; - } - - // 1f. Loading ZIP files. - if (type === "loaded-zip-success") { - if (debug) console.log("🤐 Zip load success:", content.url); - preloadPromises[content.url]?.resolve(content.data); - delete preloadPromises[content.url]; - return; - } - - if (type === "loaded-zip-rejection") { - if (debug) console.warn("🤐 Zip load failure:", content.url); - preloadPromises[content.url]?.reject(content.url); - delete preloadPromises[content.url]; - return; - } - - // Request a repaint (runs when the window is resized.) - if (type === "needs-paint") { - noPaint = false; - return; - } - - if (type === "reframed") { - // Always update the currentDisplay settings for synchronous - // screen buffer updates. - currentDisplay = { - width: content.innerWidth, - height: content.innerHeight, - subdivisions: content.subdivisions, - }; - $commonApi.display = currentDisplay; - - // IMPORTANT: Update screen dimensions immediately so the next paint uses correct size - if (content.width !== undefined && content.height !== undefined && screen) { - const oldWidth = screen.width; - const oldHeight = screen.height; - const oldBufferSize = screen.pixels.length; - - screen.width = content.width; - screen.height = content.height; - // Create fresh buffer - bios.mjs freeze frame provides visual continuity - // The piece's paint() will fill correct content on next frame - screen.pixels = new Uint8ClampedArray(content.width * content.height * 4); - - if ($commonApi.rec.presenting && (oldWidth !== content.width || oldHeight !== content.height)) { - console.log('📐 REFRAME: Worker dimensions updated', oldWidth, 'x', oldHeight, '→', content.width, 'x', content.height); - console.log(' Buffer:', oldBufferSize, '→', screen.pixels.length, '| Expected:', content.width * content.height * 4); - } - } - - // Only trigger a reframe event if we have already passed `boot` (painted - // at least once) - if (booted) { - reframed = true; - formReframing = true; - } - return; - } - - // Handle QR code fullscreen toggle from hitbox taps - if (type === "qr:toggle-fullscreen") { - toggleQRFullscreen(); - return; - } - - // 1. Beat - if (type === "beat") { - if (!sound) return; // Just in case no `frame` has been sent yet. - try { - beat($activePaintApi); - } catch (e) { - console.warn(" 💗 Beat failure...", e); - } - - send({ type: "beat", content: sound }); - // soundClear?.(); - sound.sounds.length = 0; // Empty the sound command buffer. - sound.bubbles.length = 0; - sound.kills.length = 0; - return; - } - - // 2. Frame - // Where each piece action (boot, sim, paint, etc...) is run. - if (type === "frame") { - // Update loop paused state from bios - loopPaused = content.paused || false; - - // Take hold of a previously worker transferrable screen buffer - // and re-assign it. - let pixels; - if (content.pixels && screen) { - pixels = new Uint8ClampedArray(content.pixels); - // Only use the transferred buffer if it matches current screen dimensions - // After a reframe, the buffer from bios may have old dimensions - const expectedLength = screen.width * screen.height * 4; - if (pixels.length === expectedLength) { - screen.pixels = pixels; - } else { - if ($commonApi.rec.presenting) { - console.log('⚠️ FRAME: Ignoring mismatched buffer from bios. Got:', pixels.length, '| Expected:', expectedLength, '| Keeping reframed buffer'); - } - // Keep the current screen.pixels buffer (from reframe) - } - } - - function getHudEditScrubThreshold() { - const promptLength = Math.max( - 1, - (currentHUDPlainTxt || currentHUDTxt || currentText || "").length, - ); - const blockWidth = - currentHUDLabelBlockWidth || tf?.blockWidth || DEFAULT_TYPEFACE_BLOCK_WIDTH; - const baseCaretWidth = blockWidth + 2; - const maxCaretWidth = blockWidth * 3; - if (promptLength <= 4) return baseCaretWidth; - if (promptLength <= 8) return Math.floor(baseCaretWidth * 1.5); - return Math.min(maxCaretWidth, Math.floor(promptLength * blockWidth * 0.25)); - } - - function triggerBackspaceAction(overrideContent) { - $commonApi.sound.synth({ - tone: 800, - beats: 0.1, - attack: 0.01, - decay: 0.5, - volume: 0.15, - }); - send({ type: "keyboard:unlock" }); - - const content = - overrideContent || currentHUDPlainTxt || currentHUDTxt || currentText; - let promptSlug = "prompt"; - if (content) { - if (lisp.isKidlispSource(content)) { - promptSlug += "~" + lisp.encodeKidlispForUrl(content); - } else { - promptSlug += "~" + content.replace(/~/g, " "); - } - } - - $commonApi.jump(promptSlug); - send({ type: "keyboard:open" }); - currentHUDScrub = 0; - currentHUDScrubReadyState = null; - $commonApi.needsPaint?.(); - } - - function triggerShareAction() { - $commonApi.sound.synth({ - tone: 1800, - beats: 0.15, - attack: 0.01, - decay: 0.5, - volume: 0.15, - }); - $commonApi.sound.synth({ - tone: 1800 / 2, - beats: 0.15 * 2, - attack: 0.01, - decay: 0.5, - volume: 0.1, - }); - - // Prefer existing cached short codes so we don't re-embed long inline source - const cachedShortCode = getCachedCode(currentCode); - if (cachedShortCode) { - $commonApi.jump("share~$" + cachedShortCode); - } else if (currentOriginalCodeId && currentOriginalCodeId.startsWith("$")) { - $commonApi.jump("share~" + currentOriginalCodeId); - } else { - const textToShare = currentHUDPlainTxt || currentHUDTxt || currentText || ""; - $commonApi.jump("share~" + lisp.encodeKidlispForUrl(textToShare)); - } - currentHUDScrub = 0; - currentHUDScrubReadyState = null; - $commonApi.needsPaint?.(); - } - - // 🌟 Global Keyboard Shortcuts (these could also be seen via `act`) - content.keyboard.forEach((data) => { - if (currentText && currentText.indexOf("botce") > -1) return; // No global keys on `botce`. 23.11.12.23.38 - - // 📼 [Ctrl+R+R] Quick tape - start recording without leaving the piece - // Handle this BEFORE the keyboard:down check since it's a special event - if (data.name === "keyboard:quick-tape" && !getPackMode()) { - console.log("📼 Quick tape: Handler triggered!"); - console.log("📼 Quick tape: currentText =", currentText); - console.log("📼 Quick tape: currentParams =", currentParams); - console.log("📼 Quick tape: $commonApi.rec =", $commonApi.rec); - - // Play a sound to indicate recording started - $commonApi.sound.synth({ - tone: 2000, - duration: 0.05, - attack: 0.01, - decay: 0.5, - volume: 0.25, - }); - - // Set up the recording with default 7 second duration - const defaultDuration = 7; - const kidlispFps = (typeof window !== 'undefined' && window.currentKidlispFps) || null; - - console.log("📼 Quick tape: About to call rec.rolling()..."); - - // Set up recording options and callback - $commonApi.rec.rolling( - { - type: "video", - pieceName: currentText || "quick-tape", - pieceParams: currentParams ? currentParams.join("~") : "", - originalCommand: currentText, - intendedDuration: defaultDuration, - frameMode: false, - frameCount: null, - kidlispFps: kidlispFps, - cleanMode: false, - showTezosStamp: false, - mystery: false - }, - (time) => { - // This callback is invoked after recording starts - console.log(`📼 Quick tape: Timer callback invoked! time=${time}`); - $commonApi.rec.tapeTimerSet(defaultDuration, time, false); - console.log(`📼 Quick tape: Timer set complete`); - }, - ); - - console.log("📼 Quick tape: rec.rolling() called"); - - // Mark as recording - $commonApi.rec.recording = true; - - console.log(`📼 Quick tape: Recording initiated! Duration=${defaultDuration}s`); - return; // Don't process this event further - } - - if (data.name.indexOf("keyboard:down") === 0) { - // [Escape] (Deprecated on 23.05.22.19.33) - // If not on prompt, then move backwards through the history of - // previously loaded pieces in a session. - // if ( - // data.key === "Escape" && - // currentPath !== "aesthetic.computer/disks/prompt" - // ) { - // if (pieceHistoryIndex > 0) { - // send({ type: "back-to-piece" }); - // } else { - // // Load the prompt automatically. - // // $api.load("prompt"); Disabled on 2022.05.07.03.45 - // } - // } - - if ((data.key === "$" || data.key === "Home") && !getPackMode()) { - if (data.ctrl || data.alt) { - const sys = $commonApi.system; - // Make it a painting. - sys.nopaint.replace( - cachedAPI, - graph.cloneBuffer(screen), - "$creenshot", - ); - $commonApi.jump("prompt"); - } else { - downloadScreenshot(); // 🖼️ Take a screenshot. - } - $commonApi.sound.synth({ - tone: 1600, - duration: 0.02, - attack: 0.01, - decay: 0.5, - volume: 0.25, - }); - } - - // ⛈️ Jump back to the `prompt` from anywhere.. - if ( - !getPackMode() && // Disable navigation keys in OBJKT mode - !SOLO_MODE && // Disable navigation keys in solo mode - (data.key === "`" || - data.key === "Enter" || - data.key === "Backspace" || - data.key === "Escape") && - system !== "prompt" && - system !== "world" && - currentText !== "chat" && - currentText !== "laer-klokken" && - currentText !== "sign" && - currentText !== "jas" && - currentPath !== "aesthetic.computer/disks/prompt" - ) { - // If recording, Escape should stop the tape instead of jumping to prompt - if (data.key === "Escape" && $commonApi.rec.recording) { - console.log("🎬 Escape pressed during recording - stopping tape"); - - // Cut the recording first - $commonApi.rec.cut(); - - // Play the sound AFTER a brief delay so it doesn't get recorded - setTimeout(() => { - $commonApi.sound.synth({ - tone: 600, - duration: 0.15, - attack: 0.01, - decay: 0.8, - volume: 0.2, - }); - }, 100); // 100ms delay to ensure recording has stopped - - return; // Don't proceed with navigation - } - // 🎬 Stop demoplay on Escape - if (data.key === "Escape" && typeof $commonApi.system.stopDemoplay === "function") { - leaving = false; - $commonApi.system.stopDemoplay(); - return; - } - // Stop merry pipeline for both Escape and Backspace - let merryOriginalCommand = null; - if (data.key === "Escape" || data.key === "Backspace") { - const stopMerry = $commonApi.system.stopMerryPipeline; - if (typeof stopMerry === "function") { - // Capture original command before stopping (for backspace editing) - if (data.key === "Backspace" && $commonApi.system.merry?.originalCommand) { - merryOriginalCommand = $commonApi.system.merry.originalCommand; - } - // Reset leaving flag to allow merry stop to jump - leaving = false; - stopMerry({ - reason: data.key === "Escape" ? "escape" : "backspace", - jumpAfter: false, - jumpTarget: null, - cutTape: true, - }); - } else if ($commonApi.system.merry) { - $commonApi.system.merry.running = false; - delete $commonApi.system.merry; - } - } - - $commonApi.sound.synth({ - tone: data.key === "Backspace" ? 800 : 1200, - beats: 0.1, - attack: 0.01, - decay: 0.5, - volume: 0.15, - }); - - send({ type: "keyboard:unlock" }); - - // 🌐 Branded domain: Escape/backtick/Enter navigates to aesthetic.computer - if (data.key !== "Backspace" && redirectIfBrandedDomain()) return; - - if (!labelBack || data.key === "Backspace" || data.key === "Escape") { - let promptSlug = "prompt"; - if (data.key === "Backspace") { - triggerBackspaceAction(merryOriginalCommand); - } else { - $commonApi.jump(promptSlug); - send({ type: "keyboard:open" }); - } - } else { - if ($commonApi.history.length > 0) { - send({ type: "back-to-piece" }); - // $commonApi.jump( - // $commonApi.history[$commonApi.history.length - 1], - // ); - } else { - $commonApi.jump(promptSlug); - send({ type: "keyboard:open" }); - } - } - } - - // [Tab] Toggle HUD label and QR overlay visibility with smooth animation - // Disabled in pack mode (OBJKT bundles) - // Only works in KidLisp pieces (to avoid conflict with autocomplete) - if (data.key === "Tab" && !getPackMode()) { - const isKidLispPiece = detectKidLispPiece({ currentPath, currentHUDTxt, currentText, cleanText: currentHUDTxt }); - if (isKidLispPiece) { - const currentTime = performance.now(); - const timeSinceLastTab = currentTime - (hudAnimationState.lastTabTime || 0); - const isDoubleTap = timeSinceLastTab < 300; // 300ms double-tap window - - hudAnimationState.lastTabTime = currentTime; - // Skip sound only for embedded/inline KidLisp (kidlisp.com editor, PJ KidLisp) - const isEmbedded = detectEmbeddedKidLisp({ currentPath, currentHUDTxt, currentText }); - toggleHUDVisibility(isDoubleTap, isEmbedded); - } - } - - // [Shift] Toggle QR code fullscreen mode for KidLisp pieces - // if (data.key === "Shift") { - // console.log("⌨️ [Shift Key] Received! getPackMode():", getPackMode()); - // } - if (data.key === "Shift" && !getPackMode()) { - toggleQRFullscreen(); - } - - // [Ctrl + X] - // Enter and exit fullscreen mode. - if (data.key === "x" && data.ctrl && currentText !== "notepat" && !getPackMode()) { - send({ type: "fullscreen-enable" }); - } - - // [Ctrl + D] - // Toggle auto-density override — force full density even when FPS is low. - if (data.key === "d" && data.ctrl && globalKidLispInstance) { - if (globalKidLispInstance.autoDensityOverride) { - // Re-enable auto-density - globalKidLispInstance.autoDensityOverride = false; - if (typeof window !== 'undefined') window.acAutoDensityOverride = false; - globalKidLispInstance.enableAutoDensity(); - } else { - // Force full density — disable auto-scaling - globalKidLispInstance.autoDensityOverride = true; - if (typeof window !== 'undefined') window.acAutoDensityOverride = true; - globalKidLispInstance.disableAutoDensity(); - } - } - } - }); - - // Add 'loading' status to $commonApi. - $commonApi.loading = loading; // Let the piece know if we are already - // loading another piece. - - // Globalize any background music data, retrievable via bgm.data - $commonApi.bgm.data = { - amplitude: content.audioMusicAmplitude, - sample: content.audioMusicSampleData, - }; - - // Synthetic audio for headless captures — feed KidLisp audio globals - if (SPOOF_AUDIO_MODE && content.audioMusicAmplitude > 0) { - const norm = content.audioMusicAmplitude / 255; // 0–1 - updateKidLispAudio({ - amp: norm * 10, - leftAmp: norm * 10, - rightAmp: norm * 10, - __source: "spoof", - }); - } - - // Hand-tracking - if (content.hand) $commonApi.hand = { mediapipe: content.hand }; - - // Pens - if (content.pen) { - const primaryPointer = help.findKeyAndValue( - content.pen.pointers, - "isPrimary", - true, - ); - - // Returns all [pens] if n is undefined, or can return a specific pen by 1 based index. - // [pens] are sorted by `pointerIndex` - - // TODO: Including "help.findKeyAndValue" seems to bring a lot of - // allocation here because it keeps the whole API around? - // Re-test this when pointers is not empty! 22.11.12.20.02 - const pointers = content.pen.pointers; - const pointersValues = Object.values(pointers); - - // Make all available dragBoxes into `Box` instances. - pointersValues.forEach((p) => { - if (p.dragBox) p.dragBox = geo.Box.from(p.dragBox); - }); - - const pens = pointersValues.reduce((arr, value) => { - arr[value.pointerNumber] = value; - return arr; - }, []); - - // if (pens.length > 0 && debug) - // console.log("Pens:", pens, content.pen.events); - - $commonApi.pens = function (n) { - if (n === undefined) return pens; - return help.findKeyAndValue(pointers, "pointerNumber", n - 1) || {}; - }; - - if (pointersValues.length > 1 && primaryPointer) - primaryPointer.multipen = true; // Set a flag for multipen activity on main pen API object. - - $commonApi.pen = primaryPointer; - - if ( - screen && - primaryPointer && - (primaryPointer.delta?.x !== 0 || primaryPointer.delta?.y !== 0) - ) { - // Skip sending fairy points in KidLisp pieces. - const isKidlisp = detectKidLispPiece({ currentPath, currentHUDTxt, currentText }) || - (currentPath && currentPath.endsWith('.lisp')); - if (!isKidlisp) { - udp?.send("fairy:point", { - x: primaryPointer.x / screen.width, - y: primaryPointer.y / screen.height, - }); - } - } - } - - // 🕶️ VR Pen - $commonApi.pen3d = content.pen3d?.pen; - - // Add upload event to allow the main thread to open a file chooser. - // type: Accepts N mimetypes or file extensions as comma separated string. - // Usage: upload(".jpg").then((data) => ( ... )).catch((err) => ( ... )); - $commonApi.sideload = (type) => { - const prom = new Promise((resolve, reject) => { - fileImport = { resolve, reject }; - }); - send({ type: "import", content: type }); - return prom; - }; - - // 🤖 Sim // no send - $commonApi.seconds = function (s) { - return s * 120; // TODO: Get 120 dynamically from the Loop setting. 2022.01.13.23.28 - }; - - // 🔈 Sound - // TODO: Most of the $sound api doesn't need to be generated per - // frame. 24.01.14.15.19 - - // For reference in `freq` below. - const noteFrequencies = { - c: 16.35, - "c#": 17.32, - db: 17.32, - d: 18.35, - "d#": 19.45, - eb: 19.45, - e: 20.6, - f: 21.83, - "f#": 23.12, - gb: 23.12, - g: 24.5, - "g#": 25.96, - ab: 25.96, - a: 27.5, - "a#": 29.14, - bb: 29.14, - b: 30.87, - }; - - const $sound = { - get time() { - const currentTime = content.audioTime; - // Add comprehensive logging for audio timing (throttled to avoid spam) - // if (debug && Math.floor(currentTime * 100) % 100 === 0) { // Log every 10ms when rounded - // console.log(`🎵 AUDIO_TIME: ${currentTime.toFixed(6)}s (getter called from ${(new Error().stack.split('\n')[2] || 'unknown').trim()})`); - // } - return currentTime; - }, - // 🎹 DAW state (populated via daw:tempo, daw:transport, daw:phase, daw:samplerate messages from M4L) - // Uses persistentDawState which survives frame updates (unlike $commonApi.sound which is recreated) - get daw() { - return persistentDawState; - }, - // 🎛️ Spreadnob state (populated via spreadnob:* messages from M4L) - get spreadnob() { - return persistentSpreadnobState; - }, - // Get the bpm with bpm() or set the bpm with bpm(newBPM). - bpm: function (newBPM) { - if (newBPM) sound.bpm = newBPM; - return sound.bpm; - }, - enabled: () => { - return AUDIO_SAMPLE_RATE > 0; - }, - // Compute the frequency of a musical note. - // 🗒️ Can take a number or formatted octave string such as 5C# or even C#5 for C sharp in fifth octave. - freq: function (input) { - // console.log("🎵 Note to check:", input); - // Return if it's just a number or parses as one. - if (typeof input === "number") return input; - if (input === null || input === undefined) return null; - - const trimmed = String(input).trim(); - if (trimmed === "") return null; - - if (!isNaN(parseFloat(trimmed)) && isFinite(trimmed)) return Number(trimmed); - - let octave = 4; - let note = trimmed.toLowerCase(); // Downcase everything. - - // Handle special tokens that shouldn't resolve to a frequency - if (note === "rest" || note === "pause" || note === "_" || note === "speech") return null; - - const prefixMatch = note.match(/^(-?\d+)([a-z#/+\-]+)$/); - const suffixMatch = note.match(/^([a-z#/+\-]+)(-?\d+)$/); - - if (prefixMatch) { - octave = parseInt(prefixMatch[1], 10); - note = prefixMatch[2]; - } else if (suffixMatch) { - note = suffixMatch[1]; - octave = parseInt(suffixMatch[2], 10); - } - - // Remove trailing + or - markers and other notation artifacts - note = note.replace(/[+\-]+$/g, ""); - - // Replace notepat "s"/"f" suffixes with standard accidentals when appropriate - note = note.replace(/^([a-g])s$/, "$1#").replace(/^([a-g])f$/, "$1b"); - - const frequency = noteFrequencies[note]; // Look up freq for the note. - if (!frequency) throw new Error("Note not found in the list"); - - // Calculate the frequency for the given octave - const finalFreq = frequency * Math.pow(2, octave); - return finalFreq; - }, - // Calculate a musical note from a frequency. - note: function (frequency) { - let closestNote = "", - minDiff = Infinity; - for (let octave = 0; octave <= 8; octave++) { - for (let note in noteFrequencies) { - const noteFrequency = noteFrequencies[note] * Math.pow(2, octave); - const diff = Math.abs(frequency - noteFrequency); - if (diff < minDiff) { - minDiff = diff; - closestNote = note + octave; - } - } - } - - return closestNote.toUpperCase(); - }, - // MIDI - midi: { - connect: () => send({ type: "midi:connect" }), - // Convert MIDI note number to note string - note: function (midiNumber) { - const noteNames = [ - "C", - "C#", - "D", - "D#", - "E", - "F", - "F#", - "G", - "G#", - "A", - "A#", - "B", - ]; - const octave = floor(midiNumber / 12) - 1; - const noteIndex = midiNumber % 12; - return noteNames[noteIndex] + octave; - }, - }, - // Rendering - paint: { - bars: function paintSound( - { ink, box, screen, num }, - amplitude, - waveform, - x, - y, - width, - height, - color, - options = { noamp: false, nobounds: false }, - ) { - const yMid = round(y + (height - 2) / 2), - yMax = round((height - 2) / 2); - let lw = options.noamp ? 0 : 4; // levelWidth; - const xStep = (width - lw) / waveform.length; - - // Vertical bounds. - if (!options.nobounds) { - ink("yellow") - .line(x + lw, y, x + width - 1, y) - .line(x + lw, y + height, x + width - 1, y + height); - } - - // Level meter. - if (!options.noamp) { - ink("black").box(x, y, lw, height + 1); - ink("green").box(x, y + height, lw, -amplitude * height); - } - - // Filled waveform - const waves = waveform.map((v, i) => { - if (v < -1) v = -1; - if (v > 1) v = 1; - return [x + lw + i * xStep, yMid + v * yMax]; - }); - - ink(options.secondaryColor || "black").box( - x + lw, - y + 1, - width - lw, - height - 1, - ); - - ink(options.primaryColor || "white"); - - let remainder = 0; - let totalWidthCovered = 0; - - waves.forEach((point, index) => { - let bx = x + lw + totalWidthCovered; - if (bx >= x + width) return; - // Compute the pixel-aligned width for the current bar. - let barWidth = Math.floor(xStep + remainder); - remainder = (xStep + remainder) % 1; // Collect the fractional remainder. - // Ensure we don't exceed the full width for the last bar. - if (index === waves.length - 1 || bx + barWidth >= x + width) - barWidth = x + width - bx; - box( - bx, - y + point[1] + 1 - y, - barWidth, - y + (height - 1) - point[1], - ); - totalWidthCovered += barWidth; - }); - - // Waveform - // ink("lime", 255).poly( - // waveform.map((v, i) => [x + lw + i * xStep, yMid + v * yMax]), - // ); - - // TODO: Fill a point above this line and below. - // ink("blue").flood(x + 7, y + 1); - // ink("teal").flood(x + 7, y + height - 2); - - // const my = screen.height - mic.amplitude * screen.height; - // ink("yellow", 128).line(0, my, screen.width, my); // Horiz. line for amplitude. - }, - - // Paints a waveform with a bounding box based on amplitude. - waveform: function paintWaveform( - { ink }, - amplitude, - waveform, - x, - y, - width, - height, - color = "yellow", - options, - ) { - if (waveform?.length < 1) return; - const direction = options?.direction || "left-to-right"; - if (direction === "left-to-right") { - const xStep = width / (waveform.length - 1); - - const yMid = y + height / 2, - yMax = height / 2; - - ink(color, 128).poly( - waveform.map((v, i) => { - const p = [x + i * xStep, yMid + (v || 0) * yMax]; - return p; - }), - ); - } else if (direction === "bottom-to-top") { - const yStep = height / (waveform.length - 1); - const xMid = x + width / 2, - xMax = width; - - ink("blue", 128).poly( - waveform.map((v, i) => { - const p = [xMid + (v || 0) * xMax, y + height - i * yStep]; - return p; - }), - ); - } else { - console.warn("🌊 Unsupported direction."); - } - }, - - // Renders an "AUDIO ENGINE OFF" badge when the audio context is - // suspended. Returns { visible, x, y, width, height } so callers - // can react to its layout. - audioEngineBadge: function paintAudioEngineBadge( - { ink, screen }, - speaker, - x, - y, - options = {}, - ) { - const waveforms = speaker?.waveforms?.left; - const amp = speaker?.amplitudes?.left; - const ready = - waveforms && - typeof waveforms.length === "number" && - waveforms.length > 0 && - typeof amp === "number" && - Number.isFinite(amp); - - if (ready) return { visible: false, x: 0, y: 0, width: 0, height: 0 }; - - const maxWidth = - options.maxWidth != null ? options.maxWidth : screen.width - 20; - const padding = 4; - const badgeHeight = options.height || 12; - const align = options.align || "center"; // "center" | "left" | "right" - - // Responsive text choices - const texts = ["AUDIO ENGINE OFF", "ENGINE OFF", "OFF"]; - let chosen = texts[0]; - for (const t of texts) { - // Approximate glyph width for MatrixChunky8 is 6px per char + 1px gap - const tw = t.length * 7; - if (tw + padding * 2 <= maxWidth) { - chosen = t; - break; - } - } - const textWidth = chosen.length * 7; - const totalWidth = textWidth + padding * 2; - - let bx = x; - if (align === "center") { - bx = x != null ? x : Math.floor((screen.width - totalWidth) / 2); - } else if (align === "right") { - bx = (x != null ? x : screen.width) - totalWidth; - } - - ink(180, 0, 0, 240).box(bx, y, totalWidth, badgeHeight); - ink(255, 255, 0).write( - chosen, - { x: bx + padding, y: y + 3 }, - undefined, - undefined, - false, - "MatrixChunky8", - ); - - return { visible: true, x: bx, y, width: totalWidth, height: badgeHeight }; - }, - }, - }; - - $sound.microphone = microphone; - $sound.speaker = speaker; - $sound.gameboy = gameboy; - $sound.sampleRate = AUDIO_SAMPLE_RATE; - - // TODO: Generalize square and bubble calls. - // TODO: Move this stuff to a "sound" module. - sound.bpm = content.audioBpm; - - // Clear synchronized audio triggers. - // soundClear = () => { - // sound.sounds.length = 0; - // sound.bubbles.length = 0; - // sound.kills.length = 0; - // }; - - // Trigger a named audio sample to playback in the `bios`. - // options: { volume: 0-n, pan: 0-2?, loop: Bool, ...(there is more) } - - $sound.getSampleData = async function getSampleData(id) { - const prom = new Promise((resolve, reject) => { - sfxSampleReceivers[id] = resolve; - return { resolve, reject }; - }); - send({ type: "sfx:get-sample-data", content: { id } }); - return prom; - }; - - $sound.registerSample = function registerSample(id, data, sampleRate) { - if (!id || !data) return; - send({ type: "sfx:register", content: { id, data, sampleRate } }); - }; - - // 🔄 Live buffer update - updates sample data for currently playing sounds - $sound.updateSample = function updateSample(id, data, sampleRate) { - if (!id || !data) return; - send({ type: "sfx:update-sample", content: { id, data, sampleRate } }); - }; - - soundTime = content.audioTime; - - $sound.play = function play(sfx, options, callbacks) { - const id = sfx + "_" + $sampleCount; // A *unique id for this sample. - $sampleCount += 1n; - - send({ type: "sfx:play", content: { sfx, id, options } }); - - const playingSound = { - options, // Allow the options passed to BIOS to be inspected. - startedAt: soundTime, // performance.now(), - killed: false, - kill: (fade) => { - send({ type: "sfx:kill", content: { id, fade } }); - }, - progress: async () => { - if (playingSound.killed) return { progress: 0 }; - const prom = new Promise((resolve, reject) => { - sfxProgressReceivers[id] = (progressData) => { - // Enhanced logging for audio progress tracking - if (debug && progressData?.progress !== undefined) { - const timeElapsed = soundTime - playingSound.startedAt; - // console.log(`🎵 AUDIO_PROGRESS: id=${id.substring(id.lastIndexOf('_') + 1)}, progress=${progressData.progress.toFixed(6)}, elapsed=${timeElapsed.toFixed(3)}s, started=${playingSound.startedAt.toFixed(3)}s, current=${soundTime.toFixed(3)}s`); - - // Detect potential timing issues - compare in consistent units - if (progressData.progress > 0 && timeElapsed > 0 && progressData.duration) { - // Convert audio progress to actual time for comparison - const actualAudioTime = progressData.progress * progressData.duration; - const timeDrift = Math.abs(actualAudioTime - timeElapsed); - const driftPercent = (timeDrift / progressData.duration) * 100; - - if (driftPercent > 5) { // 5% drift threshold - console.warn(`🎵 SYNC_DRIFT: actual=${actualAudioTime.toFixed(3)}s, expected=${timeElapsed.toFixed(3)}s, drift=${timeDrift.toFixed(3)}s (${driftPercent.toFixed(1)}%)`); - } - } - } - resolve(progressData); - }; - return { resolve, reject }; - }); - send({ type: "sfx:progress", content: { id } }); - return prom; - }, - update: function (properties) { - if (properties.shift || properties.sampleSpeed !== undefined) { - send({ - type: "sfx:update", - content: { id, properties }, - }); - } - }, - }; - - sfxKillReceivers[id] = () => { - callbacks?.kill?.(); - playingSound.killed = true; - }; - - return playingSound; - }; - - $sound.skip = function () { - send({ type: "beat:skip" }); - }; - - $sound.at = function (timeToRun, callback) { - // TODO: Finish this implementation. - // timeToRun; - // content.audioTime; - }; - - $sound.getDuration = async function getDuration(id) { - const prom = new Promise((resolve, reject) => { - sfxDurationReceivers[id] = resolve; - return { resolve, reject }; - }); - send({ type: "sfx:get-duration", content: { id } }); - return prom; - }; - - $sound.synth = function synth({ - tone = 440, // hz, or musical note - type = "square", // "sine", "triangle", "square", "sawtooth", "custom" - // "noise-white" <-ignores tone - duration = 0.1, // In seconds... (where beats is a shortcut) - beats = undefined, // 🧧 Should this be deprecated? - attack = 0.01, // How quickly the sound starts. - decay = 0.9, // A multiplier for when the sound fades. - volume, - pan = 0, - generator = null, // Custom waveform generator function for type "custom" - } = {}) { - const id = soundId; - if (volume === undefined) volume = 1; - if (duration === "🔁") duration = Infinity; // First emoji in the API. 24.07.03.02.26 - if (beats === undefined && duration !== undefined) - beats = (duration * sound.bpm) / 60; - - tone = $sound.freq(tone); - // console.log("⛈️ Tone:", tone); - // Add generator to sound data for custom type - const soundData = { id, type, tone, beats, attack, decay, volume, pan }; - if (type === "custom" && generator) { - soundData.generator = generator.toString(); // Convert function to string for postMessage - } - - sound.sounds.push(soundData); - soundId += 1n; - let seconds; - if (beats === undefined && duration !== undefined) seconds = duration; - else seconds = (60 / sound.bpm) * beats; - // console.log("Beats:", beats, "Duration:", duration, "Seconds:", seconds, "BPM:", sound.bpm); - - const end = soundTime + seconds; - - return { - startedAt: soundTime, // performance.now(), - id, - kill: function (fade) { - sound.kills.push({ id, fade }); - }, - progress: function (time) { - return 1 - max(0, end - time) / seconds; - }, - update: function (properties) { - if (properties.tone) properties.tone = $sound.freq(properties.tone); - send({ - type: "synth:update", - content: { id, properties }, - }); - }, - updateGenerator: function (newGenerator) { - if (type === "custom") { - send({ - type: "update-generator", - content: { id, generator: newGenerator.toString() }, // Convert function to string - }); - } - }, - }; - }; - $sound.bubble = function ({ radius, rise, volume = 1, pan = 0 } = {}) { - const id = soundId; - sound.bubbles.push({ id, radius: radius, rise, volume, pan }); - soundId += 1n; - - return { - startedAt: soundTime, - id, - kill: function (fade) { - sound.kills.push({ id, fade }); - }, - update: function (properties) { - send({ - type: "bubble:update", - content: { id, properties }, - }); - }, - enableSustain: function () { - send({ - type: "bubble:update", - content: { id, properties: { sustain: true } }, - }); - }, - disableSustain: function () { - send({ - type: "bubble:update", - content: { id, properties: { sustain: false } }, - }); - }, - }; - }; - - $sound.kill = function (id, fade) { - sound.kills.push({ id, fade }); - }; - - // 🏠 Room/Reverb API - global reverb effect - $sound.room = { - // Toggle room reverb on/off - toggle: function () { - send({ type: "room:toggle" }); - }, - // Enable room reverb - on: function () { - send({ type: "room:set", content: { enabled: true } }); - }, - // Disable room reverb - off: function () { - send({ type: "room:set", content: { enabled: false } }); - }, - // Set room parameters: { enabled, mix (0-1), feedback (0-0.95) } - set: function (options = {}) { - send({ type: "room:set", content: options }); - }, - // Get current room state (async via callback) - get: function () { - send({ type: "room:get" }); - }, - }; - - // 🧩 Glitch API - global raw synth effect - $sound.glitch = { - // Toggle glitch on/off - toggle: function () { - send({ type: "glitch:toggle" }); - }, - // Enable glitch - on: function () { - send({ type: "glitch:set", content: { enabled: true } }); - }, - // Disable glitch - off: function () { - send({ type: "glitch:set", content: { enabled: false } }); - }, - // Set glitch parameters: { enabled, mix (0-1), crush (2-12), rate (Hz), jitter (0-1) } - set: function (options = {}) { - send({ type: "glitch:set", content: options }); - }, - // Get current glitch state (async via callback) - get: function () { - send({ type: "glitch:get" }); - }, - }; - - $commonApi.sound = $sound; - - // System beep. - $commonApi.beep = (tone = 1200) => { - $sound.synth({ - tone, - beats: 0.1, - attack: 0.01, - decay: 0.5, - volume: 0.25, - }); - }; - - // Act & Sim (Occurs after first boot and paint, `boot` occurs below.) - if (booted && paintCount > 0n /*&& !leaving*/) { - let $api = {}; - keys($commonApi).forEach((key) => ($api[key] = $commonApi[key])); - keys($updateApi).forEach((key) => ($api[key] = $updateApi[key])); - keys(painting.api).forEach((key) => ($api[key] = painting.api[key])); - $api.api = $api; // Add a reference to the whole API. - - // 🎮 Add gamepad helper API (separate event streams per gamepad) - $api.gamepads = createGamepadAPI(content.gamepads); - - // Set the robo API context so it can send events - $api.robo.setAPI($api); - - $api.inFocus = inFocus; - - // 🔒 Apply security restrictions for untrusted pieces - if (pieceMetadata && shouldRestrictPiece(pieceMetadata)) { - $api = createRestrictedApi($api, pieceMetadata); - } - - cachedAPI = $api; // Remember this API for any other acts outside - // of this loop, like a focus change or custom act broadcast. - - // Use screen.width/height instead of content.width/height to get the most up-to-date dimensions - // content.width/height can be stale if a reframe just happened - $api.screen = { - width: screen.width, - height: screen.height, - pixels: screen.pixels, - }; - - $api.cursor = (code) => (cursorCode = code); - - // 📻 Signaling - $api.signal = (content) => { - send({ type: "signal", content }); - }; - - // Deprecated on 23.07.01.15.31 (Remove later if no regressions.) - // if (initialSim) { - // console.log("initial", initialSim, content.updateCount, 'paintcount', paintCount); - // simCount += 1n; - // $api.simCount = simCount; - // try { - // sim($api); - // } catch (e) { - // console.warn("🧮 Sim failure...", e); - // } - // initialSim = false; - // } else - - if (content.updateCount > 0 && paintCount > 0n) { - // Run `sim` the number of times as requested from `bios`. - for (let i = content.updateCount; i--; ) { - simCount += 1n; - $api.simCount = simCount; - try { - sim($api); - noticeTimer?.step(); // Globally tick the noticeTimer if it exists. - // ⌛ Run through all the global hourglass timers. - for (let i = hourGlasses.length - 1; i >= 0; i--) { - hourGlasses[i].step(); - if (hourGlasses[i].complete && !hourGlasses[i].autoFlip) - hourGlasses.splice(i, 1); - } - $api.rec.tapeTimerStep($api); - } catch (e) { - console.warn("🧮 Sim failure...", e); - } - } - } - - // 🌟 Act - // *Device Event Handling* - - // TODO: Shouldn't all these events come in as part of one array to - // keep their order of execution across devices? - // TODO: Could "device" be removed in favor of "device:event" strings and - // if needed, a device method? - - // Window Events - - // Reframing the piece... (resizing the window). - if (reframed === true) { - - $api.event = { - device: "none", - is: (e) => e === "reframed", - }; - try { - act($api); - } catch (e) { - console.warn("️ ✒ Act failure...", e); - } - reframed = false; - - // Global reframings. - // currentPromptButton?.reposition({ - // left: 6, - // bottom: 6, - // screen: $api.screen, - // }); - } - - // If a disk failed to load, then notify the disk that loaded it - // by checking to see if loadFailure has anything set. - if (loadFailure) { - $api.event = { - error: loadFailure, - is: (e) => e === "load-error", - }; - try { - act($api); - } catch (e) { - console.warn("️ ✒ Act failure...", e); - } - send({ type: "load-failure" }); - loadFailure = undefined; - } - - // Signaling - if (signals.length) { - const data = { signal: signals }; - Object.assign(data, { - device: "none", - is: (e) => e === "signal", - }); - $api.event = data; - try { - act($api); - } catch (e) { - console.warn("️ ✒ Act failure...", e); - } - signals.length = 0; - } - - // Keyboard Paste Event - // if (content.clipboardText) { - // const data = { text: content.clipboardText }; - // Object.assign(data, { - // device: "none", - // is: (e) => e === "pasted:text", - // }); - // $api.event = data; - // try { - // act($api); - // } catch (e) { - // console.warn("️ ✒ Act failure...", e); - // } - // } - - // *** Pen Events *** - // Ingest all pen input events by running act for each event. - // TODO: I could also be transforming pen coordinates here... - // TODO: Keep track of lastPen to see if it changed. - const startTime = performance.now(); - let penEventCount = 0; - - content.pen?.events.forEach((data) => { - penEventCount++; - Object.assign(data, { - device: data.device, - hudButtonActive: currentHUDButtonActive, // Add global HUD button flag - is: (e) => { - let [name, pointer] = e.split(":"); - if (pointer) { - if (pointer === "any") { - return name === data.name; - } else { - return name === data.name && data.index === parseInt(pointer); - } - } else { - return name === data.name && data.isPrimary === true; - } - }, - }); - - //console.log(data) - $api.event = data; - // 🌐🖋️️ Global pen events. - try { - // Always check to see if there was a tap on the corner. - const { event: e, jump, send, sound, system, piece } = $api; - let originalColor; - let masked = false; - - if ( - e.is("touch:5") && - piece !== "notepat" && - piece !== "stample" && - piece !== "toss" - ) { - sound.synth({ - tone: 1600, - duration: 0.02, - attack: 0.01, - decay: 0.5, - volume: 0.25, - }); - system.nopaint.replace( - cachedAPI, - graph.cloneBuffer(screen), - "$creenshot", - ); - jump("prompt"); - } - - // Corner prompt button. - if (!TV_MODE) { - currentHUDButton?.act(e, { - down: () => { - originalColor = currentHUDTextColor; - currentHUDScrub = 0; - currentHUDScrubReadyState = null; - currentHUDTextColor = [0, 255, 0]; - send({ type: "keyboard:enabled" }); // Enable keyboard flag. - send({ type: "keyboard:unlock" }); - $api.needsPaint(); - - // Set global flag to block other button interactions - currentHUDButtonActive = true; - // Track that this was a direct touch on the HUD button - currentHUDButtonDirectTouch = currentHUDButton.box.contains(e); - - // Mask unless we are in the camera. - if ($api.slug !== "camera") masked = true; - - $api.sound.synth({ - tone: 600, - beats: 0.1, - attack: 0.01, - decay: 0.5, - volume: 0.25, - }); - }, - push: (btn) => { - // Block HUD label navigation in solo mode - if (SOLO_MODE) return; - - const fallbackShareWidth = tf.blockWidth * "share ".length; - const shareWidth = Math.max(currentHUDShareWidth || 0, fallbackShareWidth); - const editWidth = Math.max(currentHUDEditWidth || 0, getHudEditScrubThreshold()); - - // 📦 PACK mode: no scrub-to-share, tap refreshes page - if (getPackMode()) { - currentHUDButtonActive = false; - currentHUDButtonDirectTouch = false; - currentHUDScrub = 0; - currentHUDScrubReadyState = null; - $api.sound.synth({ - tone: 1200, - beats: 0.1, - attack: 0.01, - decay: 0.5, - volume: 0.15, - }); - if (typeof window !== 'undefined') window.location.reload(); - masked = true; - $api.needsPaint(); - return; - } - - // If scrubbed to reveal "share", jump to share piece - if (currentHUDScrub >= shareWidth) { - currentHUDButtonActive = false; - currentHUDButtonDirectTouch = false; - triggerShareAction(); - return; - } - - // If scrubbed left to edit threshold, trigger backspace-to-edit - if (currentHUDScrub <= -editWidth) { - currentHUDButtonActive = false; - currentHUDButtonDirectTouch = false; - triggerBackspaceAction(); - return; - } - - // If scrubbed but not to max, cancel the push - if ( - (currentHUDScrub > 0 && currentHUDScrub < shareWidth) || - (currentHUDScrub < 0 && currentHUDScrub > -editWidth) - ) { - btn.actions.cancel?.(); - return; - } - - // Clear global HUD button flags - currentHUDButtonActive = false; - currentHUDButtonDirectTouch = false; - - $api.sound.synth({ - tone: 1200, - beats: 0.1, - attack: 0.01, - decay: 0.5, - volume: 0.15, - }); - - // 🌐 Branded domain: tapping HUD label navigates to aesthetic.computer - if (redirectIfBrandedDomain()) return; - - if (!labelBack) { - jump("prompt"); - } else { - labelBack = false; // Reset `labelBack` after jumping. - if ($commonApi.history.length > 0) { - send({ type: "back-to-piece" }); - } else { - jump("prompt"); - } - } - $api.needsPaint(); - masked = true; - currentHUDScrub = 0; - currentHUDScrubReadyState = null; - }, - scrub: (btn) => { - if (getPackMode()) return; // No scrub-to-share in bundled mode. - if (piece === "share") return; // No need to share scrub while in share. - if (currentHUDQR || currentHUDQRCells?.length || hudAnimationState.qrFullscreen) return; // Disable swipe-to-share when QR is shown. - - // Only allow scrubbing if this was a direct touch on the HUD button - if (!currentHUDButtonDirectTouch) return; - - if (btn.over || currentHUDScrub !== 0) { - const deltaX = Number(e?.delta?.x) || 0; - currentHUDScrub += deltaX; - } - - const fallbackShareWidth = (currentHUDLabelBlockWidth || DEFAULT_TYPEFACE_BLOCK_WIDTH) * "share ".length; - const shareWidth = Math.max(currentHUDShareWidth || 0, fallbackShareWidth); - const editWidth = Math.max(currentHUDEditWidth || 0, getHudEditScrubThreshold()); - - if (currentHUDScrub >= 0) { - const fallbackWidth = hudAnimationState.labelWidth - ? Math.max(hudAnimationState.labelWidth - currentHUDScrub, 0) - : currentHUDLabelBlockWidth * (currentHUDPlainTxt?.length || currentHUDTxt.length); - const baseWidth = currentHUDLabelMeasuredWidth ?? fallbackWidth; - btn.box.w = baseWidth + currentHUDScrub; - // console.log(btn.b); - } - - if (currentHUDScrub > shareWidth) currentHUDScrub = shareWidth; - if (currentHUDScrub < -editWidth) currentHUDScrub = -editWidth; - - let nextReadyState = null; - if (currentHUDScrub >= shareWidth) nextReadyState = "share"; - if (currentHUDScrub <= -editWidth) nextReadyState = "edit"; - - if (nextReadyState !== currentHUDScrubReadyState && nextReadyState !== null) { - $api.sound.synth({ - tone: nextReadyState === "share" ? 1600 : 900, - beats: 0.07, - attack: 0.01, - decay: 0.2, - volume: 0.12, - }); - } - currentHUDScrubReadyState = nextReadyState; - - if (currentHUDScrub >= shareWidth) { - currentHUDScrub = shareWidth; - currentHUDTextColor = [0, 255, 255]; - } else if (currentHUDScrub <= -editWidth) { - currentHUDScrub = -editWidth; - currentHUDTextColor = [255, 255, 0]; - } else if (currentHUDScrub > 0) { - currentHUDTextColor = [0, 200, 200]; - } else if (currentHUDScrub < 0) { - currentHUDTextColor = [255, 120, 180]; - } else if (currentHUDScrub === 0) { - if (btn.over) { - currentHUDTextColor = [0, 255, 0]; - } else { - currentHUDTextColor = [255, 0, 0]; - } - } - - $api.needsPaint(); - }, - cancel: () => { - currentHUDTextColor = originalColor; - - // Clear global HUD button flags - currentHUDButtonActive = false; - currentHUDButtonDirectTouch = false; - - const fallbackShareWidth = tf.blockWidth * "share ".length; - const shareWidth = Math.max(currentHUDShareWidth || 0, fallbackShareWidth); - const editWidth = Math.max(currentHUDEditWidth || 0, getHudEditScrubThreshold()); - if (currentHUDScrub === shareWidth) { - triggerShareAction(); - return; - } - - if (currentHUDScrub === -editWidth) { - triggerBackspaceAction(); - return; - } - - if (currentHUDScrub !== 0) { - $api.sound.synth({ - tone: 260, - beats: 0.09, - attack: 0.01, - decay: 0.2, - volume: 0.12, - }); - } - - currentHUDScrub = 0; - currentHUDScrubReadyState = null; - // TODO: This might break on pieces where the keyboard is already - // open. - send({ type: "keyboard:disabled" }); // Disable keyboard flag. - send({ type: "keyboard:lock" }); - $api.needsPaint(); - $api.sound.synth({ - tone: 200, - beats: 0.1, - attack: 0.01, - decay: 0.5, - volume: 0.15, - }); - }, - rollover: (btn) => { - if (btn) { - send({ type: "keyboard:unlock" }); - if (btn.down) { - currentHUDTextColor = [0, 255, 0]; - } - } - }, - rollout: () => { - // console.log("rolled out..."); - currentHUDTextColor = [200, 80, 80]; - send({ type: "keyboard:lock" }); - }, - }); - } - - if (!masked && !TV_MODE) act($api); // Run the act function for all pen events (skip in TV mode). - } catch (e) { - console.warn("️ ✒ Act failure...", e); - } - }); - - // *** 3D Pen Events *** - content.pen3d?.events?.forEach((data) => { - Object.assign(data, { - is: (e) => { - let [prefix, event, pointer] = e.split(":"); - if ( - prefix === "3d" && - event === data.name && - (pointer === undefined || parseInt(pointer) === data.pointer) - ) - return true; - }, - }); - $api.event = data; - try { - if (!TV_MODE) act($api); // Skip 3D pen events in TV mode - } catch (e) { - console.warn("️ ✒ Act failure...", e); - } - }); - - // if (penEventCount > 0) { - // const processingTime = performance.now() - startTime; - //if (processingTime > 5) { // Only log if processing took more than 5ms - //console.log("🖋️ Pen event processing:", { avgPerEvent: (processingTime / penEventCount).toFixed(2) + "ms" - //}); - //} - // } - - // 🎬 Inject demoplay synthetic keyboard events - if ($commonApi.system.demoplay?.syntheticKeyboard?.length) { - const synth = $commonApi.system.demoplay.syntheticKeyboard.splice(0); - content.keyboard = [...(content.keyboard || []), ...synth]; - } - - // Ingest all keyboard input events by running act for each event. - content.keyboard?.forEach((data) => { - Object.assign(data, { - device: "keyboard", - is: (e) => { - const parts = e.split(":"); - if (parts.length > 2) { - // Check for an exact match if `keyboard:action:?` - return data.name === e; - } else { - // Or a subtring match if `keyboard:action` - return data.name.indexOf(e) === 0; - } - }, - }); - $api.event = data; - try { - if (!TV_MODE) act($api); // Execute piece shortcut (skip in TV mode). - } catch (e) { - console.warn("️ ✒ Act failure...", e); - } - }); - - // Ingest all gamepad input events by running act for each event. - content.gamepad?.forEach((data) => { - Object.assign(data, { - device: "gamepad", - is: (e) => data.name.indexOf(e) === 0, - }); - $api.event = data; - try { - if (!TV_MODE) act($api); // Execute piece shortcut (skip in TV mode). - } catch (e) { - console.warn("️ ✒ Act failure...", e); - } - }); - - // *** UI Cancel Interactions *** (For edge detection) - if (content.type === "ui:cancel-interactions" && content.content) { - const cancelData = { - name: "ui:cancel-interactions", - x: content.content.x, - y: content.content.y, - pointer: content.content.pointer, - reason: content.content.reason, - is: (e) => e === "ui:cancel-interactions", - }; - - $api.event = cancelData; - try { - if (!TV_MODE) act($api); // Execute cancel for all buttons (skip in TV mode) - } catch (e) { - console.warn("️ ✒ Act failure (UI cancel)...", e); - } - } - - // *** Act Alerts *** (Custom events defined in here.) - // These do not run in the initial loader / preview piece. - actAlerts.forEach((action) => { - // Check if `name`'s not a string, and if not, attach arbitrary data. - let name, - extra = {}; - if (typeof action !== "string") { - // Make extra be an object with every key on action other than 'name'. - ({ name, ...extra } = action); - } else { - name = action; - } - - const data = { - name, - is: (e) => e === name, - of: (e) => name.startsWith(e), - ...extra, - }; - - // TODO: All all fields from 'extra' into 'data'. - - $api.event = data; - try { - // In TV mode, only allow certain system events, not user input events - const allowInTvMode = name.startsWith('speech:') || - name.startsWith('dark-mode') || - name.startsWith('light-mode') || - name.startsWith('microphone-') || - name.startsWith('aesthetic-parent:'); - - if (!TV_MODE || allowInTvMode) { - act($api); - } - } catch (e) { - console.warn("️ ✒ Act failure...", e); - } - }); - // 🎬 Forward actAlerts to demoplay conductor for wait-for actions - if ($commonApi.system.demoplay?.eventCallback) { - actAlerts.forEach((action) => { - const name = typeof action === "string" ? action : action.name; - if (name) $commonApi.system.demoplay.eventCallback(name); - }); - } - //if (actAlerts.length > 0) console.log(actAlerts, booted); - actAlerts.length = 0; // Clear act alerts. - } - - // 🖼 Paint - if (content.needsRender) { - let $api = {}; - keys($commonApi).forEach((key) => ($api[key] = $commonApi[key])); - keys(painting.api).forEach((key) => ($api[key] = painting.api[key])); - $api.api = $api; // Add a reference to the whole API. - - // 🎮 Add gamepad helper API (separate event streams per gamepad) - $api.gamepads = createGamepadAPI(content.gamepads); - - // Set the robo API context so it can send events - $api.robo.setAPI($api); - - // 🔒 Apply security restrictions for untrusted pieces - if (pieceMetadata && shouldRestrictPiece(pieceMetadata)) { - $api = createRestrictedApi($api, pieceMetadata); - } - - cachedAPI = $api; // Remember this API for any other acts outside - // of this loop, like a focus change or custom act broadcast. - - // Object.assign($api, $commonApi); - // Object.assign($api, painting.api); - - // Use piece-level frame counter that only increments when piece actually paints - // This ensures frame-based calculations (like in kidlisp) work correctly with FPS limiting - $api.paintCount = pieceFrameCount; - - $api.inFocus = content.inFocus; - - // Make a screen buffer or resize it automatically if it doesn't exist. - - if ( - !screen || - screen.width !== content.width || - screen.height !== content.height - ) { - const hasScreen = screen !== undefined; - - screen = { - pixels: - pixels || new Uint8ClampedArray(content.width * content.height * 4), - width: content.width, - height: content.height, - load: function load(name) { - if (store[name]?.pixels) { - this.pixels = new Uint8ClampedArray(store[name].pixels); - this.width = store[name].width; - this.height = store[name].height; - $commonApi.resize(this.width, this.height); - - // 🎨 Broadcast screen resize from load operation - if ($commonApi.broadcastPaintingUpdate) { - $commonApi.broadcastPaintingUpdate("resized", { - width: this.width, - height: this.height, - source: "screen_load" - }); - } - - return true; - } else { - return false; - } - }, - save: function save(name) { - store[name] = { - pixels: new Uint8ClampedArray(this.pixels), - width: this.width, - height: this.height, - }; - }, - }; - - screen[hasScreen ? "resized" : "created"] = true; // Screen change type. - - // 🎨 Broadcast screen resize to other tabs - if (hasScreen && $commonApi.broadcastPaintingUpdate) { - $commonApi.broadcastPaintingUpdate("resized", { - width: screen.width, - height: screen.height, - source: "screen_resize" - }); - } - - // TODO: Add the depth buffer back here. - // Reset the depth buffer. - // TODO: I feel like this is causing a memory leak... - graph.depthBuffer.length = screen.width * screen.height; - graph.depthBuffer.fill(Number.MAX_VALUE); - - graph.writeBuffer.length = 0; //screen.width * screen.height; - // graph.writeBuffer.fill(0); - } - - // TODO: Disable the depth buffer for now... it doesn't need to be - // regenerated on every frame. - // TODO: This only needs to run if 'form' is running in a piece. 25.03.20.19.27 - graph.depthBuffer.fill(Number.MAX_VALUE); // Clear depthbuffer. - graph.writeBuffer.length = 0; //fill(0); // Clear writebuffer. - - $api.screen = screen; - $api.screen.center = { x: screen.width / 2, y: screen.height / 2 }; - - // Snapshot true screen state each frame (after content.pixels transfer). - // page(sub-buffer) mutates screen in place; _mainScreenObject lets page() - // detect by identity when we're returning to the main screen and restore. - _trueScreenPixels = screen.pixels; - _trueScreenWidth = screen.width; - _trueScreenHeight = screen.height; - _mainScreenObject = screen; - - $api.fps = function (newFps) { - // Use piece-level timing instead of changing global render loop - if (newFps === undefined || newFps === null) { - if (pieceFPS !== null) { - pieceFPS = null; // Remove FPS limit only if it was previously set - } - } else { - // Only initialize timing if FPS wasn't set before, or if it's a different value - if (pieceFPS === null) { - // First time setting FPS - initialize timing - pieceFPS = newFps; - lastPaintTime = 0; // This will trigger "first frame" logic on next paint - } else if (pieceFPS !== newFps) { - // FPS changed to a different value - reset timing - pieceFPS = newFps; - lastPaintTime = 0; // This will trigger "first frame" logic on next paint - } - // If pieceFPS === newFps, do nothing (cache logic) - } - // Don't send fps-change to global render loop anymore - // send({ type: "fps-change", content: newFps }); - }; - - $api.cursor = (code) => (cursorCode = code); - - graph.setBuffer(screen); - - // API Stops being modified here... - /*if (!$activePaintApi)*/ $activePaintApi = $api; - - // Add paintings cache to API for KidLisp ready? function - $activePaintApi.paintings = paintings; - - // 🎯 Initialize global KidLisp instance with the main API - initializeGlobalKidLisp($api); - - // TODO: Set bpm from boot. - /* - $api.sound = { - time: content.time, - bpm: function (newBPM) { - if (newBPM) { - content.bpm[0] = newBPM; - } - return content.bpm[0]; - }, - }; - */ - - // TODO: Boot's painting is currently bound by whatever dirtyBox gets - // set to at the end of `paint`. - - // Run boot only once before painting for the first time. - if (paintCount === 0n && loading === false) { - const dark = await store.retrieve("dark-mode"); // Read dark mode. - if (dark === true || dark === false) $commonApi.dark = dark; - - // System specific preloaders. - //if ($commonApi?.system?.name === "nopaint" || currentText === "prompt") { - - // Create a new painting if one doesn't already exist. - if (!store["painting"]) { - store["painting"] = - (await store.retrieve("painting", "local:db")) || - painting.api.painting(screen.width, screen.height, ($) => { - $.wipe(64); - }); - - store["painting:resolution-lock"] = await store.retrieve( - "painting:resolution-lock", - "local:db", - ); - - store["painting:transform"] = await store.retrieve( - "painting:transform", - "local:db", - ); - - addUndoPainting(store["painting"]); - } - - const sys = $commonApi.system; - sys.painting = store["painting"]; - - // Set the painting record if one is in storage. - if (!sys.nopaint.recording) { - sys.nopaint.record = - (await store.retrieve("painting:record", "local:db")) || []; - - if (sys.nopaint.record.length === 0) { - $commonApi.system.nopaint.startRecord("new"); - } - - sys.nopaint.recording = sys.nopaint.record.length > 0; - } - - sys.nopaint.translation = - store["painting:transform"]?.translation || sys.nopaint.translation; - sys.nopaint.zoomLevel = - store["painting:transform"]?.zoom || sys.nopaint.zoomLevel; - - try { - const bootStartTime = performance.now(); - diskTimings.bootStarted = Math.round(bootStartTime - diskTimingStart); - // Silent: boot() starting - - // Reset zebra cache at the beginning of boot to ensure consistent state - $api.num.resetZebraCache(); - - // Notify boot progress - // console.log("📢 Disk sending boot-log: running boot"); - send({ - type: "boot-log", - content: "running boot" - }); - - if (system === "nopaint") nopaint_boot({ ...$api, params: $api.params, colon: $api.colon }); - await boot($api); - const bootEndTime = performance.now(); - diskTimings.bootComplete = Math.round(bootEndTime - diskTimingStart); - // Silent: boot() completed - booted = true; - log.boot.success("Boot completed, booted =", booted, "pending events:", pendingExportEvents.length); - - // 📨 Flush any pending export events that arrived during boot - if (pendingExportEvents.length > 0) { - log.boot.debug(`Flushing ${pendingExportEvents.length} pending export events`); - log.boot.verbose("receive === defaults.receive:", receive === defaults.receive); - log.boot.verbose("typeof receive:", typeof receive); - const eventsToFlush = [...pendingExportEvents]; - pendingExportEvents = []; - eventsToFlush.forEach(event => { - if (typeof receive === "function" && receive !== defaults.receive) { - try { - log.boot.verbose("Flushing queued event:", event.type); - receive(event); - } catch (e) { - console.warn("📼 ❌ Error flushing event:", event.type, e); - } - } - }); - } - } catch (e) { - console.warn("🥾 Boot failure...", e); - } - send({ type: "disk-loaded-and-booted" }); - } - - // Paint a frame, which can return false to enable caching via noPaint and by - // default returns undefined (assume a repaint). - // Once paint returns false and noPaint is marked true, `needsPaint` must be called. - // Note: Always marked false on a disk's first frame. - - let painted = false; - let dirtyBox; - - // Render a thumbnail instead of the piece. - if (previewMode) { - try { - // Assign a default resolution on first preview, - // which can be over-ridden using `resolution` inside the - // `preview` function. - if (firstPreviewOrIcon) { - if (currentSearch === "preview") { - $api.resolution(1200 / 8, 630 / 8, 0); - } else { - $api.resolution( - ...currentSearch - .split("=")[1] - .split("x") - .map((n) => floor(parseInt(n) / 8)), - 0, - ); - } - firstPreviewOrIcon = false; - } - - preview($api); - painting.paint(true); - painted = true; - paintCount += 1n; - } catch (err) { - console.warn("🖼️ Preview failure...", err); - previewMode = false; - previewOrIconMode = previewMode || iconMode; - } - } else if (iconMode) { - // Render a favicon instead of the piece. - try { - if (firstPreviewOrIcon) { - $api.resolution(128, 128, 0); - if (currentSearch === "icon") { - $api.resolution(128, 128, 0); - } else { - console.log("Current:", currentSearch); - $api.resolution( - ...currentSearch - .split("=")[1] - .split("x") - .map((n) => parseInt(n)), - 0, - ); - } - firstPreviewOrIcon = false; - } - icon($api); - painting.paint(true); - painted = true; - paintCount += 1n; - } catch (err) { - console.warn("🪷 Icon failure...", err); - iconMode = false; - previewOrIconMode = previewMode || iconMode; - } - } - - // Attempt a paint. - if ( - previewMode === false && - iconMode === false && - (noPaint === false || scream || fairies.length > 0 || (system === "nopaint" && nopaintPerf)) && - booted - ) { - let paintOut; - - // Restore kidlisp's accumulated pan state from previous frame - $api.loadpan(); - - try { - // 📓 Bake any painting from the nopaint system before anything else. - if (system === "nopaint" && $api.system?.nopaint) { - const np = $api.system.nopaint; - // No Paint: baking - - if ( - (brush || filter) && - $api.pen?.drawing /*&& currentHUDButton.down === false*/ - ) { - const brushFilterApi = { ...$api }; - // Add top-level 'color' word that maps to system.nopaint.color - brushFilterApi.color = np.color; - if (currentHUDButton.down === false) { - brushFilterApi.pen = np.brush; - if (brush) { - // $api.page($api.system.nopaint.buffer); - $api.page($api.system.painting); - // 🔥 - // TODO: Use the pen data here to get an interpolation, - // then pan to each interpolated point and repaint. - // console.log( - // "🖌️ Brush:", - // brushFilterApi.pen, - // "🖊️ Pen:", - // $api.pen, - // ); - brush(brushFilterApi); - } - if (filter) { - $api.page($api.system.painting); - filter(brushFilterApi); - } - $api.page(screen); - } - } - - // 🎯 BAKING MOVED TO ACT PHASE: Removed duplicate baking logic - // Baking now happens immediately in act() to fix race conditions - if (np.is("painting") || np.needsPresent) { - np.present($api); // No Paint: prepaint - } - } // All: Paint - - // 🎞️ Piece-level FPS timing control - const now = performance.now(); - let shouldPaint = true; - - if (pieceFPS !== null && pieceFPS > 0) { - const targetFrameTime = 1000 / pieceFPS; // milliseconds per frame - - // If this is the first paint (lastPaintTime === 0), always allow it - if (lastPaintTime === 0) { - lastPaintTime = now; - shouldPaint = true; - } else { - const timeSinceLastPaint = now - lastPaintTime; - - if (timeSinceLastPaint < targetFrameTime) { - shouldPaint = false; // Skip this frame - } else { - lastPaintTime = now; // Update last paint time - shouldPaint = true; - } - } - } - - // Only call paint() if timing allows or no FPS limit is set - if (shouldPaint) { - // Reset zebra cache at the start of each frame so it can advance once per frame - $api.num.resetZebraCache(); - - // Always call paint() - piece paints underneath, GOL overlays on top - paintOut = paint($api); // Returns `undefined`, `false`, or `DirtyBox`. - graph.gpuLogTick(); // 📊 GPU effect logging every 8 frames - // Increment piece frame counter only when we actually paint - pieceFrameCount++; - - // 🎬 Piece Transition: Also capture noise16 frame as fallback (for initial page load) - if (paint === defaults.paint && $api.screen?.pixels) { - // Capture the noise16 state in case no piece was running before (initial load) - golTransition.overlayPixels = new Uint8ClampedArray($api.screen.pixels); - golTransition.width = $api.screen.width; - golTransition.height = $api.screen.height; - } - - // 🎬 Piece Transition: When first piece paint happens, switch to reveal phase - // Skip for KidLisp pieces - they have their own rendering that conflicts - const isKidLispPiece = currentPath && ( - lisp.isKidlispSource(currentPath) || - currentPath === "(...)" || - currentPath.includes("$") || - currentPath.endsWith('.lisp') - ); - - // Skip transitions entirely if TRANSITION_TYPE is "none" - if (TRANSITION_TYPE !== "none" && pieceFrameCount === 1 && paint !== defaults.paint && golTransition.overlayPixels && $api.screen?.pixels && !isKidLispPiece) { - const screenW = $api.screen.width; - const screenH = $api.screen.height; - const overlayW = golTransition.width; - const overlayH = golTransition.height; - - // If dimensions differ, scale the overlay to match the new screen - if (screenW !== overlayW || screenH !== overlayH) { - console.log(`🎬 Transition: Scaling overlay from ${overlayW}x${overlayH} to ${screenW}x${screenH}`); - golTransition.overlayPixels = scaleOverlayPixels( - golTransition.overlayPixels, - overlayW, overlayH, - screenW, screenH - ); - golTransition.width = screenW; - golTransition.height = screenH; - // Re-initialize with new dimensions if needed - if (golTransition.active) { - initGOLCells(screenW, screenH); - } - } - - // If transition was already started during load, switch to reveal phase - // Otherwise start it now (fallback for cases where capture didn't happen) - if (golTransition.active) { - transitionPieceLoaded(); // Switch from loading to reveal phase - } else { - // Fallback: start transition now if it wasn't started during load - initGOLCells(golTransition.width, golTransition.height); - golTransition.generation = 0; - golTransition.active = true; - golTransition.phase = "revealing"; // Skip loading, go straight to reveal - console.log(`🎬 Transition: Started ${TRANSITION_TYPE} (direct reveal)`, golTransition.width, "x", golTransition.height); - } - } - - // Log first piece paint - if (pieceFrameCount === 1) { - diskTimings.firstPaint = Math.round(performance.now() - diskTimingStart); - // Silent: first piece paint - } - - // 🎨 Signal to hide boot canvas when piece's paint takes over from default noise16 - // Only signal once when piece has a custom paint function (not defaults.paint) - // Note: pieceFrameCount may already be >1 if defaults.paint ran while the module - // was loading (e.g., KidLisp pieces that take several seconds to initialize). - // Use acPieceReady itself as the "already fired" guard instead of pieceFrameCount. - if (paint !== defaults.paint && !(typeof window !== "undefined" && window.acPieceReady)) { - send({ type: "piece-paint-ready" }); - // 🔥 Global signal for Puppeteer/Oven to detect piece is ready for screenshot - if (typeof window !== "undefined") { - window.acPieceReady = true; - window.acPieceReadyTime = Date.now(); - const isPackMode = - (typeof window !== "undefined" && window.acPACK_MODE) || - (typeof globalThis !== "undefined" && globalThis.acPACK_MODE); - if (!isPackMode) { - console.log("🟢 acPieceReady = true (first paint complete)"); - } - } - } - - // TODO: Remove old embedded layer rendering - using simplified approach now - // globalThis.renderKidlispProgrammaticLayers(); - } else { - // When skipping paint, use undefined to ensure continuous rendering - // This maintains the render loop while skipping the actual paint call - paintOut = undefined; - } - - // Store paintOut for next frame (only when we actually painted) - if (shouldPaint) { - lastPaintOut = paintOut; - } - - // Save kidlisp's accumulated pan state for next frame - $api.savepan(); - // Reset pan for system UI rendering - $api.unpan(); - } catch (e) { - console.warn("🎨 Paint failure...", e); - } - - // `DirtyBox` and `undefined` always set `noPaint` to `true`. - noPaint = - paintOut === false || (paintOut !== undefined && paintOut !== true); - - // 🧬 GOL Transition: Force pixels to be sent while transition is active - if (golTransition.active) { - noPaint = false; // Override - GOL modified pixels, must send them - } - - if (system === "video") console.log('📝 DISK: paintOut =', paintOut, '→ noPaint =', noPaint); - - // Run everything that was queued to be painted, then devour paintLayers. - //await painting.paint(); - - // Upper layer. - const { page, layer, ink, needsPaint, pieceCount } = $api; - - // Restore screen if page(sub-buffer) calls during paint left it corrupted. - // Brushes like smear call page(system.painting) which mutates screen.pixels/width/height. - // page(screen) at the end of those brushes can't restore because screen is already corrupted. - if (_trueScreenPixels && screen.pixels !== _trueScreenPixels) { - screen.pixels = _trueScreenPixels; - screen.width = _trueScreenWidth; - screen.height = _trueScreenHeight; - } - - page($api.screen); // Make sure we're on the right bufer. - - layer(1000); // Always make sure this stuff draws on top. - - // const piece = $api.slug?.split("~")[0]; - // if ( - // !previewMode && - // !iconMode && - // !hideLabel && - // system !== "prompt" && - // piece !== "textfence" && - // piece !== "bleep" && - // piece !== undefined && - // piece.length > 0 && - // piece !== "painting" && - // pieceCount > 0 - // ) { - // currentPromptButton = - // currentPromptButton || - // new $api.ui.TextButton("Back", { - // left: 6, - // bottom: 6, - // screen: $api.screen, - // }); - // currentPromptButton.paint($api); - // } - - // 😱 Scream - Paint a scream if it exists. - // TODO: Should this overlay after the fact and not force a paint? 23.05.23.19.21 - // Yes probably, because of layering issues? - if (scream || screaming) { - ink("yellow").write(scream, { x: 6, y: 18 }, "red"); - //ink("red").write(scream, { x: 6 + 1, y: 18 + 1 }); - - /* - ink(255) - .wipe(255, 0, 0) - .write( - scream, - { center: "xy", size: 3, thickness: 1 }, - undefined, - $api.screen.width - 8, - needsPaint(), - ); - */ - if (!screaming) { - screaming = true; - clearTimeout(screamingTimer); - screamingTimer = setTimeout(() => { - screaming = false; - scream = null; - }, 1000); - } - } - - // 🧚 Ambient Pen Points - Paint if they exist. - fairies.forEach(({ x, y }) => { - ink().point(x * screen.width, y * screen.height); - }); - if (fairies.length > 0) { - needsPaint(); - // if (system === "nopaint") $api.system.nopaint.needsPresent = true; - } - fairies.length = 0; - - // 🔴 Show a cross-piece "Recording" indicator. - // Currently only implemented for `painting:record`. 23.08.20.21.36 - if ( - $api.system?.nopaint?.recording && - !hideLabel && - pieceHistoryIndex > -1 && - !loading - ) { - // ink("red").box(screen.width - 3, 1, 2); - } - - // Show a notice if necessary. - if (notice) { - ink(noticeColor[0]) - //.pan(help.choose(-1, 0, 1), help.choose(-1, 0, 1)) - .write( - notice, - { center: "xy", size: 2 }, - // { center: "x", y: 32, size: 2 }, - noticeColor[1], - $api.screen.width - 8, - noticeOpts?.wrap === "char" ? false : true, - ); - //.unpan(); - } - - layer(0); - - painting.paint(true); - - // 🎬 Piece Transition: Step and apply overlay AFTER painting.paint() executes - // This ensures the overlay is composited on TOP of the piece's rendered frame - if (golTransition.active && screen?.pixels) { - // Check dimensions still match - if (screen.width !== golTransition.width || screen.height !== golTransition.height) { - console.warn("🎬 Transition: Screen dimensions changed! Aborting."); - golTransition.active = false; - golTransition.overlayPixels = null; - golTransition.blockData = null; - golTransition.dripOffsets = null; - golTransition.dripSpeeds = null; - golTransition.dripDirections = null; - } else { - // Step the transition - const stillActive = golStep(screen.pixels); - - // Apply overlay on top of piece's paint - applyGOLOverlay(screen.pixels); - - // End transition when complete - if (!stillActive) { - console.log(`🎬 Transition: ${TRANSITION_TYPE} complete!`); - golTransition.active = false; - golTransition.overlayPixels = null; - golTransition.blockData = null; - golTransition.dripOffsets = null; - golTransition.dripSpeeds = null; - golTransition.dripDirections = null; - } - } - } - - painted = true; - if (system === "video") console.log('🟢 DISK: painted set to TRUE, paintCount:', paintCount); - paintCount = paintCount + 1n; - - // Check for frame-based recording completion (runs on every painted frame) - if ($api.rec.tapeFrameMode && $api.rec.tapeFrameTarget > 0) { - const currentFrame = Number(paintCount); - const framesPassed = currentFrame - $api.rec.tapeFrameStart; - - $api.rec.tapeProgress = framesPassed / $api.rec.tapeFrameTarget; - - if (framesPassed >= $api.rec.tapeFrameTarget) { - console.log(`🎬 Frame-based recording complete: ${framesPassed}/${$api.rec.tapeFrameTarget} frames`); - - // Clear failsafe since normal timer completed - if ($api.rec.failsafeTimeout) { - clearTimeout($api.rec.failsafeTimeout); - $api.rec.failsafeTimeout = null; - } - - // Reset frame-based recording state - $api.rec.tapeProgress = 0; - $api.rec.tapeFrameMode = false; - $api.rec.tapeFrameStart = 0; - $api.rec.tapeFrameTarget = 0; - - // Trigger cut and jump to video - if (typeof $api.rec.cut === 'function') { - $api.rec.cut(() => { - $commonApi.jump("video"); - }); - } else { - log.tape.warn("Cut function not available"); - $commonApi.jump("video"); - } - } - } - - if (paintOut) dirtyBox = paintOut; - - delete screen.resized; // Remove status from screen after painting. - delete screen.created; - - //console.log("bake") - //send({ type: "3d-bake" }); - } else { - if (system === "video") { - console.log('❌ DISK: PAINT BLOCK SKIPPED - previewMode:', previewMode, 'iconMode:', iconMode, 'noPaint:', noPaint, 'booted:', booted); - } - } - - // 🏷️ corner-label: Draw any Global UI / HUD in an overlay buffer that will get - // composited by the other thread. - - // TODO: ❤️‍🔥 Why is this being composited by a different thread? - // Also... where do I put a scream? - - // System info label (addressability). - let label; - const piece = currentHUDTxt?.split("~")[0]; - const defo = 6; // Default offset - - - - if ( - !previewMode && - !iconMode && - !hideLabel && - piece !== undefined && - piece.length > 0 - ) { - - // Use plain text for width calculation to avoid counting color codes - const textForWidthCalculation = currentHUDPlainTxt || currentHUDTxt; - - // Double-check: strip color codes directly if they're still present - // Handle multiple color code formats: \colorname\, \255,255,255\, etc. - const colorCodeRegex = /\\[^\\]*\\/g; // Matches \anything\ format - let cleanText = textForWidthCalculation.replace(colorCodeRegex, ''); - - // Also try removing RGB color codes like \255,255,255\ if any remain - cleanText = cleanText.replace(/\\\d+,\d+,\d+(,\d+)?\\/g, ''); - - // Remove any remaining single backslashes that might be color code remnants - cleanText = cleanText.replace(/\\/g, ''); - - // Analyze visible lines for accurate sizing - const rawLines = cleanText.split('\n'); - const visibleLines = rawLines.map((line) => line.replace(/\s+$/, '')); - const visibleLineCount = visibleLines.length; - - const defaultTypeface = tf; - const matrixTypeface = typefaceCache.get("MatrixChunky8"); - - const isKidlispPiece = detectKidLispPiece({ - currentPath, - currentHUDTxt, - currentText, - cleanText, - }); - - // 🎨 KidLisp HUD color: green when running, yellow when paused - if (isKidlispPiece && !currentHUDTextColor) { - if (loopPaused) { - currentHUDTextColor = [255, 220, 80]; // Yellow when paused (matches pause button #ffd93d) - } else { - currentHUDTextColor = [128, 255, 128]; // Green when running - } - } - - // 🔤 Font Preloading: Ensure MatrixChunky8 starts loading early if available - if (matrixTypeface && !matrixTypeface.__loadPromise) { - ensureTypefaceLoaded(matrixTypeface); - } - - const measureLineWidth = (line, typeface) => { - if (!line || !line.length || !typeface) return 0; - const advances = typeface.data?.advances; - const defaultAdvance = typeface.blockWidth || DEFAULT_TYPEFACE_BLOCK_WIDTH; - let width = 0; - for (let i = 0; i < line.length; i++) { - const ch = line[i]; - width += advances && advances[ch] !== undefined ? advances[ch] : defaultAdvance; - } - return width; - }; - - const buildLayout = (typeface) => { - if (!typeface) return null; - - const fontIdentifier = typeface === defaultTypeface ? undefined : typeface.name; - const blockWidth = typeface.blockWidth || DEFAULT_TYPEFACE_BLOCK_WIDTH; - const blockHeight = typeface.blockHeight || DEFAULT_TYPEFACE_BLOCK_HEIGHT; - const lineWidths = visibleLines.map((line) => measureLineWidth(line, typeface)); - const longestLineWidth = lineWidths.reduce((max, width) => Math.max(max, width), 0); - // 🎨 Use screen width minus padding for KidLisp wrapping - // Account for left margin (HUD_LABEL_TEXT_MARGIN) in measurement bounds - const bounds = isKidlispPiece ? ($api.screen.width - 6 - HUD_LABEL_TEXT_MARGIN) : Math.max($api.screen.width * 4, 1000); - - const naturalBounds = $api.text.box( - cleanText, - { x: 0, y: 0 }, - bounds, - 1, - true, // Enable wrapping for proper measurement - fontIdentifier, - ); - - const wrappedLines = Array.isArray(naturalBounds?.lines) - ? naturalBounds.lines.map((line) => { - if (typeof line === "string") return line; - return line?.join?.(" ") || ""; - }) - : visibleLines; - const wrappedLineWidths = wrappedLines.map((line) => - measureLineWidth((line || "").replace(/\s+$/, ""), typeface), - ); - const wrappedLineStep = blockHeight + 1; - const wrappedLastLineIndex = Math.max(0, wrappedLines.length - 1); - const wrappedLastLineWidth = wrappedLineWidths[wrappedLastLineIndex] || 0; - const wrappedLastLineY = wrappedLastLineIndex * wrappedLineStep; - - if (fontIdentifier === "MatrixChunky8") { - for (const line of visibleLines) { - for (let i = 0; i < line.length; i++) { - typeface.glyphs?.[line[i]]; - } - } - } - - return { - typeface, - fontIdentifier, - blockWidth, - blockHeight, - lineWidths, - longestLineWidth, - textBoxWidth: naturalBounds?.box?.width ?? 0, - textBoxHeight: naturalBounds?.box?.height ?? 0, - naturalBounds, - wrappedLines, - wrappedLineWidths, - wrappedLastLineWidth, - wrappedLastLineY, - }; - }; - - const defaultLayout = buildLayout(defaultTypeface); - let selectedLayout = defaultLayout; - - // 🔤 Force MatrixChunky8 if hud.tinyLabel() was called - if (forceTinyHudLabel && matrixTypeface) { - const matrixLayout = buildLayout(matrixTypeface); - if (matrixLayout) { - selectedLayout = matrixLayout; - } - } - - // Disabled: MatrixChunky8 breakpoint removed per user request - // if ( - // isKidlispPiece && - // matrixTypeface && - // defaultLayout && - // defaultLayout.longestLineWidth > $api.screen.width - // ) { - // const matrixLayout = buildLayout(matrixTypeface); - // if (matrixLayout) { - // selectedLayout = matrixLayout; - // } - // } - - if (!selectedLayout && matrixTypeface) { - selectedLayout = buildLayout(matrixTypeface); - } - - if (!selectedLayout) { - selectedLayout = defaultLayout; - } - - const selectedTypeface = selectedLayout?.typeface || defaultTypeface; - const selectedHudFont = selectedLayout?.fontIdentifier; - const hudBlockWidth = selectedLayout?.blockWidth ?? DEFAULT_TYPEFACE_BLOCK_WIDTH; - const hudBlockHeight = selectedLayout?.blockHeight ?? DEFAULT_TYPEFACE_BLOCK_HEIGHT; - const textBounds = selectedLayout?.naturalBounds; - const textBoxWidth = selectedLayout?.textBoxWidth ?? 0; - const textBoxHeight = selectedLayout?.textBoxHeight ?? 0; - const longestVisibleLineWidth = selectedLayout?.longestLineWidth ?? 0; - const wrappedLineCount = textBounds?.lines?.length ?? visibleLineCount; - const measuredTextHeight = textBoxHeight || (wrappedLineCount * hudBlockHeight); - currentHUDTextBoxWidth = textBounds?.box?.width || 0; - currentHUDTextBoxHeight = textBounds?.box?.height || 0; - currentHUDTextHeight = measuredTextHeight; - const wrappedLastLineWidth = selectedLayout?.wrappedLastLineWidth ?? 0; - const wrappedLastLineY = selectedLayout?.wrappedLastLineY ?? 0; - - // 🔤 Font Loading Check: If MatrixChunky8 was selected but not loaded, skip rendering HUD - // This prevents pop-in where font_1 renders briefly before MatrixChunky8 glyphs load - let skipHudRender = false; - if (selectedHudFont === "MatrixChunky8" && matrixTypeface) { - // Check if actual glyph data exists (glyphs are loaded on-demand via Proxy) - // Test a common character like '$' to verify glyphs are accessible - // A real glyph will have properties like dwidth, advance, or resolution - // The Proxy returns null for missing glyphs (typeof null === 'object') - const dollarGlyph = matrixTypeface.glyphs && matrixTypeface.glyphs['$']; - const hasGlyphs = dollarGlyph && dollarGlyph !== null && - (dollarGlyph.dwidth || dollarGlyph.advance !== undefined || dollarGlyph.resolution); - - if (!hasGlyphs) { - // Trigger loading if not started - if (!matrixTypeface.__loadPromise) { - ensureTypefaceLoaded(matrixTypeface); - } - - // Wait for font to load - if (matrixTypeface.__loadPromise) { - matrixTypeface.__loadPromise.then(() => { - matrixTypeface.__loaded = true; - // Trigger repaint once loaded - if (typeof window !== "undefined" && window.$activePaintApi?.needsPaint) { - window.$activePaintApi.needsPaint(); - } - }).catch((err) => { - console.warn("Failed to load MatrixChunky8:", err); - matrixTypeface.__loaded = true; // Mark as loaded to prevent infinite waiting - }); - } - // Skip rendering this frame - wait for font to load - skipHudRender = true; - } - } - - // Only proceed with HUD rendering if font is ready - if (!skipHudRender) { - - const measuredShareWidth = measureLineWidth("share ", selectedTypeface); - const fallbackShareWidth = hudBlockWidth * "share ".length; - const effectiveShareWidth = measuredShareWidth > 0 - ? Math.max(measuredShareWidth, fallbackShareWidth) - : fallbackShareWidth; - const measuredEditWidth = measureLineWidth("edit ", selectedTypeface); - const fallbackEditWidth = hudBlockWidth * "edit".length; - const dynamicEditWidth = getHudEditScrubThreshold(); - const effectiveEditWidth = Math.max( - measuredEditWidth > 0 ? Math.max(measuredEditWidth, fallbackEditWidth) : fallbackEditWidth, - dynamicEditWidth, - ); - currentHUDShareWidth = effectiveShareWidth; - currentHUDEditWidth = effectiveEditWidth; - const shareWidth = effectiveShareWidth; - currentHUDLeftPad = shareWidth; - const scrubExtension = Math.max(0, currentHUDScrub); - const editExtension = currentHUDScrub < 0 ? effectiveEditWidth : 0; - - currentHUDLabelFontName = selectedHudFont; - currentHUDLabelBlockWidth = hudBlockWidth; - currentHUDLabelBlockHeight = hudBlockHeight; - - // Prefer visible line width for KidLisp pieces to avoid oversized buffers - // When wrapping is enabled, use textBoxWidth (wrapped width) instead - let w = isKidlispPiece ? textBoxWidth : textBoxWidth; - if (!w || w <= 0) { - w = textBoxWidth; - } - - // Add extra padding to buffer width to prevent font_1 character cutoff - const bufferWidthPadding = 6; // Extra pixels for wider glyphs - w += bufferWidthPadding; - - // 📡 LAN badge superscript - add width for device letter if connected to dev session server - // Hide in kidlisp.com embedded mode (NOAUTH_MODE) - const showLanBadge = devIdentity && devIdentity.host && !globalThis.NOAUTH_MODE; - const lanBadgePadding = showLanBadge ? 10 : 0; // Space for letter + margin - w += lanBadgePadding; - - // ✨ Custom superscript (e.g. ".com") - add width for text - const showCustomSuperscript = !!currentHUDSuperscript; - const customSuperscriptPadding = showCustomSuperscript ? (currentHUDSuperscript.length * 5 + 3) : 0; - w += customSuperscriptPadding; - - // 📱 HUD QR code - generate cells and calculate width - let hudQRSize = 0; - let hudQRPadding = 0; - // Debug: Log QR state on first few frames (disabled for cleaner output) - // if (pieceFrameCount < 3) { - // console.log("📱 HUD QR render check - frame:", pieceFrameCount, "currentHUDQR:", JSON.stringify(currentHUDQR), "currentHUDQRCells:", !!currentHUDQRCells); - // } - if (currentHUDQR) { - // Generate QR cells if not cached - if (!currentHUDQRCells) { - try { - currentHUDQRCells = qr(currentHUDQR, { errorCorrectLevel: ErrorCorrectLevel.L }).modules; - console.log("📱 QR cells generated:", currentHUDQRCells?.length, "for URL:", currentHUDQR); - } catch (e) { - console.error("HUD QR generation failed:", e); - currentHUDQRCells = null; - } - } - if (currentHUDQRCells) { - hudQRSize = currentHUDQRCells.length; // 1px per cell - hudQRPadding = 4; // Gap between QR and text - } - } - const hudQRTotalWidth = hudQRSize > 0 ? hudQRSize + hudQRPadding + 2 : 0; // +2 for border - - // Final text dimensions: KidLisp width uses visible line metrics, height from text box - let h = measuredTextHeight; - - if (piece === "video") w = screen.width; - - const baseLabelWidth = w + shareWidth; - - // Use natural text dimensions for buffer - preserve original layout - // Add QR code width to buffer if present - let bufferW = baseLabelWidth + scrubExtension + editExtension + hudQRTotalWidth; - let bufferH = h; - - // Ensure buffer height fits QR code if present - if (hudQRSize > 0 && bufferH < hudQRSize + 4) { - bufferH = hudQRSize + 4; // QR size + padding - } - - // Ensure minimum buffer size for readability - const minTextWidth = Math.max(isKidlispPiece ? textBoxWidth : textBoxWidth, 50); - const minimumAllowedWidth = shareWidth + Math.max(minTextWidth, 0); - const minBufferH = Math.max(hudBlockHeight, 20); // At least one line height - - if (bufferW < minimumAllowedWidth + scrubExtension + editExtension) { - bufferW = minimumAllowedWidth + scrubExtension + editExtension; - } - if (bufferH < minBufferH) { - bufferH = minBufferH; - } - - // Fix height calculation for KidLisp pieces when wordWrap=false - if (isKidlispPiece) { - // When wordWrap=false, text.box doesn't calculate height correctly for multi-line text - // Calculate proper height based on actual line count - const properHeight = measuredTextHeight; - if (properHeight > bufferH) { - bufferH = properHeight; - } - } - - const hudDescenderPadding = Math.max(2, Math.round(hudBlockHeight * 0.2)); - bufferH += hudDescenderPadding; - - // DEBUG: Log buffer dimensions in copy-pasteable format (reduced frequency) - if (false && isKidlispPiece && currentHUDTxt && pieceFrameCount % 120 === 0) { - console.log(`HUD_BUFFER: bufferW=${bufferW} bufferH=${bufferH} textBoxW=${textBounds.box.width} textBoxH=${textBounds.box.height} longestLinePx=${longestVisibleLineWidth} lines=${visibleLineCount} fontHeight=${hudBlockHeight} expectedH=${visibleLineCount * hudBlockHeight}`); - console.log(`HUD_TEXT: "${cleanText.replace(/\n/g, '\\n').substring(0, 150)}"`); - } - - - - // Store actual dimensions for animation calculations - currentHUDLabelMeasuredWidth = baseLabelWidth; - hudAnimationState.labelWidth = bufferW; - hudAnimationState.labelHeight = bufferH; - h = bufferH; - - label = $api.painting(bufferW, bufferH, ($) => { - // Ensure label renders with clean pan state - $.unpan(); - // Ensure label renders without any active mask from the piece - $.unmask(); - - // Clean rendering - no debug elements - - - - let c; - if (currentHUDTextColor) { - c = num.shiftRGB(currentHUDTextColor, [255, 255, 255], 0.75); - } else if (currentHUDStatusColor) { - c = currentHUDStatusColor; - } else { - c = [255, 200, 240]; - } - if (piece !== "video") { - let text = currentHUDTxt; - if (currentHUDTxt.split(" ")[1]?.indexOf("http") !== 0) { - text = currentHUDTxt?.replaceAll("~", " "); - // Handle command separators: replace § with spaces for display - // but keep actual newlines as newlines - text = text?.replaceAll("§", " "); - } - - // 📱 Render QR code to the LEFT of the text if present - let qrOffset = 0; - if (currentHUDQR && currentHUDQRCells) { - const qrCells = currentHUDQRCells; - const qrSize = qrCells.length; - // Position QR at the start of visible area (after share button padding) - // Include scrub offset so QR moves with the label when swiping to "share" - // Move left by 4px for tighter fit (up is handled by label Y offset) - const qrX = currentHUDLeftPad + currentHUDScrub - 4; - const qrY = 0; // Top of buffer - - // White background (1px border around QR) - $.ink(255, 255, 255).box(qrX, qrY, qrSize + 2, qrSize + 2); - - // Draw QR code cells - for (let y = 0; y < qrSize; y++) { - for (let x = 0; x < qrSize; x++) { - if (qrCells[y][x]) { - $.ink(0, 0, 0).box(qrX + 1 + x, qrY + 1 + y, 1, 1); - } - } - } - - qrOffset = qrSize + 4; // QR width + border + gap - } - - const baseX = currentHUDLeftPad + qrOffset; - // Keep text fixed while scrubbing left; only right-scrub shifts label content. - const hudTextX = baseX + HUD_LABEL_TEXT_MARGIN + Math.max(0, currentHUDScrub); - const hudTextY = 0; - const typefaceNameForWrite = selectedHudFont; - const hasColorCodes = textContainsColorCodes(text); - const baseTextColor = currentHUDTextColor || "white"; - - // 🎨 Character wrapping for KidLisp HUD prompts - // Wrap based on screen width minus padding (6px total: 2px margin + 4px padding) - // Subtract the left margin since text starts at x=HUD_LABEL_TEXT_MARGIN - const wrapBounds = isKidlispPiece ? ($api.screen.width - 6 - HUD_LABEL_TEXT_MARGIN) : undefined; - - drawHudLabelText($, text, { - x: hudTextX, - y: hudTextY, - typefaceName: typefaceNameForWrite, - textColor: baseTextColor, - shadowColor: "black", - shadowOffsetX: 1, - shadowOffsetY: 1, - preserveColors: hasColorCodes, - bounds: wrapBounds, - // wordWrap defaults to true when bounds is set, enabling character wrapping - }); - - // ✨ Custom Superscript (e.g. ".com") - draw after the text in cyan - if (showCustomSuperscript) { - const firstLine = text?.split('\n')[0] || text; - const firstLineWidth = cachedAPI.text.width( - stripColorCodes(firstLine), - selectedTypeface - ); - - const superscriptX = hudTextX + firstLineWidth + 2; - const superscriptY = 2; // Moved down 2px - - // Special handling for ".com" - color the "." based on connection status - if (currentHUDSuperscript === ".com") { - // Color dot based on socket connection: green if connected, red if not - if (socket?.connected) { - $.ink(0, 255, 100); // Green for connected - } else { - $.ink(255, 100, 100); // Red/salmon for disconnected - } - $.write(".", { x: superscriptX, y: superscriptY }, undefined, undefined, false, "MatrixChunky8"); - - // Render "com" in bright cyan - const dotWidth = cachedAPI.text.width(".", "MatrixChunky8"); - $.ink(0, 220, 255); // Bright cyan - $.write("com", { x: superscriptX + dotWidth, y: superscriptY }, undefined, undefined, false, "MatrixChunky8"); - } else { - // Default rendering for other superscripts - $.ink(0, 220, 255); // Bright cyan - $.write(currentHUDSuperscript, { x: superscriptX, y: superscriptY }, undefined, undefined, false, "MatrixChunky8"); - } - } - - // 📡 LAN Badge Superscript - draw device letter after the text (+ custom superscript offset) - if (showLanBadge) { - const deviceLetter = devIdentity.letter || - (devIdentity.name ? devIdentity.name.charAt(0).toUpperCase() : null) || - String.fromCharCode(65 + (devIdentity.connectionIndex || 0)); // A, B, C... - - // Get the first line of text for positioning the superscript - const firstLine = text?.split('\n')[0] || text; - const firstLineWidth = cachedAPI.text.width( - stripColorCodes(firstLine), - selectedTypeface - ); - - // Position superscript to the right of first line (after custom superscript if present) - const superscriptX = hudTextX + firstLineWidth + 2 + customSuperscriptPadding; - const superscriptY = 0; // Top of the line (superscript position) - - // Draw letter in cyan using MatrixChunky8 - $.ink(0, 220, 255); // Bright cyan matching original badge - $.write(deviceLetter, { x: superscriptX, y: superscriptY }, undefined, undefined, false, "MatrixChunky8"); - } - - // 🔗 Draw "share" text in the LEFT reveal area when scrubbing - if (currentHUDScrub > 0) { - // Slide "share" in from the left as the scrub progresses. - const shareTextX = - currentHUDLeftPad + currentHUDScrub - shareWidth + HUD_LABEL_TEXT_MARGIN; - const shareTextY = 0; - - drawHudLabelText($, "share", { - x: shareTextX, - y: shareTextY, - typefaceName: undefined, // Use default font, not MatrixChunky8 - textColor: [0, 255, 255], - shadowColor: "black", - preserveColors: false, - }); - } - - // ⬅️ Draw backspace/edit reveal on right when scrubbing left - if (currentHUDScrub < 0) { - const scrubMagnitude = Math.abs(currentHUDScrub); - const editThreshold = Math.max(currentHUDEditWidth || 0, getHudEditScrubThreshold()); - const editProgress = Math.min(1, scrubMagnitude / editThreshold); - const lastLineWidth = Math.max( - 1, - wrappedLastLineWidth || - currentHUDTextBoxWidth || - (stripColorCodes(text || "").length * hudBlockWidth), - ); - const caretWidth = Math.max(2, Math.round(hudBlockWidth * 0.9)); - const caretHeight = Math.max(hudBlockHeight, 6); - const caretX = Math.min( - Math.max(hudTextX + lastLineWidth + 2, 0), - Math.max(bufferW - caretWidth - 2, 0), - ); - const unclampedCaretY = Math.max(0, hudTextY + wrappedLastLineY); - const caretY = Math.min(unclampedCaretY, Math.max(bufferH - caretHeight - 1, 0)); - const filledCols = Math.max(1, Math.round(caretWidth * editProgress)); - const editColor = editProgress >= 1 ? [255, 170, 210] : [255, 107, 157]; - - // Draw per-column with shadows and a fly-in offset from the right. - for (let col = 0; col < filledCols; col++) { - const t = filledCols > 1 ? col / (filledCols - 1) : 1; - const flyIn = Math.round((1 - t) * (caretWidth - filledCols)); - const cx = caretX + col + flyIn; - $.ink(0, 0, 0, 180).box(cx + 1, caretY + 1, 1, caretHeight); - $.ink(...editColor).box(cx, caretY, 1, caretHeight); - } - } - } else { - $.ink(0).line(1, 1, 1, h - 1); - $.ink(c).line(0, 0, 0, h - 2); - } - }); - - if (piece === "video") currentHUDOffset = { x: 0, y: 6 }; - if (!currentHUDOffset) currentHUDOffset = { x: defo, y: defo }; - - // Apply animation effects to HUD label position - if (hudAnimationState.animating) { - const currentTime = performance.now(); - const elapsed = currentTime - hudAnimationState.startTime; - const progress = Math.min(elapsed / hudAnimationState.duration, 1.0); - - // Easing function for smooth macOS-style animation (ease-out) - const easeOut = 1 - Math.pow(1 - progress, 3); - - // Use actual calculated dimensions from when HUD was last drawn - const hudWidth = hudAnimationState.labelWidth || 120; // fallback to default - const hudHeight = hudAnimationState.labelHeight || 40; // fallback to default - const qrSize = hudAnimationState.qrSize || 80; - - // Calculate slide distances: ensure full content slides off-screen - // Add extra padding (20px) to guarantee complete disappearance - // For very large labels, cap the slide distance to keep animation smooth - const maxSlideDistance = 400; // Maximum distance to slide for smooth animation - const hudSlideX = Math.max(-(hudWidth + 20), -maxSlideDistance); // Cap slide distance - const hudSlideY = Math.max(-(hudHeight + 20), -maxSlideDistance); // Cap slide distance - const qrSlideX = qrSize + 20; // Slide right by full size + padding - const qrSlideY = qrSize + 20; // Slide down by full size + padding - - if (hudAnimationState.visible) { - // Animating IN: fade from 0 to 1, slide from respective corners to position - hudAnimationState.opacity = easeOut; - // HUD slides in from top-left corner - hudAnimationState.slideOffset = { - x: (1 - easeOut) * hudSlideX, // Slide in from left using actual width - y: (1 - easeOut) * hudSlideY // Slide in from top using actual height - }; - // QR slides in from bottom-right corner - hudAnimationState.qrSlideOffset = { - x: (1 - easeOut) * qrSlideX, // Slide in from right using actual size - y: (1 - easeOut) * qrSlideY // Slide in from bottom using actual size - }; - } else { - // Animating OUT: fade from 1 to 0, slide to respective corners - hudAnimationState.opacity = 1 - easeOut; - // HUD slides out to top-left corner - hudAnimationState.slideOffset = { - x: easeOut * hudSlideX, // Slide out to left using actual width - y: easeOut * hudSlideY // Slide out to top using actual height - }; - // QR slides out to bottom-right corner - hudAnimationState.qrSlideOffset = { - x: easeOut * qrSlideX, // Slide out to right using actual size - y: easeOut * qrSlideY // Slide out to bottom using actual size - }; - } - - // End animation when progress reaches 1.0 - if (progress >= 1.0) { - hudAnimationState.animating = false; - - // Ensure final values are exact - if (hudAnimationState.visible) { - hudAnimationState.opacity = 1.0; - hudAnimationState.slideOffset = { x: 0, y: 0 }; - hudAnimationState.qrSlideOffset = { x: 0, y: 0 }; - } else { - hudAnimationState.opacity = 0.0; - hudAnimationState.slideOffset = { x: hudSlideX, y: hudSlideY }; - hudAnimationState.qrSlideOffset = { x: qrSlideX, y: qrSlideY }; - } - } - } - - currentHUDButton = - currentHUDButton || - new $api.ui.Button({ - x: 0, - y: 0, - w: currentHUDLabelMeasuredWidth, // FIXED: Don't add currentHUDOffset.x to button width - h: h, // Use just the calculated height without extra y-offset - }); - - // Update button position to match label position (with slide offset) - const finalX = currentHUDOffset.x + hudAnimationState.slideOffset.x - currentHUDLeftPad; - const finalY = currentHUDOffset.y + hudAnimationState.slideOffset.y; - // Match hitbox to full rendered HUD label buffer (including scrub reveal areas). - const hitboxWidth = Math.max( - 1, - Math.round(hudAnimationState.labelWidth || bufferW || currentHUDLabelMeasuredWidth || 1), - ); - const hitboxHeight = Math.max( - 1, - Math.round(hudAnimationState.labelHeight || bufferH || currentHUDTextHeight || currentHUDTextBoxHeight || h || 1), - ); - currentHUDButton.box.x = finalX; - currentHUDButton.box.y = finalY; - currentHUDButton.box.w = hitboxWidth; - currentHUDButton.box.h = Math.max(1, Math.round(hitboxHeight)); - - // Mark HUD button to bypass the global HUD active check (it checks itself) - currentHUDButton.noEdgeDetection = true; - // Prevent HUD button from being activated by dragging from other buttons - currentHUDButton.noRolloverActivation = true; - - // Disable button when HUD is not visible (hidden with Tab key) - currentHUDButton.disabled = !hudAnimationState.visible && !hudAnimationState.animating; - - // $commonApi.hud.currentLabel = { - // text: currentHUDTxt, - // btn: currentHUDButton, - // }; - } - } // End of skipHudRender check - close the if (!skipHudRender) block - - // Return frame data back to the main thread. - let sendData = { width: screen.width, height: screen.height }; - - // Tack on the merry progress bar at the TOP if a pipeline is running - if ($api.system.merry && $api.system.merry.running) { - const merry = $api.system.merry; - - // Calculate progress based on elapsed time (use UTC-synced clock if available) - const now = $commonApi.clock?.time?.()?.getTime?.() || Date.now(); - if (merry.startTime && merry.currentPieceStart) { - const totalElapsed = (now - merry.startTime) / 1000; // in seconds - const pieceElapsed = (now - merry.currentPieceStart) / 1000; - merry.progress = Math.min(1, totalElapsed / merry.totalDuration); - merry.pieceProgress = Math.min(1, pieceElapsed / merry.pipeline[merry.currentIndex].duration); - } - - const mainScreenWidth = screen.width; - - // Create merry progress bar painting with SEGMENTED colored blocks per piece - const merryProgressBarPainting = $api.painting(mainScreenWidth, 1, ($) => { - $.unmask(); // Ensure progress bar renders without piece mask - const animFrame = Number($api.paintCount || 0n); - - // Check for transition flash (use UTC-synced time) - const flashNow = $commonApi.clock?.time?.()?.getTime?.() || Date.now(); - let flashIntensity = 0; - if (merry.transitionFlash && merry.transitionFlash.active) { - const elapsed = flashNow - merry.transitionFlash.startTime; - if (elapsed < merry.transitionFlash.duration) { - // Ease out the flash (starts bright, fades quickly) - const progress = elapsed / merry.transitionFlash.duration; - flashIntensity = 1 - Math.pow(progress, 2); // Quadratic ease out - } else { - // Flash complete - merry.transitionFlash.active = false; - flashIntensity = 0; - } - } - - // Fill with black backdrop (or white during flash) - if (flashIntensity > 0) { - const bgBrightness = Math.floor(255 * flashIntensity); - $.ink(bgBrightness, bgBrightness, bgBrightness, 255).box(0, 0, mainScreenWidth, 1); - } else { - $.ink(0, 0, 0, 255).box(0, 0, mainScreenWidth, 1); - } - - // Define colors for each piece (cycle through palette) - const pieceColors = [ - { r: 100, g: 255, b: 100 }, // Bright green - { r: 100, g: 200, b: 255 }, // Cyan - { r: 255, g: 200, b: 100 }, // Orange - { r: 255, g: 100, b: 200 }, // Pink - { r: 200, g: 100, b: 255 }, // Purple - { r: 255, g: 255, b: 100 }, // Yellow - ]; - - // Calculate pixel width for each piece based on duration - let xOffset = 0; - merry.pipeline.forEach((piece, index) => { - const pieceWidthRatio = piece.duration / merry.totalDuration; - const pieceWidth = Math.floor(mainScreenWidth * pieceWidthRatio); - - // Determine if this piece is completed, current, or upcoming - const isCompleted = index < merry.currentIndex; - const isCurrent = index === merry.currentIndex; - const isUpcoming = index > merry.currentIndex; - - // Get color for this piece - const color = pieceColors[index % pieceColors.length]; - - // Draw this piece's segment - for (let x = xOffset; x < xOffset + pieceWidth && x < mainScreenWidth; x++) { - let baseR, baseG, baseB, alpha; - - if (isCompleted) { - // Completed pieces: Fade out progressively (darker as we move away from them) - const completedAge = merry.currentIndex - index; // How many pieces ago - const fadeFactor = Math.max(0.1, 1 - (completedAge * 0.2)); // Fade by 20% per step - baseR = Math.floor(color.r * fadeFactor * 0.25); // Start at 25% brightness - baseG = Math.floor(color.g * fadeFactor * 0.25); - baseB = Math.floor(color.b * fadeFactor * 0.25); - alpha = 255; - } else if (isCurrent) { - // Current piece: Show progress within this segment - const pieceProgress = merry.pieceProgress || 0; - const localX = x - xOffset; - const progressPoint = Math.floor(pieceWidth * pieceProgress); - - if (localX <= progressPoint) { - // Filled part of current piece - bright with pulsing - const pulse = Math.sin(animFrame * 0.3) * 0.2 + 0.8; - baseR = Math.floor(color.r * pulse); - baseG = Math.floor(color.g * pulse); - baseB = Math.floor(color.b * pulse); - alpha = 255; - - // Leader pixel - extra bright white - if (localX === progressPoint) { - const leaderPulse = Math.sin(animFrame * 0.6) * 0.3 + 0.7; - baseR = Math.floor(255 * leaderPulse); - baseG = Math.floor(255 * leaderPulse); - baseB = Math.floor(255 * leaderPulse); - } - } else { - // Unfilled part of current piece - dim preview - baseR = Math.floor(color.r * 0.3); - baseG = Math.floor(color.g * 0.3); - baseB = Math.floor(color.b * 0.3); - alpha = 255; - } - } else if (isUpcoming) { - // Upcoming pieces: Warm up as turn approaches - const stepsUntil = index - merry.currentIndex; // How many pieces away - const nextIsThis = stepsUntil === 1; - - if (nextIsThis) { - // Next piece warms up based on current piece progress - const warmup = merry.pieceProgress || 0; // 0 to 1 - const baseBrightness = 0.15; // Starting dim - const targetBrightness = 0.5; // Warm up to this - const brightness = baseBrightness + (targetBrightness - baseBrightness) * warmup; - - // Add warm orange/yellow glow as it heats up - const warmGlow = warmup * 0.3; - baseR = Math.floor(color.r * brightness + 255 * warmGlow); - baseG = Math.floor(color.g * brightness + 200 * warmGlow); - baseB = Math.floor(color.b * brightness + 100 * warmGlow * 0.5); - baseR = Math.min(255, baseR); - baseG = Math.min(255, baseG); - baseB = Math.min(255, baseB); - alpha = 255; - } else { - // Further away pieces: Very dim, getting dimmer with distance - const distanceFade = Math.max(0.05, 1 - (stepsUntil - 1) * 0.15); - baseR = Math.floor(color.r * 0.15 * distanceFade); - baseG = Math.floor(color.g * 0.15 * distanceFade); - baseB = Math.floor(color.b * 0.15 * distanceFade); - alpha = 255; - } - } - - // Fade zone overlay: blend toward next piece color at end of each segment - if (merry.fadeDuration > 0 && merry.pipeline.length > 1) { - const fadeZonePixels = Math.max(2, Math.floor(pieceWidth * merry.fadeDuration / piece.duration)); - const pixelOffset = x - xOffset; - if (pixelOffset >= pieceWidth - fadeZonePixels) { - const t = (pixelOffset - (pieceWidth - fadeZonePixels)) / Math.max(1, fadeZonePixels - 1); - const nextIdx = (index + 1) % merry.pipeline.length; - const nc = pieceColors[nextIdx % pieceColors.length]; - const maxCh = Math.max(1, color.r, color.g, color.b); - const bright = Math.max(baseR, baseG, baseB) / maxCh; - const nR = Math.floor(nc.r * bright); - const nG = Math.floor(nc.g * bright); - const nB = Math.floor(nc.b * bright); - baseR = Math.min(255, Math.floor(baseR * (1 - t) + nR * t)); - baseG = Math.min(255, Math.floor(baseG * (1 - t) + nG * t)); - baseB = Math.min(255, Math.floor(baseB * (1 - t) + nB * t)); - } - } - - // Apply transition flash boost to current piece - if (isCurrent && flashIntensity > 0) { - const boost = 1 + flashIntensity * 0.8; // Up to 80% brighter - baseR = Math.min(255, Math.floor(baseR * boost)); - baseG = Math.min(255, Math.floor(baseG * boost)); - baseB = Math.min(255, Math.floor(baseB * boost)); - } - - $.ink(baseR, baseG, baseB, alpha).box(x, 0, 1, 1); - } - - xOffset += pieceWidth; - }); - }); - - if (merryProgressBarPainting && merryProgressBarPainting.pixels && merryProgressBarPainting.pixels.length > 0) { - sendData.merryProgressBar = { - x: 0, - y: 0, // Position at TOP of screen (1px tall) - img: { - width: merryProgressBarPainting.width, - height: merryProgressBarPainting.height, - pixels: merryProgressBarPainting.pixels - } - }; - - // 🎄⏰ Add UTC time overlay for sync debugging - const utcDate = $commonApi.clock?.time?.() || new Date(); - const utcTimeStr = utcDate.toISOString().split('T')[1].split('.')[0]; // HH:MM:SS - const utcMs = utcDate.getMilliseconds().toString().padStart(3, '0'); - const cyclePos = ((now % (merry.totalDuration * 1000)) / 1000).toFixed(2); - sendData.merryUTC = { - time: `${utcTimeStr}.${utcMs}`, - cycle: cyclePos, - piece: merry.pipeline[merry.currentIndex]?.piece || '?', - index: merry.currentIndex + 1, - total: merry.pipeline.length - }; - } - } - - // 🎬 Demoplay text card overlay - if ($commonApi.system.demoplay?.card) { - const card = $commonApi.system.demoplay.card; - const elapsed = Date.now() - card.startTime; - const totalDur = card.duration; - const fadeIn = card.fadeIn || 500; - const fadeOut = card.fadeOut || 500; - - // Calculate opacity with fade in/out - let opacity = 1; - if (elapsed < fadeIn) { - opacity = elapsed / fadeIn; - } else if (elapsed > totalDur - fadeOut) { - opacity = Math.max(0, (totalDur - elapsed) / fadeOut); - } - card.opacity = opacity; - - const sw = screen.width; - const sh = screen.height; - const cardPainting = $api.painting(sw, sh, ($) => { - $.unmask(); - // Semi-transparent background - $.ink(0, 0, 0, Math.floor(opacity * 180)); - $.box(0, 0, sw, sh); - // White text, centered - $.ink(255, 255, 255, Math.floor(opacity * 255)); - const lines = card.text.split("\n"); - const lineHeight = 10; - const startY = Math.floor((sh - lines.length * lineHeight) / 2); - lines.forEach((line, i) => { - $.write(line, { center: "x", y: startY + i * lineHeight }); - }); - }); - - if (cardPainting?.pixels?.length > 0) { - sendData.demoplayCard = { - x: 0, - y: 0, - img: { - width: cardPainting.width, - height: cardPainting.height, - pixels: cardPainting.pixels, - }, - }; - } - } else if (sendData.demoplayCard) { - delete sendData.demoplayCard; - } - - // Tack on the tape progress bar pixel buffer if necessary. - if (!$api.rec.cleanMode && ($api.rec.tapeProgress || ($api.rec.recording && $api.rec.tapeTimerDuration))) { - const progress = $api.rec.tapeProgress || 0; - - // Determine if we should show progress bar at all - const isFrameBased = $api.rec.tapeFrameMode && $api.rec.tapeFrameTarget > 0; - const frameCount = $api.rec.tapeFrameTarget || 1; - - // Hide progress bar for ALL frame-based recordings - if (isFrameBased) { - // Skip creating progress bar entirely for frame recordings - } else { - // IMPORTANT: Access the main screen buffer OUTSIDE the painting context - // because inside painting(), $api.screen refers to the painting's own buffer, not the main screen - const mainScreenPixels = screen.pixels; // This is the actual frame content - const mainScreenWidth = screen.width; - const mainScreenHeight = screen.height; - - const isShortRecording = isFrameBased ? frameCount <= 120 : ($api.rec.tapeTimerDuration || 0) <= 1; - - let currentProgressWidth; - if (isFrameBased) { - // For frame-based recording, create discrete segments - const framesPassed = Math.floor(progress * frameCount); - const segmentWidth = Math.max(1, Math.floor(mainScreenWidth / frameCount)); - currentProgressWidth = framesPassed * segmentWidth; - } else { - // For time-based recording, use smooth progress - currentProgressWidth = Math.floor(mainScreenWidth * progress); - } - - // Create tape progress bar painting with VHS-style red glow - const tapeProgressBarPainting = $api.painting(mainScreenWidth, 2, ($) => { - $.unmask(); // Ensure tape progress bar renders without piece mask - // Animation frame for VHS effects - increased speed for more vibes - const animFrame = Number($api.paintCount || 0n); - - // Helper function to sample pixel color from above the progress bar - const sampleColorFromAbove = (x) => { - // Skip sampling during early frames for performance (first ~5 frames) - const frameCount = Number($api.paintCount || 0n); - if (frameCount < 5 || !mainScreenPixels || mainScreenPixels.length === 0) { - return { r: 0, g: 0, b: 0 }; // Return black during startup - } - - // Sample from a few pixels above the progress bar position - const sampleY = Math.max(0, screen.height - 10); // Sample 10px above progress bar - const pixelIndex = (sampleY * screen.width + x) * 4; - - if (pixelIndex >= 0 && pixelIndex < mainScreenPixels.length - 3) { - return { - r: mainScreenPixels[pixelIndex], - g: mainScreenPixels[pixelIndex + 1], - b: mainScreenPixels[pixelIndex + 2] - }; - } - // Fallback to black if can't sample - return { r: 0, g: 0, b: 0 }; - }; - - // Special color override for first and last frames - const isFirstFrame = progress <= 0.01; // First 1% of progress - const isLastFrame = progress >= 0.99; // Last 1% of progress - - // Calculate smooth alpha fade - progress bar fades from 25% to 75% for longer clean content viewing - // Skip fade animation for short recordings - let progressBarAlpha = 1.0; // Default to fully visible - - if (!isShortRecording) { - if (progress >= 0.20 && progress <= 0.30) { - // Fade out from 20% to 30% (10% fade-out period) - progressBarAlpha = 1.0 - ((progress - 0.20) / 0.10); - } else if (progress > 0.30 && progress < 0.70) { - // Fully hidden from 30% to 70% (40% hidden period) - progressBarAlpha = 0.0; - } else if (progress >= 0.70 && progress <= 0.80) { - // Fade in from 70% to 80% (10% fade-in period) - progressBarAlpha = (progress - 0.70) / 0.10; - } - } - - // Fill entire bar with black backdrop using fade alpha - $.ink(0, 0, 0, Math.floor(progressBarAlpha * 255)).box(0, 0, mainScreenWidth, 2); - - if (isFrameBased) { - // Draw segmented progress for frame-based recording - const segmentWidth = Math.max(1, Math.floor(mainScreenWidth / frameCount)); - const framesPassed = Math.floor(progress * frameCount); - const currentProgressWidth = framesPassed * segmentWidth; - - // Draw VHS-style progress bar pixel by pixel (same as time-based but with discrete frame spacing and no leader pixel) - for (let x = 0; x < mainScreenWidth; x++) { - let baseR, baseG, baseB; - - if (x < currentProgressWidth) { - // FILLED AREA - VHS red with analog glow and scan lines - - if (isFirstFrame) { - // FIRST FRAME - Fully green across entire bar - baseR = 0; - baseG = 255; - baseB = 0; - } else if (isLastFrame) { - // LAST FRAME - Fully red across entire bar - baseR = 255; - baseG = 0; - baseB = 0; - } else { - // NORMAL FRAMES - Enhanced color sampling from buffer above - const sampledColor = sampleColorFromAbove(x); - - // Sample additional colors for more variety - const sampledColor2 = sampleColorFromAbove(Math.max(0, x - 3)); - const sampledColor3 = sampleColorFromAbove(Math.min(mainScreenWidth - 1, x + 3)); - - // Mix multiple sampled colors for richer palette - const mixedR = Math.floor((sampledColor.r + sampledColor2.r + sampledColor3.r) / 3); - const mixedG = Math.floor((sampledColor.g + sampledColor2.g + sampledColor3.g) / 3); - const mixedB = Math.floor((sampledColor.b + sampledColor2.b + sampledColor3.b) / 3); - const mixedColor = { r: mixedR, g: mixedG, b: mixedB }; - - // Base VHS red intensity - reduced to let more color through - let redIntensity = 200; // Reduced from 255 for more color mixing - - // Use shared VHS effects calculation - const effects = calculateVHSEffects(x, animFrame); - - // Combine all VHS effects with brighter base - redIntensity = Math.floor(redIntensity * effects.scanLine * effects.analogGlow * effects.tracking * effects.secondaryGlow); - - // Create VHS red color - const vhsR = Math.max(180, Math.min(255, redIntensity)); // Reduced min from 200 - const vhsG = Math.floor(vhsR * 0.05); // Very minimal green - const vhsB = Math.floor(vhsR * 0.02); // Very minimal blue - - // Use shared blending function (55% sampled, 45% VHS red for more color influence) - const blended = blendColorWithVHS(mixedColor, vhsR, vhsG, vhsB, 0.55); - baseR = blended.r; - baseG = blended.g; - baseB = blended.b; - } - - // Use proper alpha blending for filled area (no special leader pixel treatment for frame-based) - $.ink(baseR, baseG, baseB, Math.floor(progressBarAlpha * 255)).box(x, 0, 1, 2); - } else { - // UNFILLED AREA - Keep black background (already filled above) - // No need to redraw black pixels, they're already set - } - } - } else { - // Draw VHS-style progress bar pixel by pixel (original time-based logic) - for (let x = 0; x < mainScreenWidth; x++) { - let baseR, baseG, baseB; - - // Leading edge pixel - bright glowing leader - const isLeaderPixel = x === currentProgressWidth - 1 && currentProgressWidth > 0; - - if (x < currentProgressWidth) { - // FILLED AREA - VHS red with analog glow and scan lines - - if (isFirstFrame) { - // FIRST FRAME - Fully green across entire bar - baseR = 0; - baseG = 255; - baseB = 0; - } else if (isLastFrame) { - // LAST FRAME - Fully red across entire bar - baseR = 255; - baseG = 0; - baseB = 0; - } else { - // NORMAL FRAMES - Enhanced color sampling from buffer above - const sampledColor = sampleColorFromAbove(x); - - // Sample additional colors for more variety - const sampledColor2 = sampleColorFromAbove(Math.max(0, x - 3)); - const sampledColor3 = sampleColorFromAbove(Math.min(mainScreenWidth - 1, x + 3)); - - // Mix multiple sampled colors for richer palette - const mixedR = Math.floor((sampledColor.r + sampledColor2.r + sampledColor3.r) / 3); - const mixedG = Math.floor((sampledColor.g + sampledColor2.g + sampledColor3.g) / 3); - const mixedB = Math.floor((sampledColor.b + sampledColor2.b + sampledColor3.b) / 3); - const mixedColor = { r: mixedR, g: mixedG, b: mixedB }; - - // Base VHS red intensity - reduced to let more color through - let redIntensity = 200; // Reduced from 255 for more color mixing - - // Use shared VHS effects calculation - const effects = calculateVHSEffects(x, animFrame); - - // Combine all VHS effects with brighter base - redIntensity = Math.floor(redIntensity * effects.scanLine * effects.analogGlow * effects.tracking * effects.secondaryGlow); - - // Create VHS red color - const vhsR = Math.max(180, Math.min(255, redIntensity)); // Reduced min from 200 - const vhsG = Math.floor(vhsR * 0.05); // Very minimal green - const vhsB = Math.floor(vhsR * 0.02); // Very minimal blue - - // Use shared blending function (55% sampled, 45% VHS red for more color influence) - const blended = blendColorWithVHS(mixedColor, vhsR, vhsG, vhsB, 0.55); - baseR = blended.r; - baseG = blended.g; - baseB = blended.b; - } - - // Special leader pixel treatment - if (isLeaderPixel) { - if (isFirstFrame) { - // First frame - bright green leader - baseR = 0; - baseG = 255; - baseB = 0; - } else if (isLastFrame) { - // Last frame - bright red leader - baseR = 255; - baseG = 0; - baseB = 0; - } else { - // Check if we're in the fade period (20%-80%) to enable special blinking - const isInFadePeriod = progress >= 0.20 && progress <= 0.80; - - if (isInFadePeriod && !isShortRecording) { - // During fade period - cycle through yellow, lime, and other colors for visibility - const colorCycle = Math.floor(animFrame * 0.3) % 4; // Slower color cycling - - switch (colorCycle) { - case 0: - baseR = 255; baseG = 255; baseB = 0; // Yellow - break; - case 1: - baseR = 0; baseG = 255; baseB = 0; // Lime - break; - case 2: - baseR = 255; baseG = 128; baseB = 0; // Orange - break; - case 3: - baseR = 0; baseG = 255; baseB = 255; // Cyan - break; - } - } else { - // Normal behavior outside fade period - super bright white-hot leader with pulsing - const leaderPulse = Math.sin(animFrame * 0.6) * 0.2 + 0.8; - - baseR = 255; - baseG = Math.floor(255 * leaderPulse); // Bright white-hot leader - baseB = Math.floor(255 * leaderPulse); - } - } - } - - // Apply alpha fade to final colors, with beacon-like leader pixel - let finalAlpha = progressBarAlpha; - if (isLeaderPixel) { - if (progress >= 0.20 && progress <= 0.80 && !isShortRecording) { - // During fade period - beacon-like signal marker (strong but not full opacity) - finalAlpha = 0.8; // Bright beacon for progress indication - } else { - // Outside fade period - still visible beacon - finalAlpha = Math.min(progressBarAlpha, 0.9); // Strong beacon at 90% opacity - } - } - - // Use proper alpha blending for filled area - $.ink(baseR, baseG, baseB, Math.floor(finalAlpha * 255)).box(x, 0, 1, 2); - } else { - // UNFILLED AREA - Keep black background (already filled above) - // No need to redraw black pixels, they're already set - } - } - } - }); - - // Ensure the painting was created successfully before adding to sendData - if (tapeProgressBarPainting && tapeProgressBarPainting.pixels && tapeProgressBarPainting.pixels.length > 0) { - // Structure the data to match what bios.mjs expects (same as label format) - sendData.tapeProgressBar = { - x: 0, - y: screen.height - 2, // Position at bottom of screen (2px tall) - img: { - width: tapeProgressBarPainting.width, - height: tapeProgressBarPainting.height, - pixels: tapeProgressBarPainting.pixels - } - }; - } else { - console.warn("🎬 Tape progress bar painting FAILED to create:", { - painting: !!tapeProgressBarPainting, - pixels: !!tapeProgressBarPainting?.pixels, - pixelsLength: tapeProgressBarPainting?.pixels?.length - }); - } - } // Close the else block for frame count >= 60 - } - - maybeLeave(); - - // TODO: Write this up to the data in `painting`. - sendData.TwoD = { code: twoDCommands }; - - // Attach a label buffer if necessary. - // Hide label when QR is in fullscreen mode or in device mode (device.html has its own overlay) - if (label && !hudAnimationState.qrFullscreen && !DEVICE_MODE) { - const finalX = currentHUDOffset.x + hudAnimationState.slideOffset.x - currentHUDLeftPad; - // Move label up by 4px when QR is present for tighter fit - const qrYOffset = currentHUDQR ? -4 : 0; - const finalY = currentHUDOffset.y + hudAnimationState.slideOffset.y + qrYOffset; - - sendData.label = { - x: finalX, - y: finalY, - opacity: hudAnimationState.opacity, - img: (({ width, height, pixels }) => ({ width, height, pixels }))( - label, - ), - }; - - // DEBUG: Add hitbox visualization overlay - if (globalThis.debugHudHitbox && currentHUDButton) { - const hitboxWidth = currentHUDButton.box.w; - const hitboxHeight = currentHUDButton.box.h; - const blinkFrame = Number($api.paintCount || 0n); - const blinkOn = (blinkFrame % 30) < 15; - const outerAlpha = blinkOn ? 200 : 80; - const innerAlpha = blinkOn ? 120 : 40; - - const hitboxOverlay = $api.painting(hitboxWidth, hitboxHeight, ($) => { - $.unpan(); - $.unmask(); // Ensure hitbox overlay renders without piece mask - // Draw a blinking green border to show the hitbox - $.ink(0, 255, 0, outerAlpha).box(0, 0, hitboxWidth, hitboxHeight, "outline"); - if (hitboxWidth > 2 && hitboxHeight > 2) { - $.ink(0, 255, 0, innerAlpha).box(1, 1, hitboxWidth - 2, hitboxHeight - 2, "outline"); - } - }); - - sendData.hitboxDebug = { - x: currentHUDButton.box.x, - y: currentHUDButton.box.y, - opacity: hudAnimationState.opacity, - img: (({ width, height, pixels }) => ({ width, height, pixels }))( - hitboxOverlay, - ), - }; - } - } - - // Generate QR code overlay for KidLisp pieces - let qrOverlay; - - // Skip QR/HUD overlay in device mode - device.kidlisp.com has its own DOM overlay - // DEVICE_MODE is set from bios init message (can't use location.search in worker) - const isDeviceModeQR = DEVICE_MODE; - - // Clear QR cache if caching is disabled to prevent memory buildup - if (isQROverlayCacheDisabled() && qrOverlayCache.size > 0) { - qrOverlayCache.clear(); - } - - // Detect if this is a KidLisp piece - const sourceCode = currentText || currentHUDTxt; // Use plain currentText first, then fall back to HUD text - - // Use centralized KidLisp detection for QR code generation - // But exclude proper .lisp files which should not show source HUD - const isInlineKidlispPiece = (currentPath && lisp.isKidlispSource(currentPath) && !currentPath.endsWith('.lisp')) || - currentPath === "(...)" || - // Detect cached KidLisp pieces by $code format - (sourceCode && sourceCode.startsWith("$")) || - (currentPath && currentPath.includes("/disks/$")) || - // Use the centralized KidLisp detection that includes comma syntax - (sourceCode && lisp.isKidlispSource(sourceCode)); - - // Detect if this is a clock piece with a cached code - const isClockPieceWithCode = (currentPath === "clock" || currentPath?.endsWith("/clock") || currentPath?.includes("/disks/clock")) && cachedClockCode; - - // Debug: log clock detection (disabled - too verbose) - // if (cachedClockCode) { - // console.log("🎵 [QR Debug] Clock QR check:", { currentPath, cachedClockCode, isClockPieceWithCode, hideLabel, hudVisible: hudAnimationState.visible }); - // } - - // For clock pieces with cached code, always show the QR (don't require HUD visible) - // For KidLisp pieces, require HUD to be visible/animating/fullscreen - const shouldShowQRForKidLisp = (isInlineKidlispPiece && sourceCode) && (hudAnimationState.visible || hudAnimationState.animating || hudAnimationState.qrFullscreen); - const shouldShowQRForClock = isClockPieceWithCode; // Always show for clock pieces - - // Skip ALL pixelated HUD overlays in device mode (device.html has DOM-based overlays) - if (!isDeviceModeQR && (shouldShowQRForKidLisp || shouldShowQRForClock) && !hideLabel) { - // console.log("🎵 [QR Debug] Entering QR generation for clock"); - try { - // For clock pieces with cached code, use the cached clock code directly - // For KidLisp pieces, determine cachedCode from sourceCode - let cachedCode; - let sigil = "$"; // Default sigil for KidLisp - - if (isClockPieceWithCode) { - // Clock piece: use the cached clock code with * sigil - cachedCode = cachedClockCode; - sigil = "*"; - } else if (sourceCode.startsWith("$")) { - // For $code format, the sourceCode IS the cached code (without $) - cachedCode = sourceCode.substring(1).split(":")[0]; // Remove the $ prefix and any :fps colon params - // console.log("🔑 [QR Debug] Using $code format, cachedCode:", cachedCode); - } else { - // For regular KidLisp source, check if it has been cached - cachedCode = getCachedCode(sourceCode); - // console.log("🔍 [QR Debug] Checked cache, cachedCode:", cachedCode); - - // If not cached yet but QR fullscreen is active (shift was pressed), - // trigger caching and request a repaint when complete - if (!cachedCode && hudAnimationState.qrFullscreen) { - // console.log("⏳ [QR Debug] Code not cached, triggering caching..."); - const kidlispInstance = getGlobalKidLisp(); - if (kidlispInstance) { - // console.log("✅ [QR Debug] Got KidLisp instance, calling cacheKidlispSource"); - // Trigger caching asynchronously - kidlispInstance.cacheKidlispSource(sourceCode, $commonApi).then(() => { - // Check if code was successfully cached - const newCachedCode = getCachedCode(sourceCode); - if (newCachedCode) { - //console.log(`🔄 Code cached after shift press: $${newCachedCode}`); - // Trigger repaint to show the QR code - if (typeof window !== "undefined" && window.$activePaintApi?.needsPaint) { - window.$activePaintApi.needsPaint(); - } - } - }).catch(err => { - console.warn("Failed to cache KidLisp code:", err); - }); - } - } - } - - if (cachedCode) { - // Send the cached code to main thread for tape naming (only for KidLisp) - if (sigil === "$") { - send({ type: "kidlisp:cached-code", content: cachedCode }); - } - - // Use cache key based on sigil + cached code to avoid regenerating the same QR - const cacheKey = `qr_${sigil}${cachedCode}`; - - // Get the font and ensure it's properly loaded before proceeding - const font = typefaceCache.get("MatrixChunky8"); - - // Check if ALL glyphs in the label text are loaded - // The label will be sigil + cachedCode (e.g., "$wipe", "*bako", etc.) - // We need to verify every character's glyph is loaded to avoid pop-in - const labelText = `${sigil}${cachedCode}`; - let allGlyphsLoaded = false; - - if (font && font.glyphs) { - allGlyphsLoaded = true; - const missingGlyphs = []; - - for (const char of labelText) { - const glyph = font.glyphs[char]; - // console.log('[MatrixChunky8] Glyph check:', { char, charCode: char.charCodeAt(0), glyph: glyph ? 'exists' : 'missing', glyphType: typeof glyph }); - // A real glyph has properties like dwidth, advance, or resolution - // The Proxy returns null for missing glyphs - const isRealGlyph = glyph && glyph !== null && - (glyph.dwidth || glyph.advance !== undefined || glyph.resolution); - - if (!isRealGlyph) { - allGlyphsLoaded = false; - missingGlyphs.push(char); - // console.log('[MatrixChunky8] Missing glyph:', char, 'charCode:', char.charCodeAt(0)); - } - } - - // if (!allGlyphsLoaded) { - // console.log('[QR] MatrixChunky8 glyphs pending:', { - // labelText, - // missingGlyphs: missingGlyphs.join('') || 'unknown' - // }); - // } - } - - const shouldShowQR = allGlyphsLoaded; - - // Track when glyphs first become available to delay caching by one frame - // This ensures the painting callback has completed rendering before we cache - if (font && shouldShowQR && font.__qrGlyphsReadyFrame === undefined) { - // First frame where glyphs are loaded - mark the frame number - font.__qrGlyphsReadyFrame = (typeof window !== "undefined" && window.frameCount) || 0; - } - - // Safe to cache if glyphs were ready at least 1 frame ago (or we don't have frameCount) - const currentFrame = (typeof window !== "undefined" && window.frameCount) || 0; - const safeToCache = shouldShowQR && ( - font?.__qrGlyphsReadyFrame === undefined || - currentFrame > font.__qrGlyphsReadyFrame - ); - - // If font exists but glyphs not loaded yet, trigger loading and request repaint - if (font && !shouldShowQR) { - font.__qrGlyphsReadyFrame = undefined; // Reset the ready frame tracking - if (!font.__loadPromise) { - ensureTypefaceLoaded(font); - } - if (font.__loadPromise) { - font.__loadPromise.then(() => { - font.__loaded = true; - // Clear QR cache for this code so it regenerates with proper font - qrOverlayCache.delete(cacheKey); - // Trigger repaint to show QR once font is ready - if (typeof window !== "undefined" && window.$activePaintApi?.needsPaint) { - window.$activePaintApi.needsPaint(); - } - }).catch((err) => { - console.warn("Failed to load MatrixChunky8 for QR:", err); - font.__loaded = true; // Mark as loaded to prevent infinite waiting - }); - } - } - - - // Check if this QR overlay is already cached (unless caching is disabled) - const isQRCacheDisabled = isQROverlayCacheDisabled(); - const hasQRCache = qrOverlayCache.has(cacheKey); - - // Declare variables for QR positioning and sizing (used in both cached and fresh QR paths) - let overlayWidth, overlayHeight, startX, startY; - - - // Render QR from cache if available, or generate fresh - // Note: We render even if glyphs aren't loaded yet - they'll pop in on repaint - if (!isQRCacheDisabled && hasQRCache) { - - const cachedQrData = qrOverlayCache.get(cacheKey); - - // Store QR dimensions in animation state for proper bounding box animations - hudAnimationState.qrSize = Math.max(cachedQrData.width, cachedQrData.height); - - if (hudAnimationState.qrFullscreen) { - // Fullscreen mode: integer scaling only for pixel-perfect QR - const originalWidth = cachedQrData.width; - const originalHeight = cachedQrData.height; - - // Calculate maximum integer scale that fits screen with padding - const padding = Math.min(screen.width, screen.height) * 0.1; // 10% padding - const maxWidth = screen.width - padding * 2; - const maxHeight = screen.height - padding * 2; - - const maxScaleX = Math.floor(maxWidth / originalWidth); - const maxScaleY = Math.floor(maxHeight / originalHeight); - const scale = Math.min(maxScaleX, maxScaleY, 8); // Cap at 8x scale - - // Use integer scaling for pixel-perfect results - overlayWidth = originalWidth * scale; - overlayHeight = originalHeight * scale; - - // Calculate text dimensions for canvas sizing - const codeText = `${sigil}${cachedCode}`; - const fontSize = 16; // Fixed 2x scale (8px base font * 2 = 16px) - const textPadding = 8; - - // Calculate actual text width using font advances for proper sizing - const font = typefaceCache.get("MatrixChunky8"); - const advances = font?.data?.advances || {}; - let actualTextWidth = 0; - for (const char of codeText) { - const charWidth = advances[char] || 4; // Default 4px per char if no advance data - actualTextWidth += charWidth; - } - // Scale up by 2x for our size: 2 scaling - actualTextWidth *= 2; - - const textWidth = actualTextWidth + textPadding * 2; // Add padding - const textHeight = fontSize + textPadding * 2; - - // Use full screen canvas to position QR code (centered) and text - const canvasHeight = screen.height; - const canvasWidth = screen.width; - - // Center QR on screen (ensure integer coordinates) - const qrX = Math.floor((screen.width - overlayWidth) / 2); - const qrY = Math.floor((screen.height - overlayHeight) / 2); - - // Position starts at top-left of screen since we're using full screen canvas - startX = 0; - startY = 0; - - // Create scaled QR overlay with styled code text - qrOverlay = $api.painting(canvasWidth, canvasHeight, async ($) => { - $.unmask(); // Ensure QR overlay renders without piece mask - // Draw scaled QR with integer scaling (centered in canvas) - - for (let y = 0; y < originalHeight; y++) { - for (let x = 0; x < originalWidth; x++) { - const srcIndex = (y * originalWidth + x) * 4; - - if (srcIndex < cachedQrData.basePixels.length) { - const r = cachedQrData.basePixels[srcIndex]; - const g = cachedQrData.basePixels[srcIndex + 1]; - const b = cachedQrData.basePixels[srcIndex + 2]; - - $.ink(r, g, b); - // Draw scale x scale pixel block - $.box(qrX + x * scale, qrY + y * scale, scale, scale); - } - } - } - - // Add styled code text positioned at top-left corner of screen - const textX = 10; // Small margin from left edge of screen - const textY = 10; // Small margin from top edge of screen - - // Draw black shadow - $.ink("black"); - $.write(codeText, { x: textX + 1, y: textY + 1, size: 2 }); - - // Draw white text on black background - try MatrixChunky8 first, fallback to default - $.ink("white"); - const matrixFont = typefaceCache.get("MatrixChunky8"); - if (matrixFont && matrixFont.glyphs) { - try { - $.write(codeText, { x: textX, y: textY, size: 2 }, undefined, undefined, false, "MatrixChunky8"); - } catch (error) { - // Fallback to default font if MatrixChunky8 fails - $.write(codeText, { x: textX, y: textY, size: 2 }); - } - } else { - // Use default font if MatrixChunky8 not available - $.write(codeText, { x: textX, y: textY, size: 2 }); - } - - // Add "TAP TO CLOSE" instruction at bottom center of screen - const closeText = "TAP TO CLOSE"; - const closeTextY = screen.height - 20; // 20px from bottom - const closeTextX = Math.floor(screen.width / 2) - 30; // Rough center - - // Draw black shadow for close text - $.ink("black"); - $.write(closeText, { x: closeTextX + 1, y: closeTextY + 1, size: 1 }); - - // Draw white close text - $.ink("white"); - $.write(closeText, { x: closeTextX, y: closeTextY, size: 1 }); - }); - } else { - // Normal mode: use original positioning - const margin = 4; - overlayWidth = cachedQrData.width; - overlayHeight = cachedQrData.height; - startX = screen.width - overlayWidth - margin; - startY = screen.height - overlayHeight - margin; - - // Create fresh overlay for transfer from cached data - qrOverlay = { - width: cachedQrData.width, - height: cachedQrData.height, - pixels: new Uint8ClampedArray(cachedQrData.basePixels) // Fresh copy for transfer - }; - } - - // Add QR overlay to sendData with animation effects - sendData.qrOverlay = { - x: startX + (hudAnimationState.qrFullscreen ? 0 : hudAnimationState.qrSlideOffset.x), - y: startY + (hudAnimationState.qrFullscreen ? 0 : hudAnimationState.qrSlideOffset.y), - opacity: hudAnimationState.opacity, - img: qrOverlay - }; - - // Add clickable hitbox for QR code - if (hudAnimationState.qrFullscreen) { - // Fullscreen QR: make entire QR tappable to go back - send({ - type: "button:hitbox:add", - content: { - label: "qr-fullscreen", - box: { - x: startX, - y: startY, - w: overlayWidth, - h: overlayHeight - }, - message: "qr-fullscreen-tap" - } - }); - } else { - // Corner QR: make it tappable to go fullscreen - if (!SOLO_MODE) { - send({ - type: "button:hitbox:add", - content: { - label: "qr-corner", - box: { - x: startX + hudAnimationState.qrSlideOffset.x, - y: startY + hudAnimationState.qrSlideOffset.y, - w: overlayWidth, - h: overlayHeight - }, - message: "qr-corner-tap" - } - }); - } - } - - } else { - - // Always use prompt.ac for QR codes (even in dev/local) - let url = "https://prompt.ac"; - - // Use the cached nanoid code with appropriate sigil prefix for a short URL - // $ for KidLisp, * for clock melodies - url += `/${sigil}${cachedCode}`; - - // Generate QR code with medium error correction for better scannability - const cells = qr(url, { errorCorrectLevel: ErrorCorrectLevel.M }).modules; - - // Calculate size and position for bottom-right corner with 4px margin - const margin = 4; - const cellSize = 1; // 1 pixel per cell for smallest 1:1 size - const qrSize = cells.length * cellSize; - - // Store QR dimensions in animation state for proper bounding box animations - hudAnimationState.qrSize = qrSize; - - // Position in bottom-right corner - let startX = screen.width - qrSize - margin - 1; // Account for shadow width - const textHeight = 12; // Space for MatrixChunky8 8px font with shadow (8px + 4px padding) - const totalHeight = qrSize + textHeight; - let startY = screen.height - totalHeight - margin; // Move 1px closer to bottom for balanced margins - - // Create QR overlay using painting API with extra space for shadow - const textAreaHeight = 9; - const qrOffsetY = textAreaHeight; // QR starts right after text area (no gap) - const canvasHeight = qrOffsetY + qrSize + 2; // Ensure room for bottom shadow - const generatedQR = $api.painting(qrSize + 1, canvasHeight, async ($) => { - $.unmask(); // Ensure QR renders without piece mask - // Draw QR code at offset position to make room for text above - for (let y = 0; y < cells.length; y++) { - for (let x = 0; x < cells.length; x++) { - const isBlack = cells[y][x]; - if (isBlack) { - $.ink("black"); - } else { - $.ink("white"); - } - $.box(x * cellSize, (y * cellSize) + qrOffsetY, cellSize); // Add qrOffsetY to move QR down - } - } - - // QR text style configuration - const useBackdrop = true; // Set to true for backdrop style, false for shadow style - - // Prepare text for rendering (using sigil variable: $ for KidLisp, * for clock) - const codeToRender = `${sigil}${cachedCode}`; - - // Calculate actual rendered width for mathematical centering - // Get character advances from the font definition - const advances = font?.data?.advances || typefaceCache.get("MatrixChunky8")?.data?.advances || {}; - - // Calculate text dimensions and positioning - let actualTextWidth = 0; - for (const char of codeToRender) { - const charWidth = advances[char] || 4; - actualTextWidth += charWidth; - } - - // Text area configuration - be more generous with sizing - const textPaddingLeft = 1; // Minimal left padding - const textPaddingRight = 1; // Minimal right padding for safety - const textAreaWidth = actualTextWidth + textPaddingLeft + textPaddingRight; - const textAreaHeight = 10; // Increased height (was 9) - - // Position text area above QR code (which is now at qrOffsetY) - const textAreaX = qrSize - textAreaWidth; // Still flush right - const textAreaY = 0; // At the top of the canvas - - // Text position within the text area (right-aligned with left padding only) - const textX = textAreaX + textPaddingLeft; // Only left padding - const textY = textAreaY + 1; // Vertical centering within smaller text area (was 2) - - // Render text with appropriate style - if (useBackdrop) { - // Draw black background for text area (sized to fit text) - $.ink("black"); // Black background - $.box(textAreaX, textAreaY, textAreaWidth, textAreaHeight); - - // Render white text (no rotation for now) - $.ink("white"); // White text on black background - - if (getPackMode()) { - // In OBJKT mode, try MatrixChunky8 first, fall back to default if not available - let matrixFont = typefaceCache.get("MatrixChunky8"); - if (matrixFont) { - - // Check if this is a proper Typeface instance with all methods - if (!matrixFont.getGlyph || typeof matrixFont.getGlyph !== 'function') { - // Create a new proper Typeface instance - const newMatrixFont = new Typeface("MatrixChunky8"); - await newMatrixFont.load($commonApi.net.preload); - // Copy over existing glyphs data if any - if (matrixFont.glyphs) { - Object.assign(newMatrixFont.glyphs, matrixFont.glyphs); - } - matrixFont = newMatrixFont; - typefaceCache.set("MatrixChunky8", matrixFont); - } - - // Check if glyphs actually work by testing a specific character - const testGlyph = matrixFont.glyphs['$']; - // MatrixChunky8 uses BDF structure: {resolution, offset, baselineOffset, advance, commands, bbx} - const glyphsWorking = testGlyph && typeof testGlyph === 'object' && - (testGlyph.pixels || testGlyph.commands || testGlyph.resolution); - - if (!glyphsWorking) { - // Create simple fallback glyphs for common characters - const fallbackGlyph = { - resolution: [6, 8], - pixels: [ - [0, 1, 1, 1, 1, 0], - [1, 0, 0, 0, 0, 1], - [1, 0, 1, 1, 0, 1], - [1, 0, 1, 1, 0, 1], - [1, 0, 0, 0, 0, 1], - [1, 0, 0, 0, 0, 1], - [0, 1, 1, 1, 1, 0], - [0, 0, 0, 0, 0, 0] - ] - }; - - // DO NOT replace glyphs object - it's a Proxy that loads on-demand - // Just populate common characters if they're missing - if (matrixFont.glyphs) { - const commonChars = '$rozeabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - for (const char of commonChars) { - if (!matrixFont.glyphs[char]) { - matrixFont.glyphs[char] = fallbackGlyph; - matrixFont[char] = fallbackGlyph; // Also set directly on font object - } - } - } - } - - // Check if font has reasonable glyph data before rendering - const testChar = matrixFont.glyphs && (matrixFont.glyphs['$'] || matrixFont.glyphs['A'] || matrixFont.glyphs['a']); - const hasValidGlyphs = testChar && (testChar.pixels || testChar.commands || testChar.resolution); - if (hasValidGlyphs) { - $.write(codeToRender, { x: textX, y: textY }, undefined, undefined, false, "MatrixChunky8"); - } else { - // Fallback to default font if MatrixChunky8 isn't ready - $.write(codeToRender, { x: textX, y: textY }); - } - } else { - // Fallback to default font if MatrixChunky8 not available - $.write(codeToRender, { x: textX, y: textY }); - } - } else { - // Use MatrixChunky8 font in normal mode if available - const matrixFont = typefaceCache.get("MatrixChunky8"); - - // Check if MatrixChunky8 glyphs are actually loaded and ready - let useMatrixFont = false; - if (matrixFont && matrixFont.glyphs) { - // Check if ALL glyphs in codeToRender are loaded - useMatrixFont = true; - for (const char of codeToRender) { - const glyph = matrixFont.glyphs[char]; - const isLoaded = glyph && (glyph.pixels || glyph.commands || glyph.resolution); - if (!isLoaded) { - useMatrixFont = false; - break; - } - } - } - - // Only render text if MatrixChunky8 is ready (will trigger repaint when it loads) - if (useMatrixFont) { - $.write(codeToRender, { x: textX, y: textY }, undefined, undefined, false, "MatrixChunky8"); - } - // Otherwise skip rendering text - it will pop in on next repaint - } - } else { - // Shadow style: black shadow first, then white text - // Draw black shadow (1px offset) - no rotation for now - $.ink("black"); - - if (getPackMode()) { - // In OBJKT mode, try MatrixChunky8 first, fall back to default if not available - const matrixFont = typefaceCache.get("MatrixChunky8"); - if (matrixFont && matrixFont.glyphs) { - const testChar = matrixFont.glyphs['$'] || matrixFont.glyphs['A'] || matrixFont.glyphs['a']; - const hasValidGlyphs = testChar && (testChar.pixels || testChar.commands || testChar.resolution); - if (hasValidGlyphs) { - $.write(codeToRender, { x: textX + 1, y: textY + 1 }, undefined, undefined, false, "MatrixChunky8"); - } else { - $.write(codeToRender, { x: textX + 1, y: textY + 1 }); - } - } else { - $.write(codeToRender, { x: textX + 1, y: textY + 1 }); - } - } else { - // Check if MatrixChunky8 glyphs are loaded for shadow - const matrixFont = typefaceCache.get("MatrixChunky8"); - let useMatrixFont = false; - if (matrixFont && matrixFont.glyphs) { - const testGlyph = matrixFont.glyphs['$'] || matrixFont.glyphs['a']; - useMatrixFont = !!(testGlyph && (testGlyph.pixels || testGlyph.commands || testGlyph.resolution)); - } - - // Only render if MatrixChunky8 is ready - if (useMatrixFont) { - $.write(codeToRender, { x: textX + 1, y: textY + 1 }, undefined, undefined, false, "MatrixChunky8"); - } - } - - // Draw white text on top - no rotation for now - $.ink("white"); - if (getPackMode()) { - // In OBJKT mode, try MatrixChunky8 first, fall back to default if not available - const matrixFont = typefaceCache.get("MatrixChunky8"); - if (matrixFont && matrixFont.glyphs) { - const testChar = matrixFont.glyphs['$'] || matrixFont.glyphs['A'] || matrixFont.glyphs['a']; - const hasValidGlyphs = testChar && (testChar.pixels || testChar.commands || testChar.resolution); - if (hasValidGlyphs) { - $.write(codeToRender, { x: textX, y: textY }, undefined, undefined, false, "MatrixChunky8"); - } else { - $.write(codeToRender, { x: textX, y: textY }); - } - } else { - $.write(codeToRender, { x: textX, y: textY }); - } - } else { - // Check if MatrixChunky8 glyphs are loaded for white text - const matrixFont = typefaceCache.get("MatrixChunky8"); - let useMatrixFont = false; - if (matrixFont && matrixFont.glyphs) { - const testGlyph = matrixFont.glyphs['$'] || matrixFont.glyphs['a']; - useMatrixFont = !!(testGlyph && (testGlyph.pixels || testGlyph.commands || testGlyph.resolution)); - } - - // Only render if MatrixChunky8 is ready - if (useMatrixFont) { - $.write(codeToRender, { x: textX, y: textY }, undefined, undefined, false, "MatrixChunky8"); - } - } - } - - // Draw gray shadow along the right side and bottom of the entire overlay - $.ink("gray", 128); - // Right shadow - offset 1px down from top like a drop shadow - $.box(qrSize, 1, 1, qrOffsetY + qrSize); - // Bottom shadow - offset 1px from left edge like a drop shadow - $.box(1, qrOffsetY + qrSize, qrSize, 1); - - // Add a subtle white highlight to indicate the QR is clickable (corner only) - // This creates a "button-like" appearance - $.ink("white", 64); // Semi-transparent white - // Top highlight line - $.line(0, qrOffsetY, qrSize - 1, qrOffsetY); - // Left highlight line - $.line(0, qrOffsetY, 0, qrOffsetY + qrSize - 1); - }); - - // Don't cache QR overlay if font isn't fully loaded yet (text label needs to re-render) - // Cache the base QR data (not the transferable pixels) - const qrData = { - width: generatedQR.width, - height: generatedQR.height, - basePixels: new Uint8ClampedArray(generatedQR.pixels) // Keep a safe copy for caching - }; - - // Store QR dimensions in animation state for proper bounding box animations - hudAnimationState.qrSize = Math.max(qrData.width, qrData.height); - - if (hudAnimationState.qrFullscreen) { - // Fullscreen mode: integer scaling only for pixel-perfect QR - const originalQrSize = cells.length; - - // Calculate maximum integer scale that fits screen with padding - const padding = Math.min(screen.width, screen.height) * 0.1; // 10% padding - const maxSize = Math.min(screen.width, screen.height) - padding * 2; - - const maxCellSize = Math.floor(maxSize / originalQrSize); - const cellSize = Math.max(2, Math.min(maxCellSize, 12)); // Integer cell size, 2-12px - const finalQrSize = originalQrSize * cellSize; - - overlayWidth = finalQrSize; - overlayHeight = finalQrSize; - - // Calculate text dimensions for canvas sizing - const codeText = `${sigil}${cachedCode}`; - const fontSize = 16; // Fixed 2x scale (8px base font * 2 = 16px) - const textPadding = 8; - - // Calculate actual text width using font advances for proper sizing - const font = typefaceCache.get("MatrixChunky8"); - const advances = font?.data?.advances || {}; - let actualTextWidth = 0; - for (const char of codeText) { - const charWidth = advances[char] || 4; // Default 4px per char if no advance data - actualTextWidth += charWidth; - } - // Scale up by 2x for our size: 2 scaling - actualTextWidth *= 2; - - const textWidth = actualTextWidth + textPadding * 2; // Add padding - const textHeight = fontSize + textPadding * 2; - - // Use full screen canvas to position QR code (centered) and text (top-left) - const canvasHeight = screen.height; - const canvasWidth = screen.width; - - // Position QR code in center of screen - const qrX = Math.floor((screen.width - finalQrSize) / 2); - const qrY = Math.floor((screen.height - finalQrSize) / 2); - - // Position starts at top-left of screen since we're using full screen canvas - startX = 0; - startY = 0; - - // Generate fullscreen canvas with centered QR and top-left text - const fullscreenQR = $api.painting(canvasWidth, canvasHeight, ($) => { - $.unmask(); // Ensure fullscreen QR renders without piece mask - // Draw QR code centered on screen - for (let y = 0; y < cells.length; y++) { - for (let x = 0; x < cells.length; x++) { - const isBlack = cells[y][x]; - if (isBlack) { - $.ink("black"); - } else { - $.ink("white"); - } - $.box(qrX + x * cellSize, qrY + y * cellSize, cellSize, cellSize); - } - } - - // Draw text in absolute top-left corner of screen - const codeText = `${sigil}${cachedCode}`; - const textX = 10; // Small margin from left edge of screen - const textY = 10; // Small margin from top edge of screen - - // Draw black shadow offset by 1px - $.ink("black"); - $.write(codeText, { x: textX + 1, y: textY + 1, size: 2 }); // 2x scale for shadow - - // Draw white text on top - $.ink("white"); - $.write(codeText, { x: textX, y: textY, size: 2 }); // 2x scale for main text - - // Add "TAP TO CLOSE" instruction at bottom center of screen - const closeText = "TAP TO CLOSE"; - const closeTextY = screen.height - 20; // 20px from bottom - const closeTextX = Math.floor(screen.width / 2) - 30; // Rough center (adjust as needed) - - // Draw black shadow for close text - $.ink("black"); - $.write(closeText, { x: closeTextX + 1, y: closeTextY + 1, size: 1 }); - - // Draw white close text - $.ink("white"); - $.write(closeText, { x: closeTextX, y: closeTextY, size: 1 }); - }); - - // Update qrData for fullscreen - qrData.width = fullscreenQR.width; - qrData.height = fullscreenQR.height; - qrData.basePixels = new Uint8ClampedArray(fullscreenQR.pixels); - - qrOverlay = { - width: qrData.width, - height: qrData.height, - pixels: new Uint8ClampedArray(qrData.basePixels) - }; - } else { - // Normal mode: use original positioning and size - overlayWidth = qrData.width; - overlayHeight = qrData.height; - startX = screen.width - overlayWidth - margin; - startY = screen.height - overlayHeight - margin; // Removed +1 to prevent label shadow overlap - - // Create fresh overlay for transfer each time - qrOverlay = { - width: qrData.width, - height: qrData.height, - pixels: new Uint8ClampedArray(qrData.basePixels) // Fresh copy for transfer - }; - } - - // Cache the QR data for this piece once glyphs are confirmed rendered - // We use safeToCache to ensure the painting callback has completed - if (!isQRCacheDisabled && safeToCache && !hudAnimationState.qrFullscreen) { - qrOverlayCache.set(cacheKey, qrData); - console.log('[QR] ✅ Cached QR with MatrixChunky8 glyphs for:', cacheKey); - } - - // Add QR overlay to sendData with exact position and animation effects - sendData.qrOverlay = { - x: startX + (hudAnimationState.qrFullscreen ? 0 : hudAnimationState.qrSlideOffset.x), - y: startY + (hudAnimationState.qrFullscreen ? 0 : hudAnimationState.qrSlideOffset.y), - opacity: hudAnimationState.opacity, - img: qrOverlay - }; - - // Add clickable hitbox for QR code - if (hudAnimationState.qrFullscreen) { - // Fullscreen QR: make entire QR tappable to go back - send({ - type: "button:hitbox:add", - content: { - label: "qr-fullscreen", - box: { - x: startX, - y: startY, - w: overlayWidth || qrData.width, - h: overlayHeight || qrData.height - }, - message: "qr-fullscreen-tap" - } - }); - } else { - // Corner QR: make it tappable to go fullscreen - if (!SOLO_MODE) { - send({ - type: "button:hitbox:add", - content: { - label: "qr-corner", - box: { - x: startX + hudAnimationState.qrSlideOffset.x, - y: startY + hudAnimationState.qrSlideOffset.y, - w: overlayWidth || qrData.width, - h: overlayHeight || qrData.height - }, - message: "qr-corner-tap" - } - }); - } - } - - } - } else { - // No cached code yet - QR will appear once caching is complete - } - } catch (err) { - console.warn("Failed to generate QR overlay:", err); - } - } - - // 👤 Author attribution overlay for KidLisp pieces (bottom-left corner) - // Only show when: HUD is visible, we have an author, and it's a KidLisp piece - // Skip in device mode (device.kidlisp.com has its own DOM overlay) - // Note: Reuse isDeviceModeQR from earlier in the function - // In device mode, completely skip author overlay creation - if (isDeviceModeQR) { - // Don't create author overlay - device.html has its own DOM-based display - } else if (currentHUDAuthor && hudAnimationState.visible && !hudAnimationState.qrFullscreen) { - try { - const authorText = currentHUDAuthor; // Already formatted as @handle - const hitsText = currentHUDHits ? `${currentHUDHits.toLocaleString()} hits` : null; - - // Use MatrixChunky8 for compact display - const font = typefaceCache.get("MatrixChunky8"); - const charWidth = 4; // MatrixChunky8 char width - const charHeight = 8; // MatrixChunky8 char height - const padding = 2; - const lineGap = 2; - - // Calculate dimensions - const authorWidth = authorText.length * charWidth; - const hitsWidth = hitsText ? hitsText.length * charWidth : 0; - const maxWidth = Math.max(authorWidth, hitsWidth); - const height = hitsText ? (charHeight * 2 + lineGap + padding * 2) : (charHeight + padding * 2); - const width = maxWidth + padding * 2; - - // Create attribution overlay painting - const authorOverlay = $api.painting(width + 1, height + 1, ($) => { - $.unmask(); - - // Semi-transparent black background for readability - $.ink(0, 0, 0, 180).box(0, 0, width, height); - - // Draw author handle in pink (matching give.aesthetic.computer style) - $.ink(255, 107, 157); // Pink color from give page CSS --pink - if (font && font.glyphs) { - $.write(authorText, { x: padding, y: padding }, undefined, undefined, false, "MatrixChunky8"); - } else { - $.write(authorText, { x: padding, y: padding }); - } - - // Draw hits count in gold if available - if (hitsText) { - $.ink(255, 217, 61); // Gold color from give page CSS --gold - if (font && font.glyphs) { - $.write(hitsText, { x: padding, y: padding + charHeight + lineGap }, undefined, undefined, false, "MatrixChunky8"); - } else { - $.write(hitsText, { x: padding, y: padding + charHeight + lineGap }); - } - } - }); - - // Position in bottom-left corner with same margin as QR code - const margin = 4; - const startX = margin; - const startY = screen.height - height - margin; - - // Add author overlay to sendData with animation effects - sendData.authorOverlay = { - x: startX - hudAnimationState.qrSlideOffset.x, // Slide opposite direction from QR - y: startY + hudAnimationState.qrSlideOffset.y, - opacity: hudAnimationState.opacity, - img: (({ width, height, pixels }) => ({ width, height, pixels }))(authorOverlay), - }; - } catch (err) { - console.warn("Failed to generate author overlay:", err); - } - } - - let transferredPixels; - - // Check to see if we have a dirtyBox to render from. - const croppedBox = dirtyBox?.croppedBox?.(screen); - - if (croppedBox?.w > 0 && croppedBox?.h > 0) { - transferredPixels = dirtyBox.crop(screen); - sendData.pixels = transferredPixels; - sendData.dirtyBox = croppedBox; - if ($commonApi.rec.presenting) { - // Log dirtyBox usage during tape playback - } - } else if (painted === true) { - // TODO: Toggling this causes a flicker in `line`... but helps prompt. 2022.01.29.13.21 - // Otherwise render everything if we drew anything! - transferredPixels = screen.pixels; - sendData.pixels = transferredPixels; - } else { - // Painted is false, skip pixel transfer - } - - - // Optional messages to send. - if (painted === true) sendData.paintChanged = true; - if (loading === true) sendData.loading = true; - - // WebGPU state (tell main thread whether to skip CPU rendering) - if ($commonApi.webgpu.enabled) { - sendData.webgpuEnabled = true; - // Send backend preference if specified - if ($commonApi.webgpu.backend) { - sendData.gpuBackend = $commonApi.webgpu.backend; - } - // Signal end of frame to flush WebGPU command queue - send({ type: "webgpu-command", content: { type: "frame-end" } }); - } - - // These fields are one time `signals`. - if (reframe || glazeAfterReframe) { - sendData.reframe = reframe || glazeAfterReframe !== undefined; - if (glazeAfterReframe) { - send(glazeAfterReframe); - glazeAfterReframe = undefined; - } - } - - if (cursorCode) sendData.cursorCode = cursorCode; - - // Note: transferredPixels will be undefined when sendData === {}. - if (sendData.pixels) { - // 🛡️ Create a copy for transfer to avoid detaching the main rendering buffer - const pixelsCopy = new Uint8ClampedArray(sendData.pixels); - sendData.pixels = pixelsCopy.buffer; - } else { - sendData.pixels = content.pixels; - } - - if (sendData.pixels?.byteLength === 0) sendData.pixels = undefined; - - let transferredObjects = [sendData.pixels]; - - if (sendData.label) { - // 🛡️ Create a copy for transfer to avoid detaching the label buffer - const labelPixelsCopy = new Uint8ClampedArray(sendData.label.img.pixels); - transferredObjects.push(labelPixelsCopy.buffer); - } - - if (sendData.qrOverlay) { - // 🛡️ Create a copy for transfer to avoid detaching the QR overlay buffer - const qrPixelsCopy = new Uint8ClampedArray(sendData.qrOverlay.img.pixels); - transferredObjects.push(qrPixelsCopy.buffer); - } - - if (sendData.qrCornerText) { - // 🛡️ Create a copy for transfer to avoid detaching the QR corner text buffer - const qrCornerPixelsCopy = new Uint8ClampedArray(sendData.qrCornerText.img.pixels); - transferredObjects.push(qrCornerPixelsCopy.buffer); - } - - if (sendData.tapeProgressBar) { - // 🛡️ Create a copy for transfer to avoid detaching the tape progress bar buffer - const tapePixelsCopy = new Uint8ClampedArray(sendData.tapeProgressBar.img.pixels); - transferredObjects.push(tapePixelsCopy.buffer); - } - - if (sendData.merryProgressBar) { - // 🎄 Create a copy for transfer to avoid detaching the merry progress bar buffer - const merryPixelsCopy = new Uint8ClampedArray(sendData.merryProgressBar.img.pixels); - transferredObjects.push(merryPixelsCopy.buffer); - } - - if (sendData.demoplayCard) { - // 🎬 Create a copy for transfer to avoid detaching the demoplay card buffer - const demoplayPixelsCopy = new Uint8ClampedArray(sendData.demoplayCard.img.pixels); - transferredObjects.push(demoplayPixelsCopy.buffer); - } - - if (sendData.lanBadge) { - // 🔗 Create a copy for transfer to avoid detaching the LAN badge buffer - const lanBadgePixelsCopy = new Uint8ClampedArray(sendData.lanBadge.img.pixels); - transferredObjects.push(lanBadgePixelsCopy.buffer); - } - - if (sendData.authorOverlay) { - // 👤 Create a copy for transfer to avoid detaching the author overlay buffer - const authorPixelsCopy = new Uint8ClampedArray(sendData.authorOverlay.img.pixels); - transferredObjects.push(authorPixelsCopy.buffer); - } - - // 🎬 Add recording UI overlays (visible on screen but NOT captured in tape) - const recordingUIData = $commonApi.recordingUI._getPending(); - if (Object.keys(recordingUIData).length > 0) { - sendData.recordingUI = {}; - for (const [name, overlayData] of Object.entries(recordingUIData)) { - // Create a copy of the pixels for transfer - const pixelsCopy = new Uint8ClampedArray(overlayData.img.pixels); - sendData.recordingUI[name] = { - x: overlayData.x, - y: overlayData.y, - opacity: overlayData.opacity, - img: { - width: overlayData.img.width, - height: overlayData.img.height, - pixels: pixelsCopy, - }, - }; - transferredObjects.push(pixelsCopy.buffer); - } - $commonApi.recordingUI._clearPending(); - } - - // console.log("TO:", transferredObjects); - // console.log("Sent data:", sendData); - - sendData.sound = sound; - - // Log first render sent back to main thread - if (!globalThis._firstRenderSent) { - globalThis._firstRenderSent = true; - diskTimings.firstRenderSent = Math.round(performance.now() - diskTimingStart); - // Silent: first render sent - - // Send timing data to main thread (only in dev mode) - if (!getPackMode()) send({ type: "disk-timings", content: diskTimings }); - } - - send({ type: "render", content: sendData }, transferredObjects); - - sound.sounds.length = 0; // Empty the sound command buffer. - sound.bubbles.length = 0; - sound.kills.length = 0; - - twoDCommands.length = 0; // Empty the 2D GPU command buffer. - - // Flush the `signals` after sending. - if (reframe) reframe = undefined; - if (cursorCode) cursorCode = undefined; - } else { - // Send update (sim). - maybeLeave(); - // TODO: How necessary is this - does any info ever need to actually - // get sent? 23.01.06.16.02 - - // console.log(pixels); - // 🛡️ Create a copy for transfer to avoid detaching the pixels buffer - const transferPixels = pixels ? new Uint8ClampedArray(pixels) : null; - - send( - { - type: "update", - content: { - didntRender: true, - loading, - pixels: transferPixels?.buffer, - width: content.width, - height: content.height, - sound, - }, - }, - [transferPixels?.buffer], - ); - } - - // Wait 8 frames of the default piece before loading the initial piece. - // And also make sure the session has been queried. - // console.log(sessionStarted); - - // 🕷️ SPIDER MODE: Debug why piece isn't loading - if (typeof window !== 'undefined' && window.acSPIDER && paintCount <= 10n) { - console.log("🕷️ SPIDER: Frame check:", { - paintCount: paintCount.toString(), - sessionStarted, - PREVIEW_OR_ICON, - sandboxed: $commonApi.net.sandboxed, - loadAfterPreambleExists: !!loadAfterPreamble - }); - } - - if ( - paintCount > 8n && - (sessionStarted || PREVIEW_OR_ICON || $commonApi.net.sandboxed) - ) { - if (paintCount === 9n) { - const preambleTime = Math.round(performance.now() - diskTimingStart); - diskTimings.preambleComplete = preambleTime; - // Silent: preamble complete - } - if (typeof window !== 'undefined' && window.acSPIDER) { - console.log("🕷️ SPIDER: Calling loadAfterPreamble now!"); - } - loadAfterPreamble?.(); // Start loading after the first disk if necessary. - } - - // 🛡️ Safety net: if session:started was never received (auth failure, network issue, - // or race condition in boot.mjs), force-proceed after ~2 seconds (120 frames at 60fps) - // to prevent the system from being stuck on noise16 forever. - if ( - paintCount > 120n && - !sessionStarted && - !PREVIEW_OR_ICON && - !$commonApi.net.sandboxed && - loadAfterPreamble - ) { - console.warn("⚠️ session:started not received after 120 frames — proceeding without auth"); - sessionStarted = true; - // Try to recover handle from localStorage even without full auth - const cachedHandle = store["handle"]; - if (cachedHandle && !HANDLE) { - HANDLE = "@" + cachedHandle; - if (typeof window !== 'undefined') window.acHANDLE = HANDLE; - if (typeof window !== 'undefined' && window.acBootCanvas?.setHandle) window.acBootCanvas.setHandle(HANDLE); - send({ type: "handle", content: HANDLE }); - console.log(`🔐 Recovered cached handle: ${HANDLE}`); - } - loadAfterPreamble?.(); - } - - // soundClear?.(); - - // ***Frame State Reset*** - // Reset video transcoding / print progress. - - //console.log(performance.now() - frameTime, "ms"); - - //performance.mark("b"); - - //performance.measure("a", "b"); - //console.log("Frame perf:", performance.getEntriesByType("measure")[0].duration); - //performance.clearMarks(); - //performance.clearMeasures(); - } -} - -// 📚 Utilities - -// Get the active user's handle from the server if one exists, updating -// $commonApi.handle -let HANDLE; -let handleRetryInProgress = false; -// TODO: Cache this in localStorage and clear it on log in and log out? -// 24.05.23.22.16 - -async function handle(retryCount = 0) { - const MAX_RETRIES = 3; - const RETRY_DELAYS = [500, 1000, 2000]; // Exponential backoff - - if (USER) { - // Fast path: use handle already fetched by boot.mjs (via /user?withHandle=true) - const bootHandle = USER.handle; - // TODO: Check to see if this is in localStorage or not... - const storedHandle = bootHandle || store["handle"]; // || (await store.retrieve("handle")); - - // console.log("Stored handle...", storedHandle); - - if (storedHandle) { - const newHandle = "@" + storedHandle; - if (HANDLE === newHandle) return; - HANDLE = "@" + storedHandle; - if (USER && !USER.handle) USER.handle = storedHandle; // Sync to user object - if (typeof window !== 'undefined') window.acHANDLE = HANDLE; // Expose for UDP identity - if (typeof window !== 'undefined' && window.acBootCanvas?.setHandle) window.acBootCanvas.setHandle(HANDLE); - send({ type: "handle", content: HANDLE }); - store["handle:received"] = true; - if (bootHandle) store["handle"] = bootHandle; // Cache boot-fetched handle - // console.log("Retrieved handle from store:", storedHandle); - return; // Leave early if a stored handle was found. - } - - try { - log.auth.debug("Fetching handle for:", USER.sub); - const response = await fetch(`/handle?for=${USER.sub}`); - if (response.status === 200) { - const data = await response.json(); - const newHandle = "@" + data.handle; - if (newHandle === HANDLE) return; - HANDLE = newHandle; - if (USER && !USER.handle) USER.handle = data.handle; // Sync to user object - if (typeof window !== 'undefined') window.acHANDLE = HANDLE; // Expose for UDP identity - if (typeof window !== 'undefined' && window.acBootCanvas?.setHandle) window.acBootCanvas.setHandle(HANDLE); - send({ type: "handle", content: HANDLE }); - store["handle:received"] = true; - store["handle"] = data.handle; - store["handle:failed"] = false; // Clear any previous failure flag - // store.persist("handle"); // Maybe this shouldn't persist. - } else { - const errorText = await response.text(); - console.warn("🤚 Handle fetch failed:", response.status, errorText); - - // Retry on 5xx server errors - if (response.status >= 500 && retryCount < MAX_RETRIES && !handleRetryInProgress) { - handleRetryInProgress = true; - const delay = RETRY_DELAYS[retryCount] || 2000; - console.log(`🔄 Retrying handle fetch in ${delay}ms (attempt ${retryCount + 1}/${MAX_RETRIES})...`); - await new Promise(resolve => setTimeout(resolve, delay)); - handleRetryInProgress = false; - return handle(retryCount + 1); - } - - store["handle:failed"] = true; - } - } catch (error) { - console.error("🤚 Handle fetch error:", error); - - // Retry on network errors - if (retryCount < MAX_RETRIES && !handleRetryInProgress) { - handleRetryInProgress = true; - const delay = RETRY_DELAYS[retryCount] || 2000; - console.log(`🔄 Retrying handle fetch in ${delay}ms (attempt ${retryCount + 1}/${MAX_RETRIES})...`); - await new Promise(resolve => setTimeout(resolve, delay)); - handleRetryInProgress = false; - return handle(retryCount + 1); - } - - store["handle:failed"] = true; - } - } -} - -// Tell the `bios` to pull a screenshot of the next frame. -function downloadScreenshot() { - send({ - type: "$creenshot", - content: { - filename: `$creenshot-${num.timestamp()}.png`, - modifiers: { scale: 6 }, - }, - }); -} - -// Run the piece's "leave" function which will trigger -// a new load before sending off the final frame. -function maybeLeave() { - // 🚪 Leave (Skips act and sim and paint...) - if (leaving && leaveLoad) { - try { - leave({ ...painting.api, screen, ...$commonApi }); // Trigger leave. - } catch (e) { - console.warn("👋 Leave failure...", e); - } - leaveLoad(); - leaveLoad = null; - } -} - -// Debug utility for HUD hitbox visualization -globalThis.toggleHudHitboxDebug = () => { - globalThis.debugHudHitbox = !globalThis.debugHudHitbox; - $commonApi.needsPaint(); // Force repaint to show/hide the debug visualization -}; - -// Play a sound when "notice" fires. -const noticeBell = (api, { tone } = { tone: 600 }) => { - api.beep(tone); - - noticeTimer = new gizmo.Hourglass(160, { - completed: () => { - notice = ""; - noticeOpts = null; - noticeTimer = null; - $commonApi.needsPaint(); - }, - }); -}; +// Manages a piece and the transitions between pieces like a +// hypervisor or shell. + +/* #region 🏁 todo +#endregion */ + +// 📊 Worker timing telemetry - stores timing data to be sent to main thread +const diskTimings = { + sessionStarted: null, + preambleComplete: null, + loadStarted: null, + fetchComplete: null, + compileComplete: null, + bootStarted: null, + bootComplete: null, + firstPaint: null, + firstRenderSent: null, +}; +const diskTimingStart = performance.now(); + +// 🔬 Module load timing +const _importStart = performance.now(); + +import * as quat from "../dep/gl-matrix/quat.mjs"; +import * as mat3 from "../dep/gl-matrix/mat3.mjs"; +import * as mat4 from "../dep/gl-matrix/mat4.mjs"; +import * as vec2 from "../dep/gl-matrix/vec2.mjs"; +import * as vec3 from "../dep/gl-matrix/vec3.mjs"; +import * as vec4 from "../dep/gl-matrix/vec4.mjs"; + +import * as graph from "./graph.mjs"; + +import * as num from "./num.mjs"; +import * as text from "./text.mjs"; +import * as geo from "./geo.mjs"; +import * as gizmo from "./gizmo.mjs"; +import * as ui from "./ui.mjs"; +import * as help from "./help.mjs"; +import * as platform from "./platform.mjs"; + +import { signed as shop } from "./shop.mjs"; +import { parse, metadata, inferTitleDesc, updateCode } from "./parse.mjs"; +import { Socket } from "./socket.mjs"; // TODO: Eventually expand to `net.Socket` +import { Chat } from "./chat.mjs"; // TODO: Eventually expand to `net.Socket` +import { + notArray, + defaultTemplateStringProcessor, + uint8ArrayToBase64, + base64ToUint8Array, +} from "./helpers.mjs"; +import { createRestrictedApi, shouldRestrictPiece } from "./restricted-api.mjs"; +import { clearPermissions, resolvePermissionRequest } from "./piece-permissions.mjs"; +const { pow, abs, round, sin, random, min, max, floor, cos } = Math; +const { keys } = Object; + +import { nopaint_boot, nopaint_act, nopaint_is, nopaint_cancelStroke, nopaint_renderPerfHUD, nopaint_triggerBakeFlash } from "../systems/nopaint.mjs"; +import { getPreserveFadeAlpha, setPreserveFadeAlpha } from "./fade-state.mjs"; +import * as prompt from "../systems/prompt-system.mjs"; +import * as world from "../systems/world.mjs"; +import { headers } from "./headers.mjs"; +import { logs, log } from "./logs.mjs"; +import { soundWhitelist } from "./sound/sound-whitelist.mjs"; + +import { CamDoll } from "./cam-doll.mjs"; + +import { TextInput, Typeface } from "../lib/type.mjs"; + +import * as lisp from "./kidlisp.mjs"; +import { isKidlispSource, fetchCachedCode, fetchKidlispMetadata, getCachedCode, initPersistentCache, getCachedCodeMultiLevel, saveCodeToAllCaches, enableKidlispConsole, enableKidlispTrace, disableKidlispTrace, clearExecutionTrace, postExecutionTrace } from "./kidlisp.mjs"; // Add lisp evaluator. +import * as l5 from "./l5.mjs?v=20260330-runtime-support"; + +import { qrcode as qr, ErrorCorrectLevel } from "../dep/@akamfoad/qr/qr.mjs"; +import { microtype, MatrixChunky8 } from "../disks/common/fonts.mjs"; +import { + calculateVHSEffects, + getLeaderPixelColor, + blendColorWithVHS +} from "../disks/common/tape-player.mjs"; + +const _importEnd = performance.now(); +// Only log if imports took more than 50ms (slow network or first load) +if (_importEnd - _importStart > 50) { + console.log(`📦 [DISK] Imports: ${(_importEnd - _importStart).toFixed(0)}ms`); +} + +function matrixDebugEnabled() { + if (typeof window !== "undefined" && window?.acMatrixDebug) return true; + if (typeof globalThis !== "undefined" && globalThis?.acMatrixDebug) + return true; + return false; +} + +function inkFloodLoggingEnabled() { + if (typeof globalThis !== "undefined" && globalThis.AC_LOG_INK_COLORS) return true; + if (typeof process !== "undefined" && process.env?.AC_LOG_INK_COLORS === "1") return true; + return false; +} + +function inkFloodLogPrefix() { + let label = null; + if (typeof process !== "undefined" && process.env?.AC_LOG_INK_LABEL) { + label = process.env.AC_LOG_INK_LABEL; + } else if (typeof globalThis !== "undefined" && globalThis.AC_LOG_INK_LABEL) { + label = globalThis.AC_LOG_INK_LABEL; + } + return label ? `[${label}] ` : ""; +} + +function cloneColorForLog(color) { + if (Array.isArray(color)) return Array.from(color); + return color; +} + +function cloneArgsForLog(args) { + return Array.from(args).map((arg) => { + if (Array.isArray(arg)) return Array.from(arg); + if (arg && typeof arg === "object") { + try { + return JSON.parse(JSON.stringify(arg)); + } catch (err) { + return String(arg); + } + } + return arg; + }); +} + +if (typeof globalThis !== "undefined") { + if (globalThis.acMatrixDebug === undefined) { + globalThis.acMatrixDebug = true; + } + if (typeof window !== "undefined" && window.acMatrixDebug === undefined) { + window.acMatrixDebug = globalThis.acMatrixDebug; + } + + // 🖼️ Expose image cache utilities for debugging + if (typeof window !== "undefined") { + window.acClearImageCache = () => { + imageCache.clear(); + console.log("🗑️ Image cache cleared"); + }; + window.acImageCacheStats = () => { + console.log("🖼️ Image cache stats:", { + memorySize: imageCache.memory.size, + urls: Array.from(imageCache.memory.keys()) + }); + }; + } +} + +// Fetch piece metadata (trust level, author, etc.) from API +async function fetchPieceMetadata(pieceCode) { + try { + const { protocol, hostname } = getSafeUrlParts(); + const apiUrl = `${protocol}//${hostname}/api/piece-metadata?code=${encodeURIComponent(pieceCode)}`; + + const response = await fetch(apiUrl); + if (!response.ok) { + console.warn(`⚠️ Failed to fetch metadata for piece: ${pieceCode}`); + return { code: pieceCode, trustLevel: "untrusted", anonymous: true }; + } + + const metadata = await response.json(); + return metadata; + } catch (error) { + console.warn(`⚠️ Error fetching piece metadata:`, error); + // Default to untrusted on error + return { code: pieceCode, trustLevel: "untrusted", anonymous: true }; + } +} + +// Helper function to safely check for sandboxed environments in both main thread and worker contexts +function isSandboxed() { + try { + // Check for spider mode first + if (typeof window !== "undefined" && window.acSPIDER) { + return true; // Spider mode should behave like sandboxed for URL construction + } + + // In workers, window is undefined but we can still check self.origin or location + if (typeof window !== "undefined") { + return window.origin === "null"; + } else if (typeof self !== "undefined" && self.origin) { + return self.origin === "null"; + } else if (typeof location !== "undefined" && location.origin) { + return location.origin === "null"; + } else { + return false; // Default to not sandboxed if we can't determine + } + } catch (err) { + return false; // Default to not sandboxed if there's an error + } +} + +// Helper function to get safe protocol and hostname for URL construction +function getSafeUrlParts() { + try { + const sandboxed = isSandboxed(); + + if (sandboxed) { + return { + protocol: "https:", + hostname: "aesthetic.computer" + }; + } else { + // Try to get location info from various contexts + let loc = null; + if (typeof location !== "undefined") { + loc = location; + } else if (typeof self !== "undefined" && self.location) { + loc = self.location; + } else if (typeof window !== "undefined" && window.location) { + loc = window.location; + } + + if (loc) { + return { + protocol: loc.protocol, + hostname: loc.hostname || loc.host + }; + } else { + // Fallback if no location available + return { + protocol: "https:", + hostname: "aesthetic.computer" + }; + } + } + } catch (err) { + // Fallback to defaults if there's any error + return { + protocol: "https:", + hostname: "aesthetic.computer" + }; + } +} + +const SAME_ORIGIN_BUILT_IN_PIECE_HOSTS = new Set([ + "aesthetic.computer", + "www.aesthetic.computer", + "kidlisp.com", + "www.kidlisp.com", + "notepat.com", + "www.notepat.com", + "p5.aesthetic.computer", + "sitemap.aesthetic.computer", +]); + +function isLocalDevelopmentHost(hostname) { + return ( + hostname === "localhost" || + hostname === "127.0.0.1" || + /^192\.168\./.test(hostname) || + /^10\./.test(hostname) || + /^172\.(1[6-9]|2[0-9]|3[01])\./.test(hostname) + ); +} + +function getBuiltInPieceBaseUrl() { + const { protocol, hostname } = getSafeUrlParts(); + + if (typeof window !== "undefined" && window.acSPIDER) { + return "https://aesthetic.computer"; + } + + if ( + isLocalDevelopmentHost(hostname) && + typeof location !== "undefined" && + location.port + ) { + return `${protocol}//${hostname}:${location.port}`; + } + + if (SAME_ORIGIN_BUILT_IN_PIECE_HOSTS.has(hostname)) { + return `${protocol}//${hostname}`; + } + + return "https://aesthetic.computer"; +} + +let tf; // Active typeface global. + +// Cache for loaded typefaces to avoid recreating them +const typefaceCache = new Map(); + +// 🔤 Dev helper: Clear all font caches (call from console: clearFontCaches()) +// This clears typeface instances, advance caches, and forces fonts.mjs to reload +async function clearFontCaches() { + console.log("🔤 Clearing font caches..."); + + // 1. Clear typeface instance cache + const clearedTypefaces = typefaceCache.size; + for (const [name, typeface] of typefaceCache) { + // Clear the advance cache on each typeface + if (typeface.advanceCache) { + typeface.advanceCache.clear(); + } + } + typefaceCache.clear(); + + // 2. Clear IndexedDB glyph cache + try { + const { clearGlyphCache } = await import("./type.mjs"); + if (clearGlyphCache) { + await clearGlyphCache(); + console.log("🔤 IndexedDB glyph cache cleared"); + } + } catch (e) { + console.log("🔤 IndexedDB cache clear skipped:", e.message); + } + + // 3. Force reimport of fonts.mjs by cache-busting + try { + const timestamp = Date.now(); + const fontsModule = await import(`../disks/common/fonts.mjs?t=${timestamp}`); + console.log("🔤 fonts.mjs reloaded, MatrixChunky8 * advance:", fontsModule.MatrixChunky8?.advances?.['*']); + } catch (e) { + console.log("🔤 fonts.mjs reload skipped:", e.message); + } + + console.log(`🔤 Cleared ${clearedTypefaces} typeface(s). Refresh page or load a piece to see changes.`); + return true; +} + +// Expose to window for dev console access +if (typeof window !== "undefined") { + window.clearFontCaches = clearFontCaches; + + // 🔤 Auto-clear font caches in dev mode on page load + const isDevMode = location.host === "localhost:8888" || + location.host === "aesthetic.local:8888" || + location.host === "local.aesthetic.computer"; + if (isDevMode) { + // Clear IndexedDB glyph cache on dev mode page loads + (async () => { + try { + const { clearGlyphCache } = await import("./type.mjs"); + if (clearGlyphCache) { + await clearGlyphCache(); + console.log("🔤 Dev mode: IndexedDB glyph cache auto-cleared"); + } + } catch (e) { + // Silently ignore if not available yet + } + })(); + } +} + +const DEFAULT_TYPEFACE_BLOCK_WIDTH = 6; +const DEFAULT_TYPEFACE_BLOCK_HEIGHT = 10; +const HUD_LABEL_TEXT_MARGIN = 0; +const KIDLISP_HUD_WRAP_CHARACTER_LIMIT = 28; +const MIN_HUD_WRAP_WIDTH = 120; + +function resolveTypefaceInstance(typefaceRef) { + if (!typefaceRef) return undefined; + if (typefaceRef instanceof Typeface) return typefaceRef; + if (typeof typefaceRef === "string") { + if (typefaceCache.has(typefaceRef)) { + return typefaceCache.get(typefaceRef); + } + const instance = new Typeface(typefaceRef); + typefaceCache.set(typefaceRef, instance); + return instance; + } + if (typeof typefaceRef === "object" && typeof typefaceRef?.getGlyph === "function") { + return typefaceRef; + } + return undefined; +} + +function ensureTypefaceLoaded(typeface) { + if (!typeface || typeof typeface.load !== "function") return; + const requiresLoad = + typeface.data?.bdfFont || + typeface.name === "unifont" || + typeface.name === "MatrixChunky8"; + + if (!requiresLoad) return; + + if (!typeface.__loadPromise) { + typeface.__loadPromise = typeface.load($commonApi.net.preload, () => { + if (typeof window !== "undefined" && window.$activePaintApi?.needsPaint) { + window.$activePaintApi.needsPaint(); + } + }); + } + + return typeface.__loadPromise; +} + +function getTypefaceForMeasurement(typefaceName) { + if (!typefaceName) return tf; + const resolved = resolveTypefaceInstance(typefaceName); + return resolved || tf; +} + +// Flag to skip $ replacement during shadow rendering +let _isRenderingShadow = false; + +function writeHudLabelText( + $, + text, + { + x = 0, + y = 0, + typefaceName, + preserveColors = true, + bounds, + wordWrap, + } = {}, +) { + if (text === undefined || text === null || text === "") return; + + const content = preserveColors ? text : stripColorCodes(text); + const effectiveBounds = typeof bounds === "number" && bounds > 0 ? bounds : undefined; + const shouldWrap = wordWrap === undefined ? effectiveBounds !== undefined : wordWrap; + + $.write( + content, + { x, y }, + undefined, + effectiveBounds, + shouldWrap, + typefaceName, + ); + + return $; +} + +// Helper function to determine if a color is dark (needs light shadow) or light (needs dark shadow) +function isColorDark(colorStr) { + + let rgb; + + // Handle RGB comma-separated format like "0,0,0" or "255,0,0" + if (typeof colorStr === 'string' && colorStr.includes(',')) { + const parts = colorStr.split(',').map(n => parseInt(n.trim(), 10)); + rgb = parts; + } + // Handle named colors + else if (typeof colorStr === 'string') { + const lower = colorStr.toLowerCase(); + const resolved = graph.findColor(lower); + if (Array.isArray(resolved)) { + rgb = resolved; + } else { + // For unknown colors, assume they're light + return false; + } + } + // Handle array format [r, g, b] + else if (Array.isArray(colorStr)) { + rgb = colorStr; + } + + if (!rgb || rgb.length < 3) return false; + + // Calculate relative luminance using the formula from WCAG + // https://www.w3.org/TR/WCAG20/#relativeluminancedef + const [r, g, b] = rgb; + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + + // If luminance is less than 0.5, it's a dark color + return luminance < 0.5; +} + +// Helper function to get shadow color for a specific text color +const BRIGHT_SHADOW_RGB = "220,220,220"; +const DARK_SHADOW_RGB = "30,20,50"; // Dark purple-blue shadow for light mode + +function getShadowColorForText(colorStr) { + if (!colorStr) return "64,64,64"; // Default to dark gray shadow + + // Check if we're in light mode (need darker shadows for bright colors) + const isLightMode = $commonApi && !$commonApi.dark; + + let rgb; + let normalizedCommand = null; + if (typeof colorStr === "string") { + normalizedCommand = colorStr.trim().toLowerCase(); + // Treat complex command-based color strings (fade, scroll, random, etc.) as needing appropriate shadows + if (/[:(]/.test(normalizedCommand) || normalizedCommand.includes("?")) { + return isLightMode ? DARK_SHADOW_RGB : BRIGHT_SHADOW_RGB; + } + } + + // Parse the color string to get RGB values + if (typeof colorStr === 'string' && colorStr.includes(',')) { + const parts = colorStr.split(',').map(n => parseInt(n.trim(), 10)); + rgb = parts; + } + else if (typeof colorStr === 'string') { + const lower = colorStr.toLowerCase(); + const resolved = graph.findColor(lower); + if (Array.isArray(resolved)) { + rgb = resolved; + } else { + // Fallback to dark gray for unknown colors + return "64,64,64"; + } + } + else if (Array.isArray(colorStr)) { + rgb = colorStr; + } + + if (!rgb || rgb.length < 3) return "64,64,64"; + + const [r, g, b] = rgb; + + // Handle very dark colors (near black) before channel-specific logic + if (r <= 24 && g <= 24 && b <= 24) { + return BRIGHT_SHADOW_RGB; + } + + // Calculate luminance to check if color is bright + const luminance = (0.299 * r + 0.587 * g + 0.114 * b); + + // 🌞 LIGHT MODE: For bright colors on light background, use strong dark shadows + if (isLightMode && luminance > 140) { + // Use a consistent dark shadow for all bright colors in light mode + return DARK_SHADOW_RGB; + } + + // 🎨 Special case: Detect RGB channel colors and return channel-tinted shadows + // Red channel: R value, zero G and B + if (r >= 0 && g === 0 && b === 0) { + // Red channel shadow - darker red, stronger and more saturated + const shadowR = Math.max(32, Math.round(r * 0.4)); // Keep at least 40% intensity + return `${shadowR},0,0`; // Pure red shadow + } + + // Green channel: zero R, G value, zero B + if (r === 0 && g >= 0 && b === 0) { + // Green channel shadow - darker green + const shadowG = Math.max(32, Math.round(g * 0.4)); + return `0,${shadowG},0`; // Pure green shadow + } + + // Blue channel: zero R, G value (75% of B), B value (deepskyblue pattern: 0, 191, 255) + // Detects pattern where g = b * 0.75 (within rounding tolerance) + if (r === 0 && b >= 0 && g >= 0) { + const expectedG = Math.round(b * 0.75); + // Allow 1 pixel tolerance for rounding + if (Math.abs(g - expectedG) <= 1) { + // Blue/deepskyblue channel shadow - darker blue with cyan tint + const shadowG = Math.max(24, Math.round(g * 0.4)); + const shadowB = Math.max(32, Math.round(b * 0.4)); + return `0,${shadowG},${shadowB}`; // Cyan-blue shadow + } + } + + // Check if the color is dark - if so, lighten it for shadow + if (isColorDark(colorStr)) { + // Render near-white shadows under very dark text for readability + if (r <= 24 && g <= 24 && b <= 24) { + return "255,255,255"; + } + + // Lighten the color by mixing with white (stronger mix for dark colors) + const factor = 0.85; // Mix 85% with white for a much lighter shadow + const shadowR = Math.round(r + (255 - r) * factor); + const shadowG = Math.round(g + (255 - g) * factor); + const shadowB = Math.round(b + (255 - b) * factor); + return `${shadowR},${shadowG},${shadowB}`; + } + + // For light colors, darken by mixing with dark gray + const factor = 0.6; // Mix 60% darker for shadow + const shadowR = Math.round(r * (1 - factor)); + const shadowG = Math.round(g * (1 - factor)); + const shadowB = Math.round(b * (1 - factor)); + return `${shadowR},${shadowG},${shadowB}`; +} + +// Helper function to replace color codes in text with shadow colors +function replaceColorCodesWithShadows(text, defaultTextColor = "white") { + if (!text || !textContainsColorCodes(text)) return text; + + let currentTextColor = defaultTextColor; + + COLOR_CODE_MATCH_REGEX.lastIndex = 0; + return text.replace(COLOR_CODE_MATCH_REGEX, (match, colorStr) => { + if (!colorStr) return match; + + const normalized = colorStr.trim(); + const lower = normalized.toLowerCase(); + + if (lower === "reset" || lower === "default" || lower === "base") { + currentTextColor = defaultTextColor; + } else { + currentTextColor = normalized; + } + + // Get the appropriate shadow color for this text color + const shadowColor = getShadowColorForText(currentTextColor); + + // Return the shadow color code + return `\\${shadowColor}\\`; + }); +} + +function drawHudLabelText( + $, + text, + { + x = 0, + y = 0, + typefaceName, + textColor = "white", + shadowColor, + shadowOffsetX = 1, + shadowOffsetY = 1, + preserveColors = true, + bounds, + wordWrap, + } = {}, +) { + if (!text) return; + + const containsColorCodes = textContainsColorCodes(text); + const shouldPreserveColors = preserveColors || ((typefaceName === "MatrixChunky8" || typefaceName === "unifont") && containsColorCodes); + + const effectiveBounds = typeof bounds === "number" && bounds > 0 ? bounds : undefined; + const shouldWrap = wordWrap === undefined ? effectiveBounds !== undefined : wordWrap; + + + let effectiveShadowColor = shadowColor; + if (!effectiveShadowColor && textColor) { + effectiveShadowColor = getShadowColorForText(textColor); + } + if (!effectiveShadowColor) { + effectiveShadowColor = "black"; + } + + + const shouldRenderShadow = effectiveShadowColor && !( + (typefaceName === "MatrixChunky8" || typefaceName === "unifont") && + matrixDebugEnabled() + ); + + if (shouldRenderShadow) { + _isRenderingShadow = true; + // If the text has color codes and we're preserving colors, use dynamic shadows + if (shouldPreserveColors && containsColorCodes) { + // Replace color codes with appropriate shadow colors + const shadowText = replaceColorCodesWithShadows(text, textColor); + + writeHudLabelText($, shadowText, { + x: x + shadowOffsetX, + y: y + shadowOffsetY, + typefaceName, + preserveColors: true, // Keep the shadow color codes + bounds: effectiveBounds, + wordWrap: shouldWrap, + }); + } else { + // Original behavior: single shadow color for entire text + $.ink(effectiveShadowColor); + writeHudLabelText($, text, { + x: x + shadowOffsetX, + y: y + shadowOffsetY, + typefaceName, + preserveColors: false, + bounds: effectiveBounds, + wordWrap: shouldWrap, + }); + } + _isRenderingShadow = false; + } + + if (textColor) { + $.ink(textColor); + } + + const content = shouldPreserveColors ? text : stripColorCodes(text); + $.write( + content, + { x, y }, + undefined, + effectiveBounds, + shouldWrap, + typefaceName, + ); +} + +export const noWorker = { onMessage: undefined, postMessage: undefined }; + +let ROOT_PIECE = "prompt"; // This gets set straight from the host html file for the ac. + +let USER; // A holder for the logged in user. (Defined in `boot`) +let sessionStarted = false; // A flag that waits to boot until a session was +// found or not. + +let LAN_HOST; // The IP address of the hosting machine on the local network. +let SHARE_SUPPORTED; // Whether navigator.share is supported. (For `dl`) +let PREVIEW_OR_ICON; // Whether we are in preview or icon mode. (From boot.) +let VSCODE; // Whether we are running the vscode extesion or not. (From boot.) +let TV_MODE = false; // Whether running in TV mode (disables touch/keyboard input) +let DEVICE_MODE = false; // Whether running in device mode (device.kidlisp.com - skip HUD overlays) +let SOLO_MODE = false; // Whether running in solo mode (prevents navigating away from piece) +let HIGHLIGHT_MODE = false; // Whether HUD highlighting is enabled +let HIGHLIGHT_COLOR = "64,64,64"; // Default highlight color (gray) +let PERF_MODE = false; // Whether to show KidLisp performance/FPS HUD +let AUTO_SCALE_MODE = false; // Whether to enable auto-density scaling to maintain target FPS +let SPOOF_AUDIO_MODE = false; // Whether to generate synthetic audio data (for headless captures) +let NOGAP_MODE = false; // Whether gap is disabled (nogap URL param) +let AUDIO_SAMPLE_RATE = 0; +let loopPaused = false; // Whether the main loop is paused (sent from bios) +let debug = false; // This can be overwritten on boot. +let nopaintPerf = false; // Performance panel for nopaint system debugging (disabled by default) +let visible = true; // Is aesthetic.computer visibly rendering or not? +let pieceBackground = false; // Does the current piece want background sim ticks? +// True screen buffer state — refreshed each frame after content.pixels transfer. +// _mainScreenObject tracks the actual screen object so page() can detect when a brush +// switches back to the main screen (vs a sub-buffer) by object identity. +let _trueScreenPixels = null; +let _trueScreenWidth = 0; +let _trueScreenHeight = 0; +let _mainScreenObject = null; + +// 🎯 Global KidLisp Instance - Single source of truth for all KidLisp operations +let globalKidLispInstance = null; + +// 🎨 Persistent first line color storage - survives instance resets +let persistentFirstLineColor = null; + +// 🎨 Global function to store background color from KidLisp (worker-safe) +function storePersistentFirstLineColor(color) { + persistentFirstLineColor = color; + // Also store in window if available + if (typeof window !== "undefined" && window.setPersistentFirstLineColor) { + window.setPersistentFirstLineColor(color); + } +} + +// 🎨 Global function to get background color for reframe (worker-safe) +function getPersistentFirstLineColor() { + + return persistentFirstLineColor; +} + +// 🎮 Create gamepad helper API for easy access to per-gamepad events +function createGamepadAPI(gamepadEvents) { + if (!gamepadEvents) return []; + + return gamepadEvents.map((events, index) => { + if (!events || events.length === 0) return null; + + return { + index, + events, + + // Helper to check if button was pushed/released + button(buttonIndex) { + const pushEvent = events.find(e => + e.button === buttonIndex && e.action === 'push' + ); + const releaseEvent = events.find(e => + e.button === buttonIndex && e.action === 'release' + ); + return { + pushed: !!pushEvent, + released: !!releaseEvent, + event: pushEvent || releaseEvent + }; + }, + + // Helper to get axis value (returns most recent value in frame) + axis(axisIndex) { + const axisEvent = events.findLast(e => e.axis === axisIndex); + return axisEvent ? axisEvent.value : 0; + }, + + // Check if this gamepad is connected (has events) + connected() { + return events && events.length > 0; + }, + + // Get the gamepad device ID/name + get id() { + return events[0]?.gamepadId || null; + } + }; + }); +} + +// 🎨 Make storage function globally available for KidLisp +if (typeof globalThis !== "undefined") { + globalThis.storePersistentFirstLineColor = storePersistentFirstLineColor; + globalThis.getPersistentFirstLineColor = getPersistentFirstLineColor; +} + +// Global function to set persistent first line color +if (typeof window !== "undefined") { + window.setPersistentFirstLineColor = function(color) { + persistentFirstLineColor = color; + }; + + // Global function to get persistent first line color + window.getPersistentFirstLineColor = function() { + return persistentFirstLineColor; + }; +} + +function resolveBackgroundFillSpec(colorLike) { + if (colorLike === undefined || colorLike === null) return null; + + let resolved; + try { + resolved = graph.findColor(colorLike); + } catch (err) { + return null; + } + + if (!Array.isArray(resolved) || resolved.length === 0) { + return null; + } + + const first = resolved[0]; + if (typeof first === "string" && first.startsWith("fade:")) { + const fadeInfo = graph.parseFadeColor(resolved); + if (fadeInfo) { + return { + type: "fade", + fadeInfo, + }; + } + return null; + } + + const clampChannel = (value, fallback = 0) => { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback; + return Math.max(0, Math.min(255, Math.round(value))); + }; + + const r = clampChannel(resolved[0], 0); + const g = clampChannel(resolved[1], r); + const b = clampChannel(resolved[2], r); + const a = clampChannel(resolved[3], 255); + + return { + type: "solid", + rgba: [r, g, b, a], + }; +} + +function computeFadePositionForPixel(x, y, width, height, fadeInfo) { + if (!fadeInfo || !width || !height) return 0; + + const maxX = Math.max(width - 1, 0); + const maxY = Math.max(height - 1, 0); + const direction = fadeInfo.direction; + + if (typeof direction === "number" && Number.isFinite(direction)) { + return graph.calculateAngleFadePosition( + x, + y, + 0, + 0, + maxX, + maxY, + direction, + ); + } + + const numeric = direction !== undefined ? parseFloat(direction) : NaN; + if (!Number.isNaN(numeric) && Number.isFinite(numeric)) { + return graph.calculateAngleFadePosition( + x, + y, + 0, + 0, + maxX, + maxY, + numeric, + ); + } + + const safeDiv = (value, denom) => (denom <= 0 ? 0 : value / denom); + + switch (direction) { + case "horizontal-reverse": + return safeDiv(maxX - x, maxX); + case "vertical": + return safeDiv(y, maxY); + case "vertical-reverse": + return safeDiv(maxY - y, maxY); + case "diagonal": { + const dx = safeDiv(x, maxX); + const dy = safeDiv(y, maxY); + return (dx + dy) / 2; + } + case "diagonal-reverse": { + const dx = safeDiv(maxX - x, maxX); + const dy = safeDiv(maxY - y, maxY); + return (dx + dy) / 2; + } + case "horizontal": + default: + return safeDiv(x, maxX); + } +} + +function fillExpandedWithSolidPixels(screen, width, height, oldWidth, oldHeight, rgba) { + const [r, g, b] = rgba; + + const applyPixel = (x, y) => { + const i = (y * width + x) * 4; + screen.pixels[i] = r; + screen.pixels[i + 1] = g; + screen.pixels[i + 2] = b; + screen.pixels[i + 3] = 255; + }; + + if (width > oldWidth) { + for (let y = 0; y < height; y++) { + for (let x = oldWidth; x < width; x++) { + applyPixel(x, y); + } + } + } + + if (height > oldHeight) { + const bottomLimit = width > oldWidth ? oldWidth : width; + for (let y = oldHeight; y < height; y++) { + for (let x = 0; x < bottomLimit; x++) { + applyPixel(x, y); + } + } + } +} + +function fillExpandedWithFadePixels(screen, width, height, oldWidth, oldHeight, fadeInfo) { + const applyPixel = (x, y) => { + const t = computeFadePositionForPixel(x, y, width, height, fadeInfo); + const [r = 0, g = 0, b = 0, a = 255] = graph.getLocalFadeColor(t, x, y, fadeInfo); + const i = (y * width + x) * 4; + screen.pixels[i] = Math.max(0, Math.min(255, Math.round(r))); + screen.pixels[i + 1] = Math.max(0, Math.min(255, Math.round(g))); + screen.pixels[i + 2] = Math.max(0, Math.min(255, Math.round(b))); + screen.pixels[i + 3] = Math.max(0, Math.min(255, Math.round(a || 255))); + }; + + if (width > oldWidth) { + for (let y = 0; y < height; y++) { + for (let x = oldWidth; x < width; x++) { + applyPixel(x, y); + } + } + } + + if (height > oldHeight) { + const bottomLimit = width > oldWidth ? oldWidth : width; + for (let y = oldHeight; y < height; y++) { + for (let x = 0; x < bottomLimit; x++) { + applyPixel(x, y); + } + } + } +} + +const projectionMode = location.search.indexOf("nolabel") > -1; // Skip loading noise. + +import { setDebug } from "../disks/common/debug.mjs"; +import { customAlphabet } from "../dep/nanoid/nanoid.js"; +import { setPackMode, getPackMode, checkPackMode } from "./pack-mode.mjs"; +// import { update } from "./glaze.mjs"; +const alphabet = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; +const nanoid = customAlphabet(alphabet, 4); + +const defaults = { + boot: ({ cursor, screen: { width, height }, resolution, api }) => { + if (location.host.indexOf("botce") > -1) resolution(width, height, 0); + if (platform.AestheticExtension) resolution(width, height, 0); + cursor("native"); + }, // aka Setup + sim: () => false, // A framerate independent of rendering. + paint: ({ noise16Aesthetic, noise16Sotce, slug, wipe, ink, write, screen, net }) => { + // In PACK mode (exported bundles), skip noise16 — just show black + if (typeof window !== "undefined" && window.acPACK_MODE) { wipe("black"); return; } + // TODO: Make this a boot choice via the index.html file? + if (!projectionMode) { + if (slug?.indexOf("wipppps") > -1) { + wipe("black"); + } else if (slug?.indexOf("botce") > -1) { + noise16Sotce(); + } else { + noise16Aesthetic(); + if (net.motd) { + ink(255, 255, 255, 200).write( + net.motd, + { center: "x", y: Math.floor(screen.height / 2) }, + undefined, + screen.width - 18, + ); + } + if (net.loadFailureText) { + ink("white").write( + net.loadFailureText, + { x: 6, y: 6 }, + [64, 64], + screen.width - 6, + ); + } + } + } + }, + beat: () => false, // Runs every bpm. + act: () => false, // All user interaction. + leave: () => false, // Before unload. + receive: () => false, // Handle messages from BIOS (file drops, etc.) + preview: ({ wipe, slug }) => { + wipe(64).ink(255).write(slug, { center: "xy", size: 1 }); + }, + icon: ({ glaze, wipe, screen }) => { + glaze({ on: false }); + // console.log(screen.width, screen.height); + wipe(70, 50, 100) + .ink(200, 30, 100) + .box(screen.width / 2, screen.height / 2, 48, 72, "*center"); + }, +}; + +let loadAfterPreamble = null; +let hotSwap = null; + +// 🔎 NoPaint +// Inheritable via `export const system = "nopaint"` from any piece. +// Boilerplate for a distributed raster editor. +const nopaint = { + leave: function leave($) { + const { store, system, page, screen, flatten } = $; + if (NPnoOnLeave === false) { + // ^ This is set when reloading a brush without storing changes. (`N` key) + + if (system.nopaint.bakeOnLeave) { + // Add bake commands. + page(system.painting); + //$activePaintApi = $; // In case of recursive paint functions like `write`. + bake($); + // page(screen); // TODO: This should work but it doesnt... + // Seems to have something to do with + // the $activePaintAPI being set + // and it's specific to the `write` + // implementation. + flatten(); // Force-run the above painting commands. + } + // Bake any changes into the existing painting using the screen buffer. + // Note... this may not include the full API that `paint` has... + // Which could lead to inconsistencies in `bake` calls. 23.05.04.13.01 + + // const p = system.nopaint.translation; // Offset by the pan position. + // page(system.painting).paste(screen, -p.x, -p.y); // TODO: Why the + 1 offset here... + // painting.paint(); // TODO: Why is this here? + // page(screen); + //} + + addUndoPainting(system.painting, $.slug); + + // Idea: Check to see if anything actually got painted by doing a diff on + // the pixels? + + store["painting"] = { + width: system.painting.width, + height: system.painting.height, + pixels: system.painting.pixels, + }; //system.painting; // Remember the painting data. + + // console.log("System painting:", system.painting); + + store.persist("painting", "local:db"); + + // 🎨 Broadcast painting update to other tabs + $commonApi.broadcastPaintingUpdate("updated", { + source: "leave", + slug: $.slug + }); + + // And its transform. + store["painting:transform"] = { + translation: system.nopaint.translation, + zoom: system.nopaint.zoomLevel, + }; + store.persist("painting:transform", "local:db"); + } else { + // Restore a painting if `no`ing. + const paintings = system.nopaint.undo.paintings; + page(system.painting) + .paste(paintings[paintings.length - 1]) + .page(screen); + } + + NPnoOnLeave = false; + }, + // 🥞 Bake (to the painting) + bake: function bake({ paste, system }) { + paste(system.nopaint.buffer); + }, +}; + +const undoPaintings = []; // Stores the last two paintings. +let undoPosition = 0; + +function addUndoPainting(painting, step = "unspecified") { + if (!painting) return; // If there is no painting present, silently pass. + const op = painting.pixels; + const pixels = new Uint8ClampedArray(op.length); + pixels.set(op); + + if (undoPaintings.length > undoPosition + 1) { + undoPaintings.length = undoPosition + 1; + } + + if (undoPaintings.length > 0) { + const lastPainting = undoPaintings[undoPaintings.length - 1]; + + // Check for equality in the two states. + // TODO: How long does this take? + const eq = + painting.width === lastPainting.width && + painting.height === lastPainting.height && + pixels.every((value, index) => value === lastPainting.pixels[index]); + + if (eq) { + // console.log("💩 The undo stack was not changed:", undoPaintings.length); + return; + } + } + + undoPaintings.push({ + pixels, + width: painting.width, + height: painting.height, + }); + + if ($commonApi.system.nopaint.recording) { + $commonApi.system.nopaint.addToRecord({ + label: step, + painting: { + pixels, + width: painting.width, + height: painting.height, + }, + }); + } + + undoPosition = undoPaintings.length - 1; + + // Note: This could be extended to increase the size of the + // undo stack, and images could be diffed? 23.01.31.01.30 + const maxUndoSteps = 32; + if (undoPaintings.length > maxUndoSteps) undoPaintings.shift(); + + if (debug && logs.painting) + console.log("💩 Added undo painting...", undoPaintings.length); +} + +let system = null; // Used to add built-in templated behaviors like `nopaint`. + +// 🎞️ Piece-level FPS control (timing-based, doesn't affect global render loop) +let pieceFPS = null; // Target FPS for the current piece (null = no limit) +let lastPaintTime = 0; // Last time paint() was executed +let lastPaintOut = undefined; // Store last paintOut to preserve correct noPaint behavior +let shouldSkipPaint = false; // Whether to skip this frame's paint call +let pieceFrameCount = 0; // Frame counter that only increments when piece actually paints + +// 🎬 Piece Transition System +// Two transition types available: "drip" (default, fast) and "bubble-wrap" (organic, slower) +// Set to "none" to disable transitions entirely (fast cut) +const TRANSITION_TYPE = "none"; // "none", "drip" or "bubble-wrap" + +// 🧬 Piece Transition State: overlay pixels → fades to reveal piece underneath +// Captures the OUTGOING piece's pixels when transitioning between pieces +let pieceTransition = { + active: false, + phase: "loading", // "loading" (slow, reach ~50%) or "revealing" (fast, finish) + overlayPixels: null, // The captured frame that overlays on top (from outgoing piece) + generation: 0, + maxGenerations: 60, // Total frames for full transition + width: 0, + height: 0, + // Drip-specific state (per-column y-offset) - BLINDS STYLE + dripOffsets: null, // Int16Array of column y-offsets (positive = distance moved) + dripSpeeds: null, // Float32Array of per-column speeds + dripDirections: null, // Int8Array: 1 = down, -1 = up (alternating blinds) + targetOffset: 0, // Target offset for loading phase (~50% of height) + // Bubble-wrap-specific state + blockData: null, // Per-block data for bubble-wrap + blockSize: 8, + blocksX: 0, + blocksY: 0, +}; + +// 🧬 Legacy alias for backwards compatibility +const golTransition = pieceTransition; + +// 🧬 Scale overlay pixels to match new resolution (nearest-neighbor) +function scaleOverlayPixels(srcPixels, srcWidth, srcHeight, dstWidth, dstHeight) { + const dstPixels = new Uint8ClampedArray(dstWidth * dstHeight * 4); + const xRatio = srcWidth / dstWidth; + const yRatio = srcHeight / dstHeight; + + for (let y = 0; y < dstHeight; y++) { + for (let x = 0; x < dstWidth; x++) { + const srcX = Math.floor(x * xRatio); + const srcY = Math.floor(y * yRatio); + const srcIdx = (srcY * srcWidth + srcX) * 4; + const dstIdx = (y * dstWidth + x) * 4; + + dstPixels[dstIdx] = srcPixels[srcIdx]; + dstPixels[dstIdx + 1] = srcPixels[srcIdx + 1]; + dstPixels[dstIdx + 2] = srcPixels[srcIdx + 2]; + dstPixels[dstIdx + 3] = srcPixels[srcIdx + 3]; + } + } + + return dstPixels; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 🪟 BLINDS TRANSITION - Alternating up/down strips (FAST & PERFORMANT) +// ═══════════════════════════════════════════════════════════════════════════ + +// Initialize blinds transition - alternating columns slide up/down +function initDripTransition(width, height) { + pieceTransition.dripOffsets = new Int16Array(width); + pieceTransition.dripSpeeds = new Float32Array(width); + pieceTransition.dripDirections = new Int8Array(width); + pieceTransition.maxGenerations = 30; // Faster! + pieceTransition.phase = "loading"; + pieceTransition.targetOffset = Math.floor(height * 0.35); // ~35% during loading (less wait) + + // Initialize per-column state - alternating directions like blinds + for (let x = 0; x < width; x++) { + pieceTransition.dripOffsets[x] = 0; // Start at 0 (overlay fully in place) + // Alternate: even columns slide DOWN, odd columns slide UP + pieceTransition.dripDirections[x] = (x % 2 === 0) ? 1 : -1; + // Faster speed during loading phase + pieceTransition.dripSpeeds[x] = 4 + Math.random() * 3; // 4-7 pixels per frame + } +} + +// Signal that piece is loaded - switch to fast reveal phase +function transitionPieceLoaded() { + if (pieceTransition.active && pieceTransition.phase === "loading" && pieceTransition.dripSpeeds) { + pieceTransition.phase = "revealing"; + // Boost speeds for the reveal phase - FAST! + for (let x = 0; x < pieceTransition.dripSpeeds.length; x++) { + pieceTransition.dripSpeeds[x] = 10 + Math.random() * 8; // 10-18 pixels per frame + } + console.log("🎬 Transition: Piece loaded! Switching to reveal phase"); + } +} + +// Step blinds transition - advance each column's offset +function dripStep() { + pieceTransition.generation++; + const { dripOffsets, dripSpeeds, dripDirections, height, phase, targetOffset } = pieceTransition; + + let allDone = true; + + for (let x = 0; x < dripOffsets.length; x++) { + const currentOffset = dripOffsets[x]; + + if (phase === "loading") { + // Loading phase: slowly move toward target (~45%) + if (currentOffset < targetOffset) { + dripOffsets[x] += dripSpeeds[x]; + // Clamp to target during loading + if (dripOffsets[x] > targetOffset) { + dripOffsets[x] = targetOffset; + } + allDone = false; + } + } else { + // Revealing phase: accelerate to completion + if (currentOffset < height) { + dripOffsets[x] += dripSpeeds[x]; + // Add acceleration (gravity/momentum) + dripSpeeds[x] += 0.3; + allDone = false; + } + } + } + + // During loading phase, never report "done" - wait for reveal + if (phase === "loading") { + return true; // Keep running + } + + return !allDone && pieceTransition.generation < pieceTransition.maxGenerations; +} + +// Apply blinds overlay - alternating columns slide up/down to reveal new frame +function applyDripOverlay(screenPixels) { + const { overlayPixels, dripOffsets, dripDirections, width, height } = pieceTransition; + if (!overlayPixels || !screenPixels || !dripOffsets || !dripDirections) return; + + for (let x = 0; x < width; x++) { + const offset = Math.max(0, dripOffsets[x] | 0); + const direction = dripDirections[x]; + + if (offset >= height) continue; // Column fully slid off + + const visibleRows = height - offset; + + if (direction > 0) { + // SLIDE DOWN - overlay moves down, reveals new frame from TOP + for (let y = 0; y < visibleRows; y++) { + const srcY = y; // Read from top of overlay + const dstY = y + offset; // Write shifted down + const srcIdx = (srcY * width + x) * 4; + const dstIdx = (dstY * width + x) * 4; + + screenPixels[dstIdx] = overlayPixels[srcIdx]; + screenPixels[dstIdx + 1] = overlayPixels[srcIdx + 1]; + screenPixels[dstIdx + 2] = overlayPixels[srcIdx + 2]; + screenPixels[dstIdx + 3] = overlayPixels[srcIdx + 3]; + } + } else { + // SLIDE UP - overlay moves up, reveals new frame from BOTTOM + for (let y = 0; y < visibleRows; y++) { + const srcY = offset + y; // Read from bottom portion of overlay + const dstY = y; // Write shifted up + const srcIdx = (srcY * width + x) * 4; + const dstIdx = (dstY * width + x) * 4; + + screenPixels[dstIdx] = overlayPixels[srcIdx]; + screenPixels[dstIdx + 1] = overlayPixels[srcIdx + 1]; + screenPixels[dstIdx + 2] = overlayPixels[srcIdx + 2]; + screenPixels[dstIdx + 3] = overlayPixels[srcIdx + 3]; + } + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 🫧 BUBBLE-WRAP TRANSITION - Organic morphing dissolve (DEPRECATED: slower) +// ═══════════════════════════════════════════════════════════════════════════ + +// Initialize bubble-wrap transition - per-pixel dissolve with organic noise +function initBubbleWrapCells(width, height) { + pieceTransition.blockSize = 1; // Per-pixel + pieceTransition.blocksX = width; + pieceTransition.blocksY = height; + pieceTransition.blockData = null; + pieceTransition.maxGenerations = 30; // Slower for organic feel +} + +// Step bubble-wrap transition +function bubbleWrapStep(screenPixels) { + pieceTransition.generation++; + return pieceTransition.generation < pieceTransition.maxGenerations; +} + +// Apply bubble-wrap overlay - chunky biological smoothie morph +function applyBubbleWrapOverlay(screenPixels) { + const { overlayPixels, width, height, generation, maxGenerations } = pieceTransition; + if (!overlayPixels || !screenPixels) return; + + // Progress 0-1 + const progress = generation / maxGenerations; + // Eased for smooth start/end + const eased = progress < 0.5 + ? 2 * progress * progress + : 1 - Math.pow(-2 * progress + 2, 2) / 2; + + // Time offset for animated noise + const t = generation * 0.1; + + // Displacement peaks mid-transition - CHUNKY + const dispPeak = Math.sin(progress * Math.PI); + const maxDisp = dispPeak * 16; + + // Block size for chunky feel + const bs = 4; + + for (let y = 0; y < height; y++) { + const rowOff = y * width; + // Quantize y for chunky blocks + const qy = (y / bs | 0) * bs; + + for (let x = 0; x < width; x++) { + const i = (rowOff + x) * 4; + // Quantize x for chunky blocks + const qx = (x / bs | 0) * bs; + + // Organic noise at block resolution for chunky dissolve + const n1 = Math.sin(qx * 0.05 + t) * Math.cos(qy * 0.04 + t * 0.7); + const n2 = Math.sin(qx * 0.09 + qy * 0.07 - t * 0.6) * 0.5; + const n3 = Math.cos(qx * 0.02 - qy * 0.03 + t * 0.4) * 0.4; + const noise = (n1 + n2 + n3) * 0.5 + 0.5; + + // Per-block blend with organic variation + const threshold = eased * 1.5 - 0.25; + const edge = 0.3; + let blend = (threshold - noise * 0.85) / edge; + blend = blend < 0 ? 0 : blend > 1 ? 1 : blend; + + // SMOOTHIE: chaotic displacement that samples from BOTH frames + const swirl = Math.sin(qx * 0.06 + qy * 0.04 + t * 1.5) * maxDisp; + const churn = Math.cos(qx * 0.05 - qy * 0.07 + t * 1.2) * maxDisp; + + // Sample old frame with swirling displacement + let ox = (x + swirl) | 0; + let oy = (y + churn) | 0; + ox = ox < 0 ? 0 : ox >= width ? width - 1 : ox; + oy = oy < 0 ? 0 : oy >= height ? height - 1 : oy; + const oi = (oy * width + ox) * 4; + + // Sample new frame with OPPOSITE swirl - mixing! + let nx = (x - churn * 0.7) | 0; + let ny = (y + swirl * 0.6) | 0; + nx = nx < 0 ? 0 : nx >= width ? width - 1 : nx; + ny = ny < 0 ? 0 : ny >= height ? height - 1 : ny; + const ni = (ny * width + nx) * 4; + + // Get swirled colors from both + const oldR = overlayPixels[oi]; + const oldG = overlayPixels[oi + 1]; + const oldB = overlayPixels[oi + 2]; + const newR = screenPixels[ni]; + const newG = screenPixels[ni + 1]; + const newB = screenPixels[ni + 2]; + + // Chunky biological blend - smoothie mix! + const inv = 1 - blend; + let r = oldR * inv + newR * blend; + let g = oldG * inv + newG * blend; + let b = oldB * inv + newB * blend; + + // Extra chaos: sometimes swap channels mid-transition + if (dispPeak > 0.5 && ((qx + qy + generation) & 7) === 0) { + const tmp = r; + r = g; + g = b; + b = tmp; + } + + screenPixels[i] = r; + screenPixels[i + 1] = g; + screenPixels[i + 2] = b; + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 🎬 UNIFIED TRANSITION API - Dispatches to drip, bubble-wrap, or none +// ═══════════════════════════════════════════════════════════════════════════ + +// Initialize transition based on current type +function initGOLCells(width, height) { + if (TRANSITION_TYPE === "none") return; // No transition + if (TRANSITION_TYPE === "drip") { + initDripTransition(width, height); + } else { + initBubbleWrapCells(width, height); + } +} + +// Step transition +function golStep(screenPixels) { + if (TRANSITION_TYPE === "none") return false; // Immediately done + if (TRANSITION_TYPE === "drip") { + return dripStep(); + } else { + return bubbleWrapStep(screenPixels); + } +} + +// Apply transition overlay +function applyGOLOverlay(screenPixels) { + if (TRANSITION_TYPE === "drip") { + applyDripOverlay(screenPixels); + } else { + applyBubbleWrapOverlay(screenPixels); + } +} + +let boot = defaults.boot; +let sim = defaults.sim; +let paint = defaults.paint; +let beat = defaults.beat; +let brush, lift, filter; // Only set in the `nopaint` system. +let act = defaults.act; +let leave = defaults.leave; +let receive = defaults.receive; // Handle messages from BIOS +let preview = defaults.preview; +let icon = defaults.icon; +let bake; // Currently only used by the `nopaint` system. + +let leaving = false; // Set to true on first piece. +let leaveLoad; // A callback for loading the next disk after leaving. + +let previewMode = false; // Detects ?preview on a piece and yields its +// preview function if it exists. +let firstPreviewOrIcon = true; +let iconMode = false; // Detects ?icon on a piece and yields its +// icon function if it exists. +let previewOrIconMode; +let hideLabel = false; +let hideLabelViaTab = false; // Track if label is hidden via tab key toggle + +// Helper function to toggle HUD visibility (used by Tab key and center tap) +function toggleHUDVisibility(isDoubleTap = false, skipSound = false) { + const currentTime = performance.now(); + + if (hudAnimationState.animating) { + // Animation in progress: reverse direction and continue from current position + const elapsed = currentTime - hudAnimationState.startTime; + const progress = Math.min(elapsed / hudAnimationState.duration, 1.0); + + // Flip the target state + hudAnimationState.visible = !hudAnimationState.visible; + + // Update the remembered state for when QR fullscreen is turned off + if (hudAnimationState.qrFullscreen) { + hudAnimationState.cornersVisibleBeforeFullscreen = hudAnimationState.visible; + } + + // Restart animation from current position by adjusting the start time + // If we were 30% through a hide animation, start the show animation at 70% progress + const remainingProgress = 1.0 - progress; + hudAnimationState.startTime = currentTime - (remainingProgress * hudAnimationState.duration); + + // Special double-tap: immediately show HUD if hiding + if (isDoubleTap && !hudAnimationState.visible) { + hudAnimationState.animating = false; + hudAnimationState.visible = true; + hudAnimationState.opacity = 1.0; + hudAnimationState.slideOffset = { x: 0, y: 0 }; + hudAnimationState.qrSlideOffset = { x: 0, y: 0 }; + + // Update remembered state + if (hudAnimationState.qrFullscreen) { + hudAnimationState.cornersVisibleBeforeFullscreen = true; + } + } + + if (!skipSound) { + $commonApi.sound.synth({ + type: "sine", + tone: hudAnimationState.visible ? 600 : 400, + duration: 0.15, + attack: 0.1, + decay: 0.8, + volume: 0.2, + }); + } + } else { + // No animation in progress: start new animation + hudAnimationState.animating = true; + hudAnimationState.startTime = currentTime; + hudAnimationState.visible = !hudAnimationState.visible; + + // Update the remembered state for when QR fullscreen is turned off + if (hudAnimationState.qrFullscreen) { + hudAnimationState.cornersVisibleBeforeFullscreen = hudAnimationState.visible; + } + + if (!skipSound) { + $commonApi.sound.synth({ + type: "sine", + tone: hudAnimationState.visible ? 600 : 400, + duration: 0.15, + attack: 0.1, + decay: 0.8, + volume: 0.2, + }); + } + } +} + +// Helper function to toggle QR fullscreen mode for KidLisp pieces +function toggleQRFullscreen() { + try { + // Only allow QR fullscreen for KidLisp pieces that have QR codes + const sourceCode = currentText || currentHUDTxt; + console.log("🔍 [QR Toggle] sourceCode:", sourceCode, "currentPath:", currentPath); + + // Use the centralized KidLisp detection from kidlisp.mjs + const isInlineKidlispPiece = (currentPath && lisp.isKidlispSource(currentPath) && !currentPath.endsWith('.lisp')) || + currentPath === "(...)" || + (sourceCode && sourceCode.startsWith("$")) || + (currentPath && currentPath.includes("/disks/$")) || + (sourceCode && lisp.isKidlispSource(sourceCode)); + + console.log("🔍 [QR Toggle] isInlineKidlispPiece:", isInlineKidlispPiece); + + if (!isInlineKidlispPiece) { + console.log("⚠️ [QR Toggle] Not a KidLisp piece, skipping QR fullscreen toggle"); + return; + } + + if (!hudAnimationState.qrFullscreen) { + // Turning ON fullscreen QR + console.log("✅ [QR Toggle] Enabling fullscreen mode"); + + // Remember current corner visibility state + hudAnimationState.cornersVisibleBeforeFullscreen = hudAnimationState.visible; + + // If corners are currently visible, animate them out for fullscreen QR + if (hudAnimationState.visible) { + // Check if animation is already in progress + if (hudAnimationState.animating) { + // Animation in progress: reverse it + const currentTime = performance.now(); + const elapsed = currentTime - hudAnimationState.startTime; + const remaining = Math.max(0, hudAnimationState.duration - elapsed); + hudAnimationState.startTime = currentTime - remaining; + } else { + // No animation in progress: start new animation + hudAnimationState.animating = true; + hudAnimationState.startTime = performance.now(); + } + hudAnimationState.visible = false; + } + + hudAnimationState.qrFullscreen = true; + + // Remove corner QR hitbox when going fullscreen + send({ type: "button:hitbox:remove", content: "qr-corner" }); + + } else { + // Turning OFF fullscreen QR + console.log("✅ [QR Toggle] Disabling fullscreen mode"); + hudAnimationState.qrFullscreen = false; + + // Remove fullscreen QR hitbox + send({ type: "button:hitbox:remove", content: "qr-fullscreen" }); + + // Restore corner visibility to what it was before fullscreen QR was activated + if (hudAnimationState.cornersVisibleBeforeFullscreen && !hudAnimationState.visible) { + // Check if animation is already in progress + if (hudAnimationState.animating) { + // Animation in progress: reverse it + const currentTime = performance.now(); + const elapsed = currentTime - hudAnimationState.startTime; + const remaining = Math.max(0, hudAnimationState.duration - elapsed); + hudAnimationState.startTime = currentTime - remaining; + } else { + // No animation in progress: start new animation + hudAnimationState.animating = true; + hudAnimationState.startTime = performance.now(); + } + hudAnimationState.visible = true; + } + } + + // Play sound feedback + if ($commonApi?.sound?.synth) { + $commonApi.sound.synth({ + type: "sine", + tone: hudAnimationState.qrFullscreen ? 500 : 250, + duration: 0.12, + attack: 0.1, + decay: 0.9, + volume: 0.25, + }); + } + } catch (err) { + console.error("❌ [QR Toggle] Error toggling QR fullscreen:", err); + } +} + +// In PACK mode, show HUD for non-KidLisp pieces (JS bundles) but hide for KidLisp +const _packModeHudVisible = getPackMode() + ? (typeof window !== 'undefined' && window.acPACK_COLOPHON?.piece?.isKidLisp === false) + : true; +let hudAnimationState = { + visible: _packModeHudVisible, + animating: false, + startTime: 0, + duration: 500, // 500ms animation + opacity: 1.0, + slideOffset: { x: 0, y: 0 }, // HUD label offset (slides to top-left) + qrSlideOffset: { x: 0, y: 0 }, // QR overlay offset (slides to bottom-right) + labelWidth: 120, // HUD label width - updated when HUD is drawn + labelHeight: 40, // HUD label height - updated when HUD is drawn + qrSize: 80, // QR overlay size for bounding box animations + lastTabTime: 0, // Track last tab press for double-tap detection + qrFullscreen: false, // Track if QR code is in fullscreen mode + cornersVisibleBeforeFullscreen: true // Remember corner state before fullscreen QR +}; + +// Clock melody cached code for QR display (received from clock.mjs) +let cachedClockCode = null; + +let module, loadedModule; // Currently loaded piece module code with an extra reference for `hotSwap`. + +// 🎨 Enhanced painting change detection with immediate pixel monitoring +let lastPaintingHash = null; +let paintingChangeCheckInterval = null; +let frameBasedMonitoring = false; + +// Performance optimization: Use requestAnimationFrame for more efficient monitoring +function enableFrameBasedMonitoring() { + if (frameBasedMonitoring) return; + frameBasedMonitoring = true; + + let lastDimensions = null; + + function checkPaintingChanges() { + if ($commonApi.system?.painting && !$commonApi._processingBroadcast) { + const currentHash = generatePaintingHash($commonApi.system.painting); + const currentDimensions = { + width: $commonApi.system.painting.width, + height: $commonApi.system.painting.height + }; + + // Add detailed logging for nopaint interference detection + const isNopaintActive = $commonApi.system?.nopaint?.is?.("painting"); + const nopaintBuffer = $commonApi.system?.nopaint?.buffer; + + if (lastPaintingHash !== null && currentHash !== lastPaintingHash) { + // Check if this is a dimension change (resize operation) + const isDimensionChange = lastDimensions && + (lastDimensions.width !== currentDimensions.width || + lastDimensions.height !== currentDimensions.height); + + // Skip broadcasting during active nopaint operations to prevent interference + if (isNopaintActive) { + console.log(`🚫 SKIPPING broadcast during nopaint operation to prevent live preview interference`); + } else { + // Immediate broadcast with appropriate source + $commonApi.broadcastPaintingUpdateImmediate("frame_update", { + source: isDimensionChange ? "resize" : "frame_monitor", + hash: currentHash.substr(0,8) + }); + } + } + + lastPaintingHash = currentHash; + lastDimensions = currentDimensions; + } + + if (frameBasedMonitoring) { + requestAnimationFrame(checkPaintingChanges); + } + } + + requestAnimationFrame(checkPaintingChanges); +} + +// 🎨 Broadcast throttling to prevent infinite loops +let lastBroadcastTime = 0; +let broadcastThrottleDelay = 100; // Minimum ms between broadcasts +let pieceMetadata = null; // Metadata for current piece (trustLevel, etc.) +let currentPath, + currentHost, + currentSearch, + currentColon, + currentParams, + currentHash, + currentText, + currentCode, + currentHUDTxt, + currentHUDPlainTxt, // Plain text version without color codes + currentOriginalCodeId, // Track original $code identifier for sharing + currentHUDTextColor, + currentHUDStatusColor = "red", + currentHUDButton, + currentHUDButtonActive = false, // Global flag to block other button interactions when HUD is active + currentHUDButtonDirectTouch = false, // Track if HUD button was directly tapped (not rolled over) + currentHUDScrub = 0, + currentHUDScrubReadyState = null, // null | "share" | "edit" + currentHUDLabelFontName, + currentHUDLabelBlockWidth = tf?.blockWidth ?? DEFAULT_TYPEFACE_BLOCK_WIDTH, + currentHUDLabelBlockHeight = tf?.blockHeight ?? DEFAULT_TYPEFACE_BLOCK_HEIGHT, + currentHUDShareWidth = (tf?.blockWidth ?? DEFAULT_TYPEFACE_BLOCK_WIDTH) * "share ".length, + currentHUDEditWidth = tf?.blockWidth ?? DEFAULT_TYPEFACE_BLOCK_WIDTH, + currentHUDLabelMeasuredWidth = 0, + currentHUDTextHeight = 0, + currentHUDTextBoxWidth = 0, + currentHUDTextBoxHeight = 0, + currentHUDLeftPad = 0, + currentHUDOffset, + currentHUDQR = null, // QR code URL to show to the left of label (set via hud.qr()) + currentHUDQRCells = null, // Cached QR code cells + currentHUDAuthor = null, // @handle of the author for KidLisp pieces + currentHUDHits = null, // Hit count for KidLisp pieces + currentHUDSuperscript = null, // Custom superscript text (e.g. ".com") shown after label in cyan + forceTinyHudLabel = false, // Force MatrixChunky8 font for HUD label (set via hud.tinyLabel()) + qrOverlayCache = new Map(), // Cache for QR overlays to prevent regeneration every frame + hudLabelCache = null; // Cache for HUD label to prevent regeneration every frame + +// 📱 LAN Dev mode identity (set by session server in dev mode) +let devIdentity = null; // { name, host, hostIp, mode, connectionId } + +// 📡 Remote log forwarding for LAN Dev mode +let remoteLogQueue = []; +let remoteLogSocket = null; +const MAX_REMOTE_LOG_QUEUE = 50; + +function setupRemoteLogging() { + const originalConsole = { + log: console.log.bind(console), + warn: console.warn.bind(console), + error: console.error.bind(console), + }; + + function sendRemoteLog(level, args) { + if (!devIdentity || !remoteLogSocket?.connected) { + // Queue logs until connected + if (remoteLogQueue.length < MAX_REMOTE_LOG_QUEUE) { + remoteLogQueue.push({ level, args: serializeArgs(args), time: Date.now() }); + } + return; + } + + try { + const logData = { + level, + args: serializeArgs(args), + deviceName: devIdentity.name, + connectionId: devIdentity.connectionId, + time: Date.now(), + }; + remoteLogSocket.send("dev:log", logData); + } catch (e) { + // Silently fail to avoid infinite loop + } + } + + function serializeArgs(args) { + return args.map(arg => { + if (arg === null) return 'null'; + if (arg === undefined) return 'undefined'; + if (typeof arg === 'string') return arg; + if (typeof arg === 'number' || typeof arg === 'boolean') return String(arg); + if (arg instanceof Error) return `${arg.name}: ${arg.message}\n${arg.stack}`; + try { + return JSON.stringify(arg, null, 2); + } catch (e) { + return String(arg); + } + }); + } + + // Intercept console methods + console.log = (...args) => { + originalConsole.log(...args); + sendRemoteLog('log', args); + }; + + console.warn = (...args) => { + originalConsole.warn(...args); + sendRemoteLog('warn', args); + }; + + console.error = (...args) => { + originalConsole.error(...args); + sendRemoteLog('error', args); + }; + + // Capture unhandled errors (only in main thread with window) + if (typeof window !== 'undefined') { + window.addEventListener('error', (event) => { + sendRemoteLog('error', [`Uncaught: ${event.message} at ${event.filename}:${event.lineno}:${event.colno}`]); + }); + + window.addEventListener('unhandledrejection', (event) => { + sendRemoteLog('error', [`Unhandled Promise: ${event.reason}`]); + }); + } +} + +function flushRemoteLogQueue() { + if (!devIdentity || !remoteLogSocket?.connected) return; + + while (remoteLogQueue.length > 0) { + const log = remoteLogQueue.shift(); + try { + remoteLogSocket.send("dev:log", { + level: log.level, + args: log.args, + deviceName: devIdentity.name, + connectionId: devIdentity.connectionId, + time: log.time, + queued: true, + }); + } catch (e) { + break; + } + } +} + +let lastMatrixChunkyHighlightLog = null; +let lastMatrixChunkyWriteDiagnosticLog = null; +let lastMatrixChunkyPixelLog = null; + + + +// Helper functions to safely access window flags in both main thread and worker contexts +function isQROverlayCacheDisabled() { + try { + return typeof window !== 'undefined' && window.acDISABLE_QR_OVERLAY_CACHE; + } catch (e) { + return false; // Default to enabled (caching on) + } +} + +function isHUDLabelCacheDisabled() { + try { + return typeof window !== 'undefined' && window.acDISABLE_HUD_LABEL_CACHE; + } catch (e) { + return false; // Default to enabled if we can't check + } +} + +// Make cache globally accessible for character loading system +if (typeof window !== 'undefined') { + window.qrOverlayCache = qrOverlayCache; + + // Clear caches if they are disabled from BIOS + if (isQROverlayCacheDisabled()) { + qrOverlayCache.clear(); + // console.log("🚫 QR overlay cache disabled and cleared from BIOS"); + } + + // if (isHUDLabelCacheDisabled()) { + // console.log("🚫 HUD label cache disabled from BIOS"); + // } +} +//currentPromptButton; + +const COLOR_CODE_MATCH_REGEX = /\\([^\\]+)\\/g; +const COLOR_CODE_TEST_REGEX = /\\[^\\]+\\/; + +// Utility function to strip color codes from text +function stripColorCodes(str) { + if (!str) return str; + // Remove all \\color\\ sequences including: + // - Named colors: \\red\\, \\blue\\, \\cyan\\ + // - RGB values: \\255,20,147\\, \\192,192,192\\ + // - Complex patterns: \\color(args)\\ + COLOR_CODE_MATCH_REGEX.lastIndex = 0; + return str.replace( + COLOR_CODE_MATCH_REGEX, + "", + ); +} + +function textContainsColorCodes(str) { + if (!str) return false; + return COLOR_CODE_TEST_REGEX.test(str); +} + +function hasKidLispMarkers(text) { + if (!text) return false; + return ( + text.includes("ink ") || + text.includes("line ") || + text.includes("box ") || + text.includes("circle ") || + text.includes("spin ") || + text.includes("(") || + textContainsColorCodes(text) || + /\d+s\.\.\./.test(text) || + /\?\s/.test(text) + ); +} + +function detectKidLispPiece({ currentPath, currentHUDTxt, currentText, cleanText }) { + const sourceCode = currentText || currentHUDTxt; + + return ( + (currentPath && lisp.isKidlispSource(currentPath) && !currentPath.endsWith('.lisp')) || + currentPath === "(...)" || + (sourceCode && sourceCode.startsWith("$")) || + (currentPath && (currentPath.includes("/disks/$") || currentPath.includes("$"))) || + (sourceCode && lisp.isKidlispSource && lisp.isKidlispSource(sourceCode)) || + hasKidLispMarkers(currentHUDTxt) || + textContainsColorCodes(cleanText) || + hasKidLispMarkers(sourceCode) + ); +} + +// Detect embedded/inline KidLisp specifically (kidlisp.com editor, PJ KidLisp) +// Does NOT include .lisp file pieces - only inline/embedded code +function detectEmbeddedKidLisp({ currentPath, currentHUDTxt, currentText }) { + const sourceCode = currentText || currentHUDTxt; + return ( + currentPath === "(...)" || + (sourceCode && sourceCode.startsWith("$")) || + (currentPath && currentPath.includes("/disks/$")) || + (currentPath && currentPath.includes("$") && !currentPath.endsWith('.lisp')) + ); +} + +function updateHUDStatus() { + if (udp.connected && socket?.connected) { + currentHUDStatusColor = "lime"; + } else if (udp.connected || socket?.connected) { + currentHUDStatusColor = "orange"; + } else { + currentHUDStatusColor = "red"; + } +} + +let loading = false; +let loadingStartTime = null; // Track when loading started for timeout safety +const LOADING_TIMEOUT_MS = 10000; // 10 second max loading time + +// Safety check for stuck loading state +function checkLoadingTimeout() { + if (loading && loadingStartTime && (Date.now() - loadingStartTime > LOADING_TIMEOUT_MS)) { + console.warn("🔴 Loading timeout exceeded - forcing loading reset"); + loading = false; + loadingStartTime = null; + return true; // Was stuck + } + return false; +} + +let reframe; +let pendingPieceReload = null; // Queue for piece-reload messages before $commonApi.reload is ready + +const sfxProgressReceivers = {}, + sfxSampleReceivers = {}, + sfxKillReceivers = {}, + sfxDurationReceivers = {}; +let $sampleCount = 0n; + +const signals = []; // Easy messages from embedded DOM content. +const actAlerts = []; // Messages that get put into act and cleared after +// every frame. +let reframed = false; +let formReframing = false; // Just for 3D camera updates. + +let paintings = {}; // Cached bitmaps from a piece. + +// Expose pending image count for oven/grabber to poll (available immediately) +if (typeof window !== "undefined") { + window.acPendingImages = () => + Object.values(paintings).filter((v) => v === "fetching").length; +} + +// 🖼️ Image cache system for paste/stamp - persists across refreshes +// Similar to $kidlisp code cache but for image data +const imageCache = { + // In-memory cache for fast access (RAM) + memory: new Map(), // url -> ImageBitmap + + // IndexedDB persistence reference (set during boot) + store: null, + + // Initialize with store reference for persistent caching + init(storeRef) { + this.store = storeRef; + }, + + // Get from memory cache + get(url) { + return this.memory.get(url); + }, + + // Check if image is cached in memory + has(url) { + return this.memory.has(url); + }, + + // Save to both memory and persistent storage + async set(url, imageBitmap) { + // Save to memory + this.memory.set(url, imageBitmap); + + // Save to IndexedDB for persistence across refreshes + if (this.store && imageBitmap) { + try { + let pixelData, imgWidth, imgHeight; + + if (imageBitmap.pixels) { + // Already a pixel buffer { width, height, pixels } + imgWidth = imageBitmap.width; + imgHeight = imageBitmap.height; + pixelData = imageBitmap.pixels; + } else { + // ImageBitmap — convert to pixel data via canvas drawImage + const canvas = typeof OffscreenCanvas !== 'undefined' + ? new OffscreenCanvas(imageBitmap.width, imageBitmap.height) + : document.createElement('canvas'); + canvas.width = imageBitmap.width; + canvas.height = imageBitmap.height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(imageBitmap, 0, 0); + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + imgWidth = imageData.width; + imgHeight = imageData.height; + pixelData = imageData.data; + } + + // Store the pixel data along with metadata + this.store[`image-cache:${url}`] = { + width: imgWidth, + height: imgHeight, + data: Array.from(pixelData), + cached: Date.now(), + version: 1 + }; + this.store.persist(`image-cache:${url}`, "local:db"); + } catch (error) { + console.warn(`Failed to save image to persistent cache: ${url}`, error); + } + } + }, + + // Load from persistent storage (IndexedDB) + async loadFromPersistent(url) { + if (!this.store) return null; + + try { + const data = await this.store.retrieve(`image-cache:${url}`, "local:db"); + if (data && data.data && data.width && data.height) { + // Reconstruct ImageData from stored array + const imageData = new ImageData( + new Uint8ClampedArray(data.data), + data.width, + data.height + ); + + // Convert ImageData back to ImageBitmap + const imageBitmap = await createImageBitmap(imageData); + + // Cache in memory for future access + this.memory.set(url, imageBitmap); + + return imageBitmap; + } + } catch (error) { + console.warn(`Failed to load image from persistent cache: ${url}`, error); + } + return null; + }, + + // Clear all caches + clear() { + this.memory.clear(); + }, + + // Check if URL is from aesthetic.computer media backend (safe to cache) + isCacheable(url) { + if (!url) return false; + const urlStr = url.toString().toLowerCase(); + return ( + urlStr.includes('aesthetic.computer') || + urlStr.includes('art.aesthetic.computer') || + urlStr.includes('/media/@') || // User paintings + urlStr.match(/\/@[\w-]+\/\d+/) // @handle/timestamp format + ); + } +}; + +let screen; +let currentDisplay; // TODO: Remove this? 22.09.29.11.38 +let cursorCode; +let pieceHistoryIndex = -1; // Gets incremented to 0 when first piece loads. +let paintCount = 0n; +let simCount = 0n; +let booted = false; +// let initialSim = true; +let noPaint = false; +let labelBack = false; +let hiccupTimeout; // Prevent multiple hiccups from being triggered at once. + +// 🌐 Check if running on a branded .com domain (e.g. notepat.com) and redirect to aesthetic.computer +function redirectIfBrandedDomain() { + const { hostname } = getSafeUrlParts(); + if (!hostname) return false; + // Match notepat.com or www.notepat.com (add more branded domains here as needed) + if (hostname === "notepat.com" || hostname === "www.notepat.com") { + send({ type: "web", content: { url: "https://aesthetic.computer/notepat", blank: false } }); + return true; + } + return false; +} + +let storeRetrievalResolutions = {}, + storeDeletionResolutions = {}; + +// USB flash support (Electron only) +let usbDeviceListResolution = null; +let usbFlashResolution = null; + +// There are two instances of Socket that run in parallel... +let socket, socketStartDelay; // Socket server for each piece. + +// ❤️‍🔥 TODO: Explose these somehow to the $commonApi. + +// TODO: Extract `chat` into an external class. + +const chatDebug = + location.host === "local.aesthetic.computer" || + location.host === "localhost:8888" || + location.host === "aesthetic.local:8888"; +const chatClient = new Chat(chatDebug, send); + +let udp = { + send: (type, content) => { + send({ type: "udp:send", content: { type, content } }); + }, + receive: ({ type, content }) => { + // console.log("🩰 Received `piece` message from UDP:", type, content); + + // 🧚 Ambient cursor (fairies) support. Disabled for KidLisp pieces. + if (type === "fairy:point" /*&& socket?.id !== id*/ && visible) { + const isKidlisp = detectKidLispPiece({ currentPath, currentHUDTxt, currentText }) || + (currentPath && currentPath.endsWith('.lisp')); + if (!isKidlisp) { + fairies.push({ x: content.x, y: content.y }); + } + return; + } + + udpReceive?.(type, content); + }, + kill: (outageSeconds) => { + udp.connected = false; + send({ type: "udp:disconnect", content: { outageSeconds } }); + }, + connected: false, + }, + udpReceive = undefined; + +let scream = null; // 😱 Allow priviledged users to send alerts to everyone. +// (A great end<->end socket + redis test.) +let screaming = false; +let screamingTimer; // Keep track of scream duration. + +const fairies = []; // Render cursor points of other active users, +// dumped each frame. + +let glazeEnabled = false; // Keep track of whether glaze is on or off. + +// *** Dark Mode *** +// (By @tarighian) +// Pass `true` or `false` to override or `default` to the system setting. +function darkMode(enabled) { + if (enabled === "default") { + darkMode($commonApi.dark || false); + store.delete("dark-mode"); + actAlerts.push($commonApi.dark ? "dark-mode" : "light-mode"); + return $commonApi.dark; + } else { + // true or false + store["dark-mode"] = enabled; + store.persist("dark-mode"); + $commonApi.dark = enabled; + actAlerts.push($commonApi.dark ? "dark-mode" : "light-mode"); + return enabled; + } +} + +// *** Store *** +// This object is used to store and retrieve data across disks +// The `local` method encodes everything as a JSON string. + +// Note: It strangely also contains an API which could be redefined +// unintentionally. 22.10.15.01.57 +// 22.11.25.11.07: The methods should be refactored out of the storage object / not share keys. +const store = { + persist: function (key, method = "local") { + // Send data over the thread through a key in this object. + send({ + type: "store:persist", + content: { + key, + data: this[key], + method, + }, + }); + // TODO: Turn the existing key into a retrieval function / promise? + }, + retrieve: function (key, method = "local") { + const promise = new Promise((resolve) => { + storeRetrievalResolutions[key] = resolve; + }); + + send({ type: "store:retrieve", content: { key, method } }); + + return promise; + }, + delete: function (key, method = "local") { + // Remove the key from the ram store, no matter what the method. + delete store[key]; + + const promise = new Promise((resolve) => { + storeDeletionResolutions[key] = resolve; + }); + + send({ + type: "store:delete", + content: { + key, + method, + }, + }); + return promise; + }, +}; + +// Promise based API calls (through `bios` and back) +let fileImport; +let serverUpload, serverUploadProgressReporter; +let zipCreation; +let authorizationRequest; +let fileOpenRequest; +let fileEncodeRequest; +let gpuResponse; +let web3Response; +let tezosConnectResponse; +let tezosDisconnectResponse; +let tezosAddressResponse; +let tezosSignResponse; +let tezosCallResponse; + +// Other +let activeVideo; // TODO: Eventually this can be a bank to store video textures. +let videoDeviceCount = 0; +let lastActiveVideo; +let videoSwitching = false; +let preloadPromises = {}; +let inFocus; +let loadFailure; + +// 🎄 Piece code cache for merry preloading +// Maps piece slug -> { code, blobUrl, type: 'mjs' | 'lisp' } +const pieceCodeCache = new Map(); + +// Duration tracking for playlist progress bar (similar to tape system) +let durationStartTime = null; // When the piece started (null if no duration) - using performance.now() +let durationTotal = null; // Total duration in seconds (null if no duration) +let durationProgress = 0; // Current progress (0-1) +let durationCompleted = false; // Whether duration has completed +let durationBlinkState = false; // For blinking the completed bar +let pageLoadTime = performance.now(); // Time when the page first loaded - using performance.now() + +// 1. ✔ API + +// TODO: Eventually add a wiggle bank so all wiggles are indexed +// and start at random angles. +// let wiggler = 0; +let wiggleAngle = 0; + +// TODO; Change this to true and update all brushes. +// let NPdontPaintOnLeave = false; +let NPnoOnLeave = false; + +// 🔴 Recorder (Singleton) +class Recorder { + printProgress = 0; + presentProgress = 0; + printing = false; // Set by a callback from `bios`. + printed = false; // " + recording = false; // " + rollingCallback; + recorded = false; // " + presenting = false; // " + playing = false; // " + cutCallback; + printCallback; + framesCallback; + loadCallback; + + tapeTimerStart; + tapeProgress = 0; + tapeTimerDuration; + tapeFrameMode = false; // Whether we're recording based on frames instead of time + tapeFrameStart = 0; // Starting frame count + tapeFrameTarget = 0; // Target number of frames to record + + videoOnLeave = false; + + constructor() {} + + tapeTimerSet(durationOrFrames, time, isFrameMode = false) { + if (isFrameMode) { + // Frame-based recording + this.tapeFrameMode = true; + this.tapeFrameStart = Number($commonApi.paintCount || 0n); + this.tapeFrameTarget = durationOrFrames; + this.tapeTimerStart = null; + this.tapeTimerDuration = null; + + log.tape.log(`Starting frame-based recording: ${durationOrFrames} frames`); + + // Set a longer failsafe for frame-based recording (frames could take a while) + if (this.failsafeTimeout) { + clearTimeout(this.failsafeTimeout); + } + + this.failsafeTimeout = setTimeout(() => { + log.tape.warn("Frame-based failsafe triggered! Recording may have stalled."); + this.tapeProgress = 0; + this.tapeFrameMode = false; + this.tapeFrameStart = 0; + this.tapeFrameTarget = 0; + + if (typeof this.cut === 'function') { + this.cut(() => { + $commonApi.jump("video"); + }); + } else { + log.tape.warn("Cut function not available in failsafe"); + $commonApi.jump("video"); + } + this.failsafeTimeout = null; + }, Math.max(30000, durationOrFrames * 1000)); // At least 30 seconds, or 1 second per frame + } else { + // Time-based recording (existing behavior) + this.tapeFrameMode = false; + this.tapeTimerStart = time; + this.tapeTimerDuration = durationOrFrames; + + // Add a failsafe timer as backup in case tapeTimerStep stops being called + if (this.failsafeTimeout) { + clearTimeout(this.failsafeTimeout); + } + + // Set a failsafe that will trigger cut if the normal timer fails + this.failsafeTimeout = setTimeout(() => { + log.tape.warn("Failsafe timer triggered! Normal timer may have failed."); + if (this.tapeTimerDuration && this.tapeTimerStart) { + this.tapeProgress = 0; + this.tapeTimerStart = null; + this.tapeTimerDuration = null; + + if (typeof this.cut === 'function') { + this.cut(() => { + $commonApi.jump("video"); + }); + } else { + console.warn(`🎬 ⚠️ Cut function not available in failsafe, manual jump to video`); + $commonApi.jump("video"); + } + } + this.failsafeTimeout = null; + }, (durationOrFrames + 1) * 1000); // Add 1 second buffer to the failsafe + } + } + + tapeTimerStep({ needsPaint, sound: { time } }) { + if (this.tapeFrameMode) { + // Frame-based recording + if (!this.tapeFrameTarget) return; + + const currentFrame = Number($commonApi.paintCount || 0n); + const framesPassed = currentFrame - this.tapeFrameStart; + + this.tapeProgress = framesPassed / this.tapeFrameTarget; + needsPaint(); + + // Check if we've reached or exceeded the target frame count + if (framesPassed >= this.tapeFrameTarget) { + // Clear failsafe since normal timer completed + if (this.failsafeTimeout) { + clearTimeout(this.failsafeTimeout); + this.failsafeTimeout = null; + } + + this.tapeProgress = 0; + this.tapeFrameMode = false; + this.tapeFrameStart = 0; + this.tapeFrameTarget = 0; + + log.tape.success(`Frame-based recording complete: ${framesPassed} frames`); + + // Add safety check for callback existence + if (typeof this.cut === 'function') { + this.cut(() => { + $commonApi.jump("video"); + }); + } else { + log.tape.warn("Cut function not available"); + $commonApi.jump("video"); + } + } + } else { + // Time-based recording (existing behavior) + if (!this.tapeTimerDuration) return; + + // Enhanced timing with race condition protection + this.tapeProgress = (time - this.tapeTimerStart) / this.tapeTimerDuration; + needsPaint(); + + const secondsOver = + this.tapeProgress * this.tapeTimerDuration - this.tapeTimerDuration; + + // Run for an extra 150 milliseconds. + if (this.tapeProgress >= 1 && secondsOver > 0.15) { + // Clear failsafe since normal timer completed + if (this.failsafeTimeout) { + clearTimeout(this.failsafeTimeout); + this.failsafeTimeout = null; + } + + this.tapeProgress = 0; + this.tapeTimerStart = null; + this.tapeTimerDuration = null; + + // Add safety check for callback existence + if (typeof this.cut === 'function') { + this.cut(() => { + $commonApi.jump("video"); + }); + } else { + log.tape.warn("Cut function not available"); + $commonApi.jump("video"); + } + } + } + } + + slate() { + send({ type: "recorder:slate" }); // Kill the MediaRecorder instance. + // TODO: Should printing and playing also be set to false? + //$commonApi.rec.printing = false; // " + $commonApi.rec.recording = false; // Reset this singleton. + $commonApi.rec.recorded = false; // + $commonApi.rec.printed = false; // " + $commonApi.rec.printProgress = 0; // " + $commonApi.rec.cleanMode = false; // Reset clean mode flag + this.tapeFrameMode = false; + this.tapeFrameStart = 0; + this.tapeFrameTarget = 0; + } + + rolling(opts, cb) { + send({ type: "recorder:rolling", content: opts }); + this.rollingCallback = cb; + } + + cut(cb) { + $commonApi.rec.cutCallback = cb; + this.tapeProgress = 0; + this.tapeTimerStart = null; + this.tapeTimerDuration = null; + this.tapeFrameMode = false; + this.tapeFrameStart = 0; + this.tapeFrameTarget = 0; + send({ type: "signal", content: "recorder:cut" }); + } + + print(cb) { + $commonApi.rec.printCallback = cb; + send({ type: "recorder:print" }); + } + + present(noplay = false) { + this.presentProgress = 0; + send({ type: "recorder:present", content: { noplay } }); + } + + unpresent() { + send({ type: "recorder:unpresent" }); + } + + play() { + send({ type: "recorder:present:play" }); + } + + pause() { + send({ type: "recorder:present:pause" }); + } + + requestFrames(cb) { + $commonApi.rec.framesCallback = cb; + send({ type: "recorder:request-frames" }); + } +} + +let cachedAPI; // 🪢 This is a bit hacky. 23.04.21.14.59 + +const hourGlasses = []; + +async function uploadPainting(picture, progress, handle, filename) { + if (typeof picture === "string") { + return { url: picture }; // Assume a URL and just return it. + } else { + // Assume picture is a buffer with `{ pixels, width, height }` + // Upload it to the temporary bucket. + filename ||= `painting-${num.timestamp()}.png`; + + try { + const data = await $commonApi.upload( + filename, + { + pixels: picture.pixels, + width: picture.width, + height: picture.height, + }, + (p) => { + console.log("Painting upload progress:", p); + progress?.(p); + }, + !handle ? "art" : undefined, // Store in temporary if no HANDLE. + ); + console.log("🪄 Painting uploaded:", data.slug, data.ext, data.url); + return data; + } catch (err) { + console.error("🪄 Painting upload failed:", err); + } + } +} + +// Returns whether leaving a piece and also can overwrite the value. +function isLeaving(set) { + if (set === true || set === false) leaving = set; + return leaving; +} + +let docs; // Memorized by `requestDocs`. + +let baseTime = Date.now(); // Virtual clock base +let baseReal = Date.now(); // Real time at last baseTime +let clockFetching = false; +let lastServerTime = undefined; +let clockOffset = 0; // Smoothed offset from server + +// 🤖 Robo Class - For sending synthetic events through the act system +// 🤖 Robo: synthetic pen/event dispatcher decoupled from the hardware pen. +// Maintains its own virtual pen state so the real mouse/touch doesn't +// interfere with robot-driven gestures. +class Robo { + constructor() { + this.currentAPI = null; + // Virtual pen state — independent of $api.pen (which tracks the real mouse/touch). + this.pen = { x: 0, y: 0, px: 0, py: 0, pressure: 0.5, delta: { x: 0, y: 0 }, device: "robot" }; + } + + setAPI(api) { + this.currentAPI = api; + } + + // Send a synthetic event. The event payload carries its own coordinates — + // downstream handlers (nopaint updateBrush etc.) should prefer these over + // $api.pen for events with device === "robot". + sendEvent(eventType, coordinates = {}) { + if (!this.currentAPI) { + console.warn("🤖 Robo: No API context available"); + return; + } + + const x = coordinates.x ?? 0; + const y = coordinates.y ?? 0; + const px = coordinates.px ?? this.pen.x ?? 0; + const py = coordinates.py ?? this.pen.y ?? 0; + const pressure = coordinates.pressure ?? 0.5; + + // Update the robo's own virtual pen (NOT $api.pen). + this.pen.px = this.pen.x; + this.pen.py = this.pen.y; + this.pen.x = x; + this.pen.y = y; + this.pen.pressure = pressure; + this.pen.delta.x = x - px; + this.pen.delta.y = y - py; + this.pen.device = "robot"; + + const eventPayload = { + device: "robot", + type: eventType, + x, + y, + px, + py, + pressure, + delta: { x: x - px, y: y - py }, + }; + + try { + this.currentAPI.act(eventType, eventPayload); + } catch (error) { + console.error("🤖 Robo: Error dispatching event:", error); + } + } + + touch(x, y) { this.sendEvent("touch:1", { x, y, pressure: 0.5 }); } + draw(x, y, px, py) { this.sendEvent("draw:1", { x, y, px, py, pressure: 0.5 }); } + lift(x, y) { this.sendEvent("lift:1", { x, y, pressure: 0.5 }); } + act(eventType, coordinates) { this.sendEvent(eventType, coordinates); } +} + +// 🎹 Persistent DAW state - survives frame updates since $commonApi.sound gets recreated each frame +const persistentDawState = { + bpm: null, + playing: null, + time: null, + sampleRate: null, +}; + +// 🎛️ Persistent spreadnob state +const persistentSpreadnobState = { + note: null, + target: null, + value: null, + active: null, + min: null, + max: null, +}; + +const $commonApi = { + lisp, // A global reference to the `kidlisp` evalurator. + undef: undefined, // A global api shorthand for undefined. + clock: { + offset: function () { + if (clockFetching) return; + + // Skip API calls in OBJKT mode or SPIDER mode + if (getPackMode() || (typeof window !== 'undefined' && window.acSPIDER)) { + clockFetching = false; + return; + } + + clockFetching = true; + const t0 = Date.now(); + + fetch("/api/clock") + .then((response) => { + if (!response.ok) { + return response.text().then((err) => { + clockFetching = false; + throw new Error( + `Failed to fetch offset: ${response.status} ${err}`, + ); + }); + } + + return response.text().then((serverTimeISO) => { + const t1 = Date.now(); + const serverTime = new Date(serverTimeISO).getTime(); + + // Assume serverTime is the midpoint of request + const rtt = t1 - t0; + const approxClientMidpoint = t0 + rtt / 2; + const targetOffset = serverTime - approxClientMidpoint; + + // Blend the clock offset gradually (e.g. 10% of the way each resync) + const blendFactor = 0.25; + clockOffset += (targetOffset - clockOffset) * blendFactor; + + // Recompute base time to keep virtual time in sync + baseTime = Date.now() + clockOffset; + baseReal = Date.now(); + + // console.log('synced') + + lastServerTime = serverTime; + clockFetching = false; + }); + }) + .catch((err) => { + console.error("Clock:", err); + clockFetching = false; + }); + }, + + resync: function () { + $commonApi.clock.offset(); + }, + + time: function () { + return new Date(baseTime + (Date.now() - baseReal)); + }, + }, + + // Enable Pointer Lock + penLock: () => { + send({ type: "pen:lock" }); + }, + // Send a message to BIOS (for effect control, etc.) + send: (msg) => { + send(msg); + }, + chat: chatClient.system, + dark: undefined, // If we are in dark mode. + theme: { + light: { + wipeBG: 150, + wipeNum: 200, + }, + dark: { + wipeBG: 32, + wipeNum: 64, + }, + }, + glaze: function (content) { + if (glazeEnabled === content.on) return; // Prevent glaze from being fired twice... + glazeEnabled = content.on; + // Send glaze message immediately when turning ON (don't wait for paint cycle) + // This ensures glaze activates even when returning from a non-glaze piece + if (content.on) { + send({ type: "glaze", content }); + } else { + glazeAfterReframe = { type: "glaze", content }; + } + }, + + // Toggle HUD visibility (same as Tab key functionality) + // skipSound: true for KidLisp pieces to avoid beep/bop sounds on tap + toggleHUD: function (isDoubleTap = false, skipSound = false) { + toggleHUDVisibility(isDoubleTap, skipSound); + }, + + jump: function jump(to, ahistorical = false, alias = false) { + console.log("🧭 jump() called:", to, "SOLO_MODE:", SOLO_MODE, "leaving:", leaving, "loading:", loading); + + if (SOLO_MODE) { + console.log("🔒 Jump blocked: solo mode active"); + return; + } + + if (leaving) { + console.log("🚪🐴 Jump cancelled, already leaving..."); + return; + } + const jumpOut = + to.startsWith("out:") || (to.startsWith("http") && platform.Aesthetic); + + if (shop.indexOf(to) > -1) to = "/" + to; // Jump out for shop products. + + if ( + ((to.startsWith("http") || to.startsWith("/")) && !to.endsWith(".mjs")) || + jumpOut + ) { + to = to.replace("out:", ""); + try { + // url = new URL(to); + console.log("🐎 Jumping to web URL:", to); + $commonApi.net.web(to, jumpOut); + return; + } catch (e) { + // Could not construct a valid url from the jump, so we will be + // running a local aesthetic.computer piece. + console.log("🐎 URL construction failed, treating as local piece:", e.message); + return; + } + } else { + leaving = true; + graph.unmask(); // Clear any active mask when leaving a piece + } + + // Preserve resolution parameters across jumps (device mode, TV mode, etc.) + // This ensures HUD visibility and other display settings persist when navigating + { + const params = []; + if (DEVICE_MODE) params.push("device=true"); + if (TV_MODE) params.push("tv=true"); + if (SOLO_MODE) params.push("solo=true"); + if (HIGHLIGHT_MODE) { + if (HIGHLIGHT_COLOR && HIGHLIGHT_COLOR !== "64,64,64") { + params.push(`highlight=${encodeURIComponent(HIGHLIGHT_COLOR)}`); + } else { + params.push("highlight=true"); + } + } + if (PERF_MODE) params.push("perf=true"); + if (AUTO_SCALE_MODE) params.push("autoScale=true"); + if (hideLabel) params.push("nolabel=true"); + if (NOGAP_MODE) params.push("nogap=true"); + if (SPOOF_AUDIO_MODE) params.push("spoofaudio=true"); + + if (params.length > 0) { + const separator = to.includes("?") ? "&" : "?"; + to = to + separator + params.join("&"); + console.log("🧭 Preserving resolution params:", to); + } + } + + function loadLine() { + load(parse(to), ahistorical, alias, false, callback); + } + + let callback; + leaveLoad = () => { + // Intercept returns to the prompt when taping from a piece directly. + if ($commonApi.rec.videoOnLeave && to.split("~")[0] === "prompt") { + to = "video"; + $commonApi.rec.videoOnLeave = false; + $commonApi.rec.cut(loadLine); + } else { + if (to.split("~")[0] === "prompt" && + globalKidLispInstance?.clearBakedLayers) { + globalKidLispInstance.clearBakedLayers(); + graph.unmask(); // Clear mask when returning to prompt from KidLisp + } + loadLine(); // Or just load normally. + } + }; + return (cb) => (callback = cb); + }, + + // 🎄 Preload piece modules for merry pipelines (prevents network latency during fast cycling) + preloadPieces: async function preloadPieces(pieceNames) { + if (!pieceNames || pieceNames.length === 0) return; + + const baseUrl = getBuiltInPieceBaseUrl(); + + const fetchPromises = pieceNames.map(async (piece) => { + // Skip if already cached + if (pieceCodeCache.has(piece)) { + console.log(`🎄 Piece already cached: ${piece}`); + return; + } + + try { + // Try .mjs first, then .lua, then .lisp + const mjsUrl = `${baseUrl}/aesthetic.computer/disks/${piece}.mjs?v=${Date.now()}`; + let response = await fetch(mjsUrl, { cache: 'no-store' }); + + if (response.ok) { + const code = await response.text(); + pieceCodeCache.set(piece, { code, type: 'mjs' }); + console.log(`🎄 Preloaded mjs: ${piece}`); + return; + } + + // Try .lua + const luaUrl = `${baseUrl}/aesthetic.computer/disks/${piece}.lua?v=${Date.now()}`; + response = await fetch(luaUrl, { cache: 'no-store' }); + + if (response.ok) { + const code = await response.text(); + pieceCodeCache.set(piece, { code, type: 'lua' }); + console.log(`🎄 Preloaded lua: ${piece}`); + return; + } + + // Try .lisp + const lispUrl = `${baseUrl}/aesthetic.computer/disks/${piece}.lisp?v=${Date.now()}`; + response = await fetch(lispUrl, { cache: 'no-store' }); + + if (response.ok) { + const code = await response.text(); + pieceCodeCache.set(piece, { code, type: 'lisp' }); + console.log(`🎄 Preloaded lisp: ${piece}`); + return; + } + + console.warn(`🎄 Could not preload piece: ${piece}`); + } catch (err) { + console.warn(`🎄 Error preloading ${piece}:`, err); + } + }); + + await Promise.all(fetchPromises); + console.log(`🎄 Preloaded ${pieceCodeCache.size} pieces`); + }, + + // Get cached piece code (returns undefined if not cached) + getCachedPieceCode: function (pieceName) { + return pieceCodeCache.get(pieceName); + }, + + canShare: false, // Whether navigator.share is enabled for mobile devices. + leaving: isLeaving, + handle: () => { + return HANDLE; + }, + notice: (msg, color = ["white", "green"], opts) => { + notice = msg; + noticeColor = color; + noticeOpts = opts; + const sound = {}; + if (color[0] === "yellow" && color[1] === "red") sound.tone = 300; + noticeBell(cachedAPI, sound); + }, + // ⌛ Delay a function by `time` number of sim steps. + delay: (fun, time) => { + hourGlasses.push(new gizmo.Hourglass(time, { completed: () => fun() })); + }, + // Different syntax than `delay` but the same with looped behavior. + blink: (time, fun) => { + hourGlasses.push( + new gizmo.Hourglass(time, { completed: () => fun(), autoFlip: true }), + ); + }, + // 🎟️ Open a ticketed paywall on the page. + ticket: (name) => { + send({ type: "ticket-wall", content: name }); + }, + // 🪙 Mint a url or the `pixels` that get passed into the argument to a + // network of choice. + mint: async (picture, progress, params) => { + console.log("🪙 Minting...", picture); + // Determine if picture is a string or an object. + + // A record will be attached if one exists via the prompt and the user is + // logged in. + let filename; + let zipped; + if (picture.record && HANDLE) { + const record = picture.record; + filename = `painting-${record[record.length - 1].timestamp}.png`; + // Set filename based on record. + + zipped = await $commonApi.zip( + { destination: "upload", painting: { record } }, + (p) => { + console.log("🤐 Zip progress:", p); + progress?.(p); + }, + ); + + console.log("🤐 Zipped:", zipped); + } + + const data = await uploadPainting(picture, progress, HANDLE, filename); + + let description; + + if (picture.width && picture.height) { + description = `A ${picture.width}x${picture.height} pixel painting made on [aesthetic computer](https://aesthetic.computer).`; + } else { + description = `A painting made on [aesthetic computer](https://aesthetic.computer).`; + } + + if (data) { + // Redirect to Zora. + if (HANDLE && zipped) { + description = `[\` ${HANDLE}/${data.slug}\`](https://aesthetic.computer/painting~${HANDLE}/${data.slug})\n\n${description}`; + } + + if (HANDLE) data.slug = `${HANDLE}/painting/${data.slug}`; + + const pixels = `https://aesthetic.computer/api/pixel/2048:contain/${data.slug}.${data.ext}`; + + // TODO: Also add the `zip` here? + // Look into writing custom chunks into PNGS: https://chat.openai.com/c/0ed77e57-c5d2-4827-a733-c024da4bebd7 + + $commonApi.jump( + encodeURI( + `https://zora.co/create/single-edition?image=${pixels}&name=${ + params[0] || "Untitled Painting" + }&symbol=$${data.slug}&description=${description}`, + ), + ); + } + }, + // 🖨️ Print either a url or the `pixels` that get passed into + // the argument, with N quantity. + print: async (picture, quantity = 1, progress) => { + console.log("🖨️ Printing:", picture, "Quantity:", quantity); + const data = await uploadPainting(picture, progress); + let pixels; + if (data && data.code) { + // Use the short code (2-6 chars) which pixel.js can look up in the database + pixels = `${data.code}.${data.ext}`; + } else if (data && data.slug) { + // Fallback to slug if code is not available + pixels = `${data.slug}.${data.ext}`; + } else if (data) { + pixels = data.url; + } else { + $commonApi.notice("UPLOAD ERROR", ["red", "yellow"]); + return; + } + + try { + const headers = { "Content-Type": "application/json" }; + try { + // Include authorization token if logged in. + const token = await $commonApi.authorize(); // Get user token. + if (token) headers.Authorization = `Bearer ${token}`; + } catch (err) {} // Handled up-stream. + + const res = await fetch(`/api/print?new=true&pixels=${pixels}`, { + method: "POST", + headers, + body: JSON.stringify({ quantity, slug: $commonApi.slug }), + // TODO: Add order info here. ^ + }); + + const data = await res.json(); + if (!res.ok) + throw new Error( + `🖨️ Print: HTTP error! Status: ${JSON.stringify(data)}`, + ); + console.log("🖨️ Print order:", data); + $commonApi.jump(data.location); // Redirect to checkout. + } catch (error) { + console.error("🖨️ Print order error:", error); + } + }, + // ☕ Mug - Print a painting on a ceramic mug with optional color. + // `picture` can be a painting object OR a code string like "abc" or "#abc" + mug: async (picture, color = "white", quantity = 1, progress) => { + console.log("☕ Mug:", picture, "Color:", color, "Quantity:", quantity); + let pixels; + + // Check if picture is a code string (e.g., "abc" or "#abc") + if (typeof picture === "string") { + const code = picture.startsWith("#") ? picture.slice(1) : picture; + pixels = `${code}.png`; // Assume PNG for existing paintings + console.log("☕ Using existing painting code:", code); + } else { + // Upload the painting + const data = await uploadPainting(picture, progress); + if (data && data.code) { + pixels = `${data.code}.${data.ext}`; + } else if (data && data.slug) { + pixels = `${data.slug}.${data.ext}`; + } else if (data) { + pixels = data.url; + } else { + $commonApi.notice("UPLOAD ERROR", ["red", "yellow"]); + return; + } + } + + try { + const headers = { "Content-Type": "application/json" }; + try { + const token = await $commonApi.authorize(); + if (token) headers.Authorization = `Bearer ${token}`; + } catch (err) {} + + const res = await fetch(`/api/mug?new=true&pixels=${pixels}&color=${color}`, { + method: "POST", + headers, + body: JSON.stringify({ quantity, slug: $commonApi.slug }), + }); + + const data = await res.json(); + if (!res.ok) + throw new Error( + `☕ Mug: HTTP error! Status: ${JSON.stringify(data)}`, + ); + console.log("☕ Mug order:", data); + $commonApi.jump(data.location); + } catch (error) { + console.error("☕ Mug order error:", error); + } + }, + // Create a zip file of specified content. (Used for storing painting data.) + zip: (content, progress) => { + const prom = new Promise((resolve, reject) => { + zipCreation = { resolve, reject }; + }); + + if (content.destination === "upload") { + serverUploadProgressReporter = progress; + serverUploadProgressReporter?.(0); + } + + send({ type: "zip", content }); + return prom; + }, + // Track device motion. + motion: { + on: false, + start: () => { + send({ type: "motion:start" }); + }, + stop: () => { + send({ type: "motion:stop" }); + // TODO: Automatically stop when changing a disk? + }, + current: {}, // Will get replaced by an update event. + }, + // 🔷 Tezos wallet management (via bios.mjs) + wallet: { + _state: { + connected: false, + address: null, + balance: null, + network: "ghostnet", + domain: null, // .tez domain if resolved + }, + _stateRequested: false, // Track if we've requested state from bios + // Get current wallet state (requests from bios if not yet synced) + get: function () { + // Request fresh state from bios on first call + if (!$commonApi.wallet._stateRequested) { + $commonApi.wallet._stateRequested = true; + send({ type: "wallet:get-state" }); + } + return { ...$commonApi.wallet._state }; + }, + // Connect wallet (with optional address for manual entry) + connect: function (options = {}) { + // Support both connect(network) and connect({ address, network }) + if (typeof options === "string") { + send({ type: "wallet:connect", content: { network: options } }); + } else { + send({ type: "wallet:connect", content: options }); + } + }, + // Disconnect wallet + disconnect: function () { + send({ type: "wallet:disconnect" }); + }, + // Refresh balance from RPC + refreshBalance: function () { + send({ type: "wallet:refresh-balance" }); + }, + // Sign a message with the connected wallet + sign: function (message, callback) { + if (callback) $commonApi.wallet._signCallback = callback; + send({ type: "wallet:sign", content: { message } }); + }, + // Get pairing URI for mobile wallet QR code + getPairingUri: function (network = "mainnet", callback) { + if (callback) $commonApi.wallet._pairingCallback = callback; + send({ type: "wallet:get-pairing-uri", content: { network } }); + }, + // Internal callbacks + _pairingCallback: null, + _signCallback: null, + // Check if connected + isConnected: function () { + return $commonApi.wallet._state.connected; + }, + // Request state sync from bios + sync: function () { + send({ type: "wallet:get-state" }); + }, + }, + // Speak an `utterance` aloud. + speak: function speak(utterance, voice = "female:18", mode = "cloud", opts) { + return send({ type: "speak", content: { utterance, voice, mode, opts } }); + }, + // Broadcast an event through the entire act system. + act: (event, data = {}) => { + // console.log("Acted:", event); Show the synthetic event. + data.is = (e) => e === event; + cachedAPI.event = data; + try { + act(cachedAPI); + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + }, + // 🚥 `Get` api + // Retrieve media assets from a user account. + get: { + picture: (url) => { + return $commonApi.net.preload( + encodeURI(url), + true, + undefined, + // byOpts, + ); + }, + painting: (code, opts) => { + return { + by: async function (handle = "anon", byOpts) { + const extension = opts?.record ? "zip" : "png"; + + // Use the same origin-aware URL construction logic as module loading + const { protocol, hostname } = getSafeUrlParts(); + + let baseUrl; + // Check if we're in a development environment (localhost with port) + const isDevelopment = hostname === 'localhost' && typeof location !== 'undefined' && location.port; + if (isDevelopment) { + // Use the local development server + baseUrl = `${protocol}//${hostname}:${location.port}`; + } else { + // Use the production server for sandboxed iframes or production + baseUrl = `https://aesthetic.computer`; + } + + // Detect timestamp format (YYYY.M.D.H.M.S.mmm or YYYY.MM.DD.HH.MM.SS.mmm) + const isTimestamp = code.match(/^\d{4}\.\d{1,2}\.\d{1,2}\.\d{1,2}\.\d{1,2}\.\d{1,2}\.\d{1,3}$/); + + let mediaUrl; + if (isTimestamp && handle && handle !== "anon") { + // Legacy user painting with timestamp - use old format + const handleWithAt = handle.startsWith('@') ? handle : `@${handle}`; + mediaUrl = `${baseUrl}/media/${handleWithAt}/painting/${code}.${extension}`; + } else { + // 3-letter code or anonymous painting - use new endpoint + mediaUrl = `${baseUrl}/media/paintings/${code}.${extension}`; + } + + return $commonApi.net.preload( + mediaUrl, + true, + undefined, + byOpts, + ); + }, + }; + }, + }, + // ***Actually*** upload a file to the server. + // 📓 The file name can have `media-` which will sort it on the server into + // a directory via `presigned-url.js`. + upload: async (filename, data, progress, bucket, recordingSlug) => { + const prom = new Promise((resolve, reject) => { + serverUpload = { resolve, reject }; + }); + serverUploadProgressReporter = progress; + serverUploadProgressReporter?.(0); + + console.log("Painting data:", data); + + send({ type: "upload", content: { filename, data, bucket, recordingSlug } }); + return prom; + }, + code: { + channel: (chan) => { + codeChannel = chan; // Set the current `codeChannel`. + store["code-channel"] = codeChannel; // Store and keep it in the browser. + store.persist("code-channel"); + if (!codeChannel || codeChannel?.length === 0) { + console.log("📭 Code channel cleared!"); + } else { + console.log("📬 Code channel set to:", codeChannel); + } + socket.send("code-channel:sub", codeChannel); + // Tell any parent iframes that the channel has been updated. + send({ + type: "post-to-parent", + content: { type: "setCode", value: codeChannel }, + }); + // ❤️‍🔥 + // TODO: Should return a promise here, and wait for a `code-channel:subbed` + // event, that way users get better confirmation if the socket + // doesn't go through / there is a server issue. 23.07.04.18.01 + }, + // Store a painting code mapping (timestamp/slug -> 3-letter code) + store: (slug, handle, code) => { + codeCache.storeCode(slug, handle, code); + }, + // Get a painting code from timestamp/slug + get: (slug, handle) => { + return codeCache.getCode(slug, handle); + }, + }, + encode: async (file) => { + // File should be { type, data } where type is "png", "webp", "jef", etc. + const prom = new Promise((resolve, reject) => { + fileEncodeRequest = { resolve, reject }; + }); + send({ type: "file-encode:request", content: file }); + return prom; + }, + file: async () => { + const prom = new Promise((resolve, reject) => { + fileOpenRequest = { resolve, reject }; + }); + send({ type: "file-open:request" }); + return prom; + }, + // Authorize a user. + authorize: async () => { + // TODO: This should always fail while running user code. + // console.log("Sending auth request..."); + const prom = new Promise((resolve, reject) => { + authorizationRequest = { resolve, reject }; + }); + send({ type: "authorization:request" }); + return prom; + }, // Get a token for a logged in user. + // Hand-tracking. 23.04.27.10.19 TODO: Move eventually. + hand: { mediapipe: { screen: [], world: [], hand: "None" } }, + hud: { + label: (text, color, offset, plainTextOverride) => { + currentHUDTxt = text; + // Use plainTextOverride if provided, otherwise strip color codes from text + currentHUDPlainTxt = plainTextOverride || stripColorCodes(text); // Store plain text version + + if (!color) { + currentHUDTextColor = currentHUDTextColor || graph.findColor(color); + } else { + currentHUDTextColor = graph.findColor(color); + } + currentHUDOffset = offset; + + // Calculate and store dimensions for animation when directly called + // (This ensures kidlisp pieces have proper animation geometry) + if (currentHUDTxt && currentHUDTxt.length > 0) { + // Use plain text (without color codes) for dimension calculations + const textForMeasurement = currentHUDPlainTxt || currentHUDTxt; + + // Detect if this is a KidLisp piece for more generous width allowance + const sourceCode = currentText || currentHUDTxt; + const isKidlispPiece = (currentPath && lisp?.isKidlispSource && lisp.isKidlispSource(currentPath) && !currentPath.endsWith('.lisp')) || + currentPath === "(...)" || + (sourceCode && sourceCode.startsWith("$")) || + (currentPath && currentPath.includes("/disks/$")) || + (sourceCode && lisp?.isKidlispSource && lisp.isKidlispSource(sourceCode)); + + // Use full screen width for text wrapping + const maxWidth = cachedAPI.screen.width; + + const labelBounds = cachedAPI.text.box( + textForMeasurement, + undefined, + maxWidth, + 1, // scale + true, // wordWrap + // TODO: This should check useTinyHudLabel like the main corner label, but that's not accessible here + // For now, use default font to maintain consistency with current behavior + undefined // Use default font - will need to be updated when useTinyHudLabel is globally accessible + ); + + // Use the actual computed width from text.box instead of character count + const measuredWidth = labelBounds.box.width; + const fallbackShareWidth = (currentHUDLabelBlockWidth || DEFAULT_TYPEFACE_BLOCK_WIDTH) * "share ".length; + const shareWidth = Math.max(currentHUDShareWidth || 0, fallbackShareWidth); + currentHUDLeftPad = shareWidth; + const baseLabelWidth = measuredWidth + shareWidth; + const scrubExtension = Math.max(0, currentHUDScrub); + const h = labelBounds.box.height + cachedAPI.typeface.blockHeight; + + currentHUDLabelMeasuredWidth = baseLabelWidth; + hudAnimationState.labelWidth = baseLabelWidth + scrubExtension; + hudAnimationState.labelHeight = h; + } + }, + // 📱 Set a QR code to display to the LEFT of the HUD label + qr: (url) => { + console.log("📱 HUD QR called with:", JSON.stringify(url), "current:", JSON.stringify(currentHUDQR)); + if (url !== currentHUDQR) { + currentHUDQR = url; + currentHUDQRCells = null; // Clear cache to regenerate + console.log("📱 HUD QR set to:", url); + } + }, + // 🔤 Force MatrixChunky8 (tiny) font for HUD label + tinyLabel: (enabled = true) => { + forceTinyHudLabel = enabled; + }, + // ✨ Set a custom superscript (e.g. ".com") to display after the label in cyan + superscript: (text) => { + currentHUDSuperscript = text || null; + }, + currentStatusColor: () => currentHUDStatusColor, + currentLabel: () => ({ + text: currentHUDTxt, + plainText: currentHUDPlainTxt, // Include plain text version + btn: currentHUDButton, + qrSize: currentHUDQRCells?.length || hudAnimationState?.qrSize || 0, + leftPad: currentHUDLeftPad || 0, + }), + labelBack: () => { + labelBack = true; + // Store the source piece for special handling (e.g., chat returning to prompt) + send({ type: "labelBack:source", content: { sourcePiece: $commonApi.piece } }); + }, + }, + // 📱 LAN Dev mode identity (from session server) + get devIdentity() { return devIdentity; }, + send, + platform, + // USB flash support (Electron only) + usb: { + listDevices: () => new Promise((resolve) => { + usbDeviceListResolution = resolve; + send({ type: "usb:list-devices" }); + }), + flashImage: (opts) => new Promise((resolve) => { + usbFlashResolution = resolve; + send({ type: "usb:flash-image", content: opts }); + }), + }, + history: [], // Populated when a disk loads and sets the former piece. + // Trigger background music. + // Eventually add an "@" style parameter similar to what a stamp system would have. + bgm: { + set: function (trackNumber, volume) { + send({ type: "bgm-change", content: { trackNumber, volume } }); + }, + stop: () => send({ type: "bgm-stop" }), + data: {}, + }, + system: { + // prompt: { input: undefined }, Gets set in `prompt_boot`. + world: { + // Populated in `world_boot` of `world.mjs`. + teleported: false, + telepos: undefined, + teleport: (to, telepos) => { + $commonApi.system.world.teleported = true; + $commonApi.system.world.telepos = telepos; + $commonApi.jump(to); + }, + }, + nopaint: { + //boot: nopaint_boot, // TODO: Why are these in the commonApi? 23.02.12.14.26 + // act: nopaint_act, + buffer: null, // An overlapping brush buffer that gets drawn on top of the + // painting. + recording: false, + record: [], // Store a recording here. + gestureRecord: [], // Store the active gesture. + startRecord: function (fullText) { + const sys = $commonApi.system; + sys.nopaint.record = []; // Clear any existing recording. + sys.nopaint.recording = true; + sys.nopaint.addToRecord({ + label: fullText || "start", + painting: { + pixels: new Uint8ClampedArray(sys.painting.pixels), + width: sys.painting.width, + height: sys.painting.height, + }, + }); + // console.log("🖌️🔴 Now recording:", sys.nopaint.record); + }, + addToRecord: function (record) { + record.timestamp = num.timestamp(); // Insert the timestamp data. + record.gesture = $commonApi.system.nopaint.gestureRecord.slice(); + if (record.gesture.length === 0) delete record.gesture; + $commonApi.system.nopaint.gestureRecord = []; + $commonApi.system.nopaint.record.push(record); + store["painting:record"] = $commonApi.system.nopaint.record; + store.persist("painting:record", "local:db"); + // console.log("🖌️🟠 Recorded a step:", record.label); + }, + is: nopaint_is, + cancelStroke: nopaint_cancelStroke, + undo: { paintings: undoPaintings }, + needsBake: false, + needsPresent: false, + bakeOnLeave: false, + addUndoPainting, + // Regresses the system painting to a previous state. + // Or the reverse... ("yes") + no: ({ system, store, needsPaint }, yes = false) => { + const paintings = system.nopaint.undo.paintings; + + let dontRecord = false; + + if (yes) { + // ⏩ Fast-forward mode. + undoPosition += 1; + if (undoPosition > paintings.length - 1) { + undoPosition = paintings.length - 1; + dontRecord = true; + } + } else { + // ⏪ Rewind mode. + undoPosition -= 1; + if (undoPosition < 0) { + undoPosition = 0; + dontRecord = true; + } + } + + if (paintings.length > 1) { + // Copy over the old picture here... + const p = paintings[undoPosition]; + + const op = p.pixels; + const pixels = new Uint8ClampedArray(op.length); + pixels.set(op); + + store["painting"] = { + width: p.width, + height: p.height, + pixels, + }; + + const resolutionChange = + paintings[0].width !== paintings[1].width || + paintings[0].height !== paintings[1].height; + + // 🦢 Swap mode. + // 'no' should swap... + // const temp = paintings[0]; + // paintings[0] = paintings[1]; + // paintings[1] = temp; + + store.persist("painting", "local:db"); + + // 🎨 Broadcast painting update to other tabs + $commonApi.broadcastPaintingUpdate("updated", { + source: "yes-no-decision" + }); + + system.painting = store["painting"]; + + if (system.nopaint.recording && dontRecord === false) { + const label = yes ? "yes" : "no"; + system.nopaint.addToRecord({ + label, //, + // painting: { + // width: system.painting.width, + // height: system.painting.height, + // pixels: new Uint8Array(system.painting.pixels), + // }, + }); + } + + if (resolutionChange) { + system.nopaint.resetTransform({ system }); + system.nopaint.storeTransform(store, system); + } + + needsPaint(); + } + }, + // Center the picture within the screen / default translation. + resetTransform: ({ system: sys }) => { + sys.nopaint.zoomLevel = 1; + + if (!sys.painting) { + sys.nopaint.translation = { x: 0, y: 0 }; + return; + } + + sys.nopaint.translation.x = floor( + screen.width / 2 - sys.painting.width / 2, + ); + sys.nopaint.translation.y = floor( + screen.height / 2 - sys.painting.height / 2, + ); + }, + storeTransform: (store, sys) => { + store["painting:transform"] = { + translation: sys.nopaint.translation, + zoom: sys.nopaint.zoomLevel, + }; + store.persist("painting:transform", "local:db"); + }, + translation: { x: 0, y: 0 }, + zoomLevel: 1, + translate: ({ system }, x, y) => { + system.nopaint.translation.x += x; + system.nopaint.translation.y += y; + }, + // zoom: ({ system }, dir) => { + // system.nopaint.zoomLevel += dir === "in" ? 1 : -1; + // console.log("🔭 Zoom level:", system.nopaint.zoomLevel); + // if (system.nopaint.zoomLevel <= 0) system.nopaint.zoomLevel = 1; + // // TODO: Adjust the translation based on system.nopaint.brush.x and y + // // Which would serve as the zoom origin point. + // }, + zoom: ({ system }, dir, cursor) => { + // Store old zoom level + const oldZoomLevel = system.nopaint.zoomLevel; + + // Adjust the zoom level + system.nopaint.zoomLevel += dir === "in" ? 1 : -1; + + // Ensure zoom level is at least 1 + if (system.nopaint.zoomLevel <= 0) system.nopaint.zoomLevel = 1; + + // Calculate the scaling factor based on the change in zoom levels + const scale = system.nopaint.zoomLevel / oldZoomLevel; + + // Adjust the translation based on the scaling factor and the cursor's position + system.nopaint.translation.x = floor( + cursor.x + (system.nopaint.translation.x - cursor.x) * scale, + ); + system.nopaint.translation.y = floor( + cursor.y + (system.nopaint.translation.y - cursor.y) * scale, + ); + }, + brush: { x: 0, y: 0, dragBox: undefined }, + transform: (p) => { + return { + x: (p.x - nopaintAPI.translation.x) / nopaintAPI.zoomLevel, + y: (p.y - nopaintAPI.translation.y) / nopaintAPI.zoomLevel, + }; + }, + // Similar to `updateBrush` but for arbitrary points, + // with no change to `system.nopaint.brush`. + pointToPainting: ({ system, pen }) => { + const zoom = system.nopaint.zoomLevel; + const x = floor(((pen?.x || 0) - system.nopaint.translation.x) / zoom); + const y = floor(((pen?.y || 0) - system.nopaint.translation.y) / zoom); + + return { x, y }; + }, + updateBrush: ({ pen, event, system }, act) => { + // Prefer the event's coordinates over $api.pen for robot-driven events + // so the synthetic pen path doesn't get corrupted by hardware mouse moves. + const source = (event && event.device === "robot") ? event : pen; + // TODO: Use `pointToPainting` above. 23.10.11.08.49 + const zoom = system.nopaint.zoomLevel; + const x = floor(((source?.x || 0) - system.nopaint.translation.x) / zoom); + const y = floor(((source?.y || 0) - system.nopaint.translation.y) / zoom); + + if (act === "touch") { + system.nopaint.startDrag = { x, y }; + } + + // Ensure startDrag exists before creating dragBox + if (!system.nopaint.startDrag) { + system.nopaint.startDrag = { x, y }; + } + + const dragBox = new geo.Box( + system.nopaint.startDrag.x, + system.nopaint.startDrag.y, + x - system.nopaint.startDrag.x, + y - system.nopaint.startDrag.y, + ); + + system.nopaint.brush = { x, y, dragBox, pressure: source?.pressure || 0.5 }; + + // Call needsPaint during any pen movement to ensure continuous painting and HUD updates + $commonApi.needsPaint(); + }, + + // Helper to display the existing painting on the screen, with an + // optional pan amount, that returns an adjusted pen pointer as `brush`. + + // TODO: - [] Add Zoom + // - [] And Rotation! + + present: ( + { system, screen, wipe, paste, ink, slug, dark, theme, blend }, + tx, + ty, + ) => { + system.nopaint.needsPresent = false; + + // Guard: system.painting must exist for present to work + if (!system.painting) { + console.warn("🖼️ present: system.painting is undefined, skipping"); + return; + } + + const x = tx || system.nopaint.translation.x; + const y = ty || system.nopaint.translation.y; + + system.nopaint.translation = { x, y }; + + const fullbleed = + x === 0 && + y === 0 && + screen.width <= system.painting.width && + screen.height <= system.painting.height; + + if (fullbleed) { + // If we are not panned and the painting fills the screen. + paste(system.painting); + paste(system.nopaint.buffer); + } else { + // If we are panned or the painting is a custom resolution. + + wipe(theme[dark ? "dark" : "light"].wipeBG) + .paste(system.painting, x, y, system.nopaint.zoomLevel); + + // Normal alpha blending for overlay buffer + paste(system.nopaint.buffer, x, y, system.nopaint.zoomLevel); + + ink(128) + .box( + x, + y, + system.painting.width * system.nopaint.zoomLevel, + system.painting.height * system.nopaint.zoomLevel, + "outline", + ); + } + + // 🎯 DEBUG: Draw dragBox overlay if one exists (COMMENTED OUT) + /* + if (system.nopaint.brush?.dragBox) { + const dragBox = system.nopaint.brush.dragBox; + + // Apply zoom and translation to the dragBox coordinates + const overlayX = (dragBox.x * system.nopaint.zoomLevel) + x; + const overlayY = (dragBox.y * system.nopaint.zoomLevel) + y; + const overlayW = dragBox.w * system.nopaint.zoomLevel; + const overlayH = dragBox.h * system.nopaint.zoomLevel; + + // Draw the dragBox outline in bright green with some transparency + ink(0, 255, 0, 180).box(overlayX, overlayY, overlayW, overlayH, "outline"); + + // Draw corner indicators + const cornerSize = 4; + ink(0, 255, 0, 220); + // Top-left corner + ink().box(overlayX - cornerSize/2, overlayY - cornerSize/2, cornerSize, cornerSize); + // Top-right corner + ink().box(overlayX + overlayW - cornerSize/2, overlayY - cornerSize/2, cornerSize, cornerSize); + // Bottom-left corner + ink().box(overlayX - cornerSize/2, overlayY + overlayH - cornerSize/2, cornerSize, cornerSize); + // Bottom-right corner + ink().box(overlayX + overlayW - cornerSize/2, overlayY + overlayH - cornerSize/2, cornerSize, cornerSize); + + // Display coordinate info + const coordText = `${dragBox.x},${dragBox.y} ${dragBox.w}x${dragBox.h}`; + ink(0, 255, 0, 200).write(coordText, { + x: overlayX, + y: Math.max(overlayY - 15, 5) // Position above the box, but not off screen + }, undefined, undefined, false, "MatrixChunky8"); + } + */ + + // Graph `zoomLevel` + if (system.nopaint.zoomLevel !== 1 && slug !== "prompt") + ink(255, 127).write(`${system.nopaint.zoomLevel}x`, { x: 6, y: 18 }); + + return { + x, + y, //, + //brush: { x: (pen?.x || 0) - x, y: (pen?.y || 0) - y }, + }; + }, + // Kill an existing painting. + noBang: async ( + { system, store, needsPaint, painting, theme, dark }, + res = { w: screen.width, h: screen.height }, + ) => { + // console.log("deleting..."); + const deleted = await store.delete("painting", "local:db"); + await store.delete("painting:resolution-lock", "local:db"); + await store.delete("painting:transform", "local:db"); + system.nopaint.undo.paintings.length = 0; // Reset undo stack. + system.painting = null; + system.nopaint.resetTransform({ system, screen }); // Reset transform. + needsPaint(); + + // Make a blank painting. + // I don't like that these getters will not re-associate. + system.painting = painting(res.w, res.h, ($) => { + $.wipe(theme[dark ? "dark" : "light"].wipeNum); + }); + + // Store only the pixel data, not the full painting object with functions + store["painting"] = { + width: system.painting.width, + height: system.painting.height, + pixels: system.painting.pixels, + }; + + // Store the new painting BEFORE broadcasting + store.persist("painting", "local:db"); + + // 🎨 Broadcast painting cleared to other tabs + $commonApi.broadcastPaintingUpdate("cleared", { + source: "clear", + width: res.w, + height: res.h, + resetTransform: true // Flag that receiving tabs should reset their transform + }); + + // Clear any existing painting recording in RAM and + // storage. + await store.delete("painting:record", "local:db"); + if (system.nopaint.recording) { + system.nopaint.recording = false; + system.nopaint.record.length = 0; + } + + return deleted; + }, + // Replace a painting entirely, remembering the last one. + // (This will always enable fixed resolution mode.) + replace: ( + { system, screen, store, needsPaint }, + painting, + message = "(replace)", + ) => { + system.painting = painting; // Update references. + + // Store only the pixel data, not the full painting object + store["painting"] = { + width: system.painting.width, + height: system.painting.height, + pixels: system.painting.pixels, + }; + + store.persist("painting", "local:db"); // Persist to storage. + + // 🎨 Broadcast painting replacement to other tabs + $commonApi.broadcastPaintingUpdate("replaced", { + source: "replace", + message: message + }); + + store["painting:resolution-lock"] = true; + store.persist("painting:resolution-lock", "local:db"); + system.nopaint.resetTransform({ system, screen }); // Reset transform. + system.nopaint.storeTransform(store, system); + system.nopaint.addUndoPainting(system.painting, message); + system.nopaint.needsPresent = true; + needsPaint(); + }, + abort: () => (NPnoOnLeave = true), + }, + }, + // Paint all queued rendering commands immediately. + flatten: () => { + return painting.paint(true); + }, + connect: () => { + const p = new Promise((resolve, reject) => { + web3Response = { resolve, reject }; + }); + send({ type: "web3-connect" }); + return p; + }, + // Tezos Wallet API + tezos: { + // Connect to a Tezos wallet (opens Beacon popup) + connect: (network = "mainnet") => { + const p = new Promise((resolve, reject) => { + tezosConnectResponse = { resolve, reject }; + }); + send({ type: "tezos-connect", content: { network } }); + return p; + }, + // Disconnect from Tezos wallet + disconnect: () => { + const p = new Promise((resolve, reject) => { + tezosDisconnectResponse = { resolve, reject }; + }); + send({ type: "tezos-disconnect" }); + return p; + }, + // Get current wallet address (null if not connected) + address: () => { + const p = new Promise((resolve, reject) => { + tezosAddressResponse = { resolve, reject }; + }); + send({ type: "tezos-address" }); + return p; + }, + // Sign a message with the connected wallet + sign: (message) => { + const p = new Promise((resolve, reject) => { + tezosSignResponse = { resolve, reject }; + }); + send({ type: "tezos-sign", content: { message } }); + return p; + }, + // Call a contract method + call: (contractAddress, method, args, amount = 0) => { + const p = new Promise((resolve, reject) => { + tezosCallResponse = { resolve, reject }; + }); + send({ type: "tezos-call", content: { contractAddress, method, args, amount } }); + return p; + }, + }, + wiggle: function (n, level = 0.2, speed = 6) { + wiggleAngle = (wiggleAngle + 1 * speed) % 360; + const osc = sin(num.radians(wiggleAngle)); + return n + n * level * osc; + }, + dark: true, // Dark mode. (Gets set on startup and on any change.) + darkMode, // Toggle dark mode or set to `true` or `false`. + // content: added programmatically: see Content class + gpuReady: false, + gpu: { + message: (content) => { + const p = new Promise((resolve, reject) => { + gpuResponse = { resolve, reject }; + }); + send({ type: "gpu-event", content }); + return p; + }, + }, + // WebGPU 2D Renderer API + webgpu: { + enabled: false, // Flag to disable CPU renderer when true + backend: null, // Preferred backend: "webgpu", "webgl2", "canvas2d", "vello", etc. + // 🛑 Disable WebGPU and restore normal CPU rendering + disable: () => { + $commonApi.webgpu.enabled = false; + $commonApi.webgpu.backend = null; + send({ type: "webgpu-command", content: { type: "disable" } }); + }, + clear: (r = 0, g = 0, b = 0, a = 255) => { + send({ + type: "webgpu-command", + content: { + type: "clear", + color: [r, g, b, a] + } + }); + }, + line: (x1, y1, x2, y2, r = 255, g = 255, b = 255, a = 255) => { + send({ + type: "webgpu-command", + content: { + type: "line", + x1, y1, x2, y2, + color: [r, g, b, a] + } + }); + }, + render: () => { + send({ + type: "webgpu-command", + content: { type: "render" } + }); + }, + // 📊 Toggle performance overlay + perf: (enabled) => { + send({ + type: "webgpu-command", + content: { type: "perf-overlay", enabled } + }); + }, + }, + // 📊 DOM-based stats overlay (always on top of everything, even GPU canvases) + stats: { + show: (data = {}) => { + send({ type: "stats-overlay", content: { enabled: true, data } }); + }, + hide: () => { + send({ type: "stats-overlay", content: { enabled: false } }); + }, + update: (data) => { + send({ type: "stats-overlay", content: { data } }); + }, + }, + // Deprecated in favor of `bios` -> `hitboxes`. (To support iOS) + // clipboard: { + // copy: (text) => { + // send({ type: "copy", content: text }); + // }, + // }, + text: { + capitalize: text.capitalize, + reverse: text.reverse, + // Get the pixel width of a string of characters. + width: (text, fontName) => { + if (Array.isArray(text)) text = text.join(" "); + + // Use specified font or fall back to current typeface + const useTypeface = getTypefaceForMeasurement(fontName) || tf; + if (!useTypeface) { + return text.length * DEFAULT_TYPEFACE_BLOCK_WIDTH; + } + + const isProportional = + useTypeface?.data?.proportional === true || + !!useTypeface?.data?.advances || + !!useTypeface?.data?.bdfFont; + + if (isProportional && typeof useTypeface.getAdvance === "function") { + let totalWidth = 0; + for (const char of text) { + if (char === "\n" || char === "\r") continue; + const advance = useTypeface.getAdvance(char); + totalWidth += typeof advance === "number" ? advance : useTypeface.blockWidth; + } + return totalWidth; + } else { + // For monospace fonts, use the typeface's block width + const blockWidth = useTypeface.blockWidth || DEFAULT_TYPEFACE_BLOCK_WIDTH; + return text.length * blockWidth; + } + }, + height: (text) => { + // Get the pixel height of a string of characters. + return 10; + }, + // Return a text's bounding box. + box: (text, pos = { x: 0, y: 0 }, bounds, scale = 1, wordWrap = true, fontName) => { + + if (!text) { + console.warn("⚠️ No text for `box`."); + return; + } + + pos = { ...pos }; + const absScale = abs(scale ?? 1); + + let useTypeface = getTypefaceForMeasurement(fontName) || tf; + if (!useTypeface) { + useTypeface = tf; + } + + const baseBlockWidth = + useTypeface?.blockWidth ?? tf?.blockWidth ?? DEFAULT_TYPEFACE_BLOCK_WIDTH; + const baseBlockHeight = + useTypeface?.blockHeight ?? tf?.blockHeight ?? DEFAULT_TYPEFACE_BLOCK_HEIGHT; + + const blockHeight = baseBlockHeight * absScale; + + const isProportional = + useTypeface?.data?.proportional === true || + !!useTypeface?.data?.advances || + !!useTypeface?.data?.bdfFont; + + const getAdvanceWidth = (char) => { + if (!char) return baseBlockWidth * absScale; + if (isProportional && typeof useTypeface?.getAdvance === "function") { + const raw = useTypeface.getAdvance(char); + if (typeof raw === "number") { + return raw * absScale; + } + } + return baseBlockWidth * absScale; + }; + + const getCharWidth = (char) => { + if (!char || char === "\n" || char === "\r") return 0; + if (char === "\t") { + return getAdvanceWidth(" ") * 4; + } + return getAdvanceWidth(char); + }; + + if (bounds === undefined) { + const sampleWidth = getAdvanceWidth("0"); + bounds = (text.length + 2) * sampleWidth; + } + if (!(bounds > 0)) { + bounds = Number.POSITIVE_INFINITY; + } + + const lines = [""]; + const charMap = [[]]; + let line = 0; + let run = 0; + let maxWidth = 0; + + const commitLineWidth = () => { + if (run > maxWidth) { + maxWidth = run; + } + }; + + const newLine = () => { + commitLineWidth(); + line += 1; + lines[line] = ""; + charMap[line] = []; + run = 0; + }; + + const appendChar = (char, sourceIndex) => { + const width = getCharWidth(char); + const renderedChar = char === "\t" ? " " : char; + lines[line] += renderedChar; + charMap[line].push(sourceIndex); + run += width; + if (run > maxWidth) { + maxWidth = run; + } + }; + + if (!wordWrap) { + for (let idx = 0; idx < text.length; idx += 1) { + const char = text[idx]; + if (char === "\r") continue; + if (char === "\n") { + newLine(); + continue; + } + appendChar(char, idx); + } + } else { + const tokens = []; + let idx = 0; + while (idx < text.length) { + const char = text[idx]; + if (char === "\r") { + idx += 1; + continue; + } + if (char === "\n") { + tokens.push({ type: "newline", indices: [idx] }); + idx += 1; + continue; + } + if (char === " " || char === "\t") { + let textBuffer = ""; + const indices = []; + while (idx < text.length) { + const c = text[idx]; + if (c !== " " && c !== "\t") break; + textBuffer += c; + indices.push(idx); + idx += 1; + } + tokens.push({ type: "whitespace", text: textBuffer, indices }); + continue; + } + let textBuffer = ""; + const indices = []; + while (idx < text.length) { + const c = text[idx]; + if (c === "\n" || c === " " || c === "\t" || c === "\r") break; + textBuffer += c; + indices.push(idx); + idx += 1; + } + if (textBuffer.length > 0) { + tokens.push({ type: "word", text: textBuffer, indices }); + } + } + + tokens.forEach((token) => { + if (token.type === "newline") { + newLine(); + return; + } + + if (token.type === "whitespace") { + for (let i = 0; i < token.text.length; i += 1) { + const char = token.text[i]; + const sourceIndex = token.indices[i]; + const width = getCharWidth(char); + if (run > 0 && run + width > bounds) { + newLine(); + } + appendChar(char, sourceIndex); + } + return; + } + + if (token.type === "word") { + const chars = token.text.split(""); + const widths = chars.map((char) => getCharWidth(char)); + const wordWidth = widths.reduce((acc, value) => acc + value, 0); + + if (bounds > 0 && wordWidth > bounds) { + chars.forEach((char, charIdx) => { + const width = widths[charIdx]; + if (run > 0 && run + width > bounds) { + newLine(); + } + appendChar(char, token.indices[charIdx]); + }); + return; + } + + if (run > 0 && run + wordWidth > bounds) { + newLine(); + } + + chars.forEach((char, charIdx) => { + appendChar(char, token.indices[charIdx]); + }); + } + }); + } + + commitLineWidth(); + + const lineHeightGap = 1 * absScale; + const finalBlockHeight = blockHeight + lineHeightGap; + + if (lines.length >= 1 && pos.center && pos.center.indexOf("y") !== -1) { + pos.y = + $activePaintApi.screen.height / 2 - + (lines.length * finalBlockHeight) / 2 + + finalBlockHeight / 2 + + (pos.y || 0); + } + + const height = lines.length * finalBlockHeight; + + const lineWidths = lines.map((lineText, index) => { + if (!lineText) return 0; + const indices = charMap[index] || []; + let width = 0; + for (let i = 0; i < lineText.length; i += 1) { + const sourceIndex = indices[i]; + if (typeof sourceIndex === "number" && sourceIndex >= 0 && sourceIndex < text.length) { + width += getCharWidth(text[sourceIndex]); + } else { + width += getCharWidth(lineText[i]); + } + } + return width; + }); + + const maxLineWidth = lineWidths.reduce((acc, value) => Math.max(acc, value), 0); + + const box = { x: pos.x, y: pos.y, width: maxLineWidth, height }; + + return { pos, box, lines, lineWidths, lineHeight: finalBlockHeight, charMap }; + }, + }, + num: { + add: num.add, + wrap: num.wrap, + even: num.even, + odd: num.odd, + clamp: num.clamp, + wave: num.wave, + rand: num.rand, + randInt: num.randInt, + randInd: num.randInd, + randIntArr: num.randIntArr, + randIntRange: num.randIntRange, + rangedInts: num.rangedInts, + multiply: num.multiply, + perlin: num.perlin, + dist: num.dist, + dist3d: num.dist3d, + radians: num.radians, + degrees: num.degrees, + lerp: num.lerp, + map: num.map, + arrMax: num.arrMax, + arrCompress: num.arrCompress, + Track: num.Track, + timestamp: num.timestamp, + p2: num.p2, + midp: num.midp, + number: num.number, + intersects: num.intersects, + signedCeil: num.signedCeil, + signedFloor: num.signedFloor, + vec2: vec2, + vec3: vec3, + vec4: vec4, + mat3: mat3, + mat4: mat4, + quat: quat, + parseColor: num.parseColor, + findColor: num.findColor, + saturate: num.saturate, + desaturate: num.desaturate, + shiftRGB: num.shiftRGB, + rgbToHexStr: num.rgbToHexStr, + hexToRgb: num.hexToRgb, + blend: num.blend, + rgbToHsl: num.rgbToHsl, + hslToRgb: num.hslToRgb, + rainbow: num.rainbow, + zebra: num.zebra, + resetZebraCache: num.resetZebraCache, + }, + geo: { + Box: geo.Box, + DirtyBox: geo.DirtyBox, + Grid: geo.Grid, + Circle: geo.Circle, + linePointsFromAngle: geo.linePointsFromAngle, + pointFrom: geo.pointFrom, + Race: geo.Race, + Quantizer: geo.Quantizer, + }, + ui: { + Button: ui.Button, + Slider: ui.Slider, + TextButton: ui.TextButton, + TextButtonSmall: ui.TextButtonSmall, + TextInput: TextInput, + }, + help: { + choose: help.choose, + flip: help.flip, + repeat: help.repeat, + every: help.every, + any: help.any, + anyIndex: help.anyIndex, + anyKey: help.anyKey, + resampleArray: help.resampleArray, + each: help.each, + shuffleInPlace: help.shuffleInPlace, + serializePainting: (painting) => { + if (!painting) return; + const pixels = uint8ArrayToBase64(painting.pixels); + return { width: painting.width, height: painting.height, pixels }; + }, + deserializePainting: (painting) => { + if (!painting) return; + const pixels = base64ToUint8Array(painting.pixels); + return { width: painting.width, height: painting.height, pixels }; + }, + }, + gizmo: { Hourglass: gizmo.Hourglass, EllipsisTicker: gizmo.EllipsisTicker, Ticker: gizmo.Ticker }, + rec: new Recorder(), + robo: new Robo(), + net: { + signup: () => { + send({ type: "signup" }); + }, + login: () => { + store.delete("handle"); + send({ type: "login" }); + }, // { email } + logout: () => { + store.delete("handle"); + send({ type: "logout" }); + $commonApi.broadcast("logout:success"); + chatClient.system?.server?.send("logout"); + // Send a "logout" message here to the chat server. + + // TODO: And probably the session server as well in + // the future. 24.05.23.21.27 + }, + pieces: `${(() => { + // In spider mode, always use main aesthetic.computer domain + if (typeof window !== 'undefined' && window.acSPIDER) { + return 'https://aesthetic.computer'; + } + const { protocol, hostname } = getSafeUrlParts(); + return `${protocol}//${hostname}`; + })()}/aesthetic.computer/disks`, + parse, // Parse a piece slug. + // lan: // Set dynamically. + // host: // Set dynamically. + // loadFailureText: // Set dynamically. + // Make a user authorized / signed request to the api. + // Used both in `motd` and `handle`. + requestDocs: async () => { + if (typeof docs === "object") return Promise.resolve(docs); + return fetch("/docs.json") + .then((response) => { + if (response.status !== 200) { + throw new Error("Network failure: " + response.status); + } + return response.json(); + }) + .then((d) => { + docs = d; + return docs; + }) + .catch((err) => console.error("🔴 📚 Couldn't get docs:", err)); + }, + // Get the authorization token for the current user (for streaming/custom requests) + getToken: async () => { + try { + return await $commonApi.authorize(); + } catch (error) { + // Only log actual errors, not null (which just means not logged in) + if (error) console.warn("🔑 getToken:", error); + return null; + } + }, + userRequest: async (method, endpoint, body) => { + try { + const token = await $commonApi.authorize(); // Get user token. + if (!token) throw new Error("🧖 Not logged in."); + + const headers = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }; + + const options = { method, headers }; + if (body) options.body = JSON.stringify(body); + const response = await fetch(endpoint, options); + + if (response.status === 500) { + try { + const json = await response.json(); + return { status: response.status, ...json }; + } catch (e) { + return { status: response.status, message: response.statusText }; + } + } else { + const clonedResponse = response.clone(); + try { + return { + ...(await clonedResponse.json()), + status: response.status, + }; + } catch { + return { status: response.status, body: await response.text() }; + } + } + } catch (error) { + console.error("🚫 Error:", error); + return { message: "unauthorized" }; + } + }, + // Loosely connect the UDP receiver. + udp: (receive) => { + udpReceive = receive; + return udp; + }, + hiccup: (hiccupIn = 5, outageSeconds = 5) => { + console.log("😵 Hiccuping in:", hiccupIn, "seconds."); + clearTimeout(hiccupTimeout); + hiccupTimeout = setTimeout(() => { + console.log("😶‍🌫️ Hiccup!"); + chatClient.system?.server.kill(outageSeconds); // Disconnect from chat. + socket?.kill(outageSeconds); // Diconnect from socket session. + udp?.kill(outageSeconds); // Disconnect from UDP. + }, hiccupIn * 1000); + }, + // Remote debugging: Send log messages to session server for debugging on any device + log: function(levelOrFilename, ...args) { + // Handle both old API (filename, content) and new API (level, ...args) + if (levelOrFilename.startsWith('/tmp/') || levelOrFilename.includes('.log')) { + // Old API: filename, content + const filename = levelOrFilename; + const content = args[0]; + + // Send to session server if socket is available (removed verbose console log) + if (socket && socket.send) { + socket.send("dev-log", { + level: "INFO", + message: `${filename}: ${content}`, + device: navigator.userAgent || "unknown", + timestamp: Date.now() + }); + } + } else { + // New API: level, ...args + const level = levelOrFilename; + + // Serialize objects and create single-line message + const serializedArgs = args.map(arg => { + if (typeof arg === 'object' && arg !== null) { + try { + return JSON.stringify(arg); + } catch (e) { + return String(arg); + } + } + return String(arg); + }); + + const message = serializedArgs.join(" "); + + // Always log locally first + if (level === "warn") { + console.warn(...args); + } else if (level === "error") { + console.error(...args); + } else { + console.log(...args); + } + + // Send to session server if socket is available + if (socket && socket.send) { + socket.send("dev-log", { + level: level.toUpperCase(), + message: message, + device: navigator.userAgent || "unknown", + timestamp: Date.now() + }); + } + } + }, + }, + needsPaint: () => { + noPaint = false; + if (system === "nopaint") { + $commonApi.system.nopaint.needsPresent = true; + } + }, // TODO: Does "paint" needs this? + + store, + pieceCount: -1, // Incs to 0 when the first piece (usually the prompt) loads. + // Increments by 1 each time a new piece loads. + debug, + nopaintPerf, +}; + +// Convenience methods for different log levels +$commonApi.net.log.info = (...args) => $commonApi.net.log("info", ...args); +$commonApi.net.log.warn = (...args) => $commonApi.net.log("warn", ...args); +$commonApi.net.log.error = (...args) => $commonApi.net.log("error", ...args); + +// 🎬 Recording UI overlays - UI elements drawn on screen but NOT captured in tape recordings +// These are stored per-frame and cleared after sending to the main thread +let pendingRecordingUIOverlays = {}; + +// Add recording UI API to $commonApi +$commonApi.recordingUI = { + // Add an overlay that will be visible on screen but not recorded in tapes + // name: unique identifier for the overlay + // painting: a painting buffer created with $api.painting() + // x, y: position to draw the overlay + // opacity: optional opacity (0-1) + add: function(name, painting, x, y, opacity = 1) { + if (!painting || !painting.pixels) return; + pendingRecordingUIOverlays[name] = { + x: x, + y: y, + opacity: opacity, + img: { + width: painting.width, + height: painting.height, + pixels: painting.pixels, + }, + }; + }, + // Clear all recording UI overlays + clear: function() { + pendingRecordingUIOverlays = {}; + }, + // Get pending overlays (used internally when sending frame data) + _getPending: function() { + return pendingRecordingUIOverlays; + }, + // Clear pending overlays (used internally after sending) + _clearPending: function() { + pendingRecordingUIOverlays = {}; + }, +}; + +chatClient.$commonApi = $commonApi; // Add a reference to the `Chat` module. + +const nopaintAPI = $commonApi.system.nopaint; + +// 🎨 Start painting change monitoring for cross-tab sync +startPaintingChangeMonitoring(); + +// Broadcast to other tabs in the same origin. +const channel = new BroadcastChannel("aesthetic.computer"); + +channel.onmessage = (event) => { + processMessage(event.data); +}; + +async function processMessage(msg) { + if (logs.messaging) console.log(`🗼 Processing broadcast: ${msg}`); + + // 🎨 Handle painting updates (both new JSON format and legacy string format) + if (msg.startsWith("painting:") || (msg.startsWith("{") && msg.includes('"type":"painting:updated"'))) { + const isNopaintActive = $commonApi.system?.nopaint?.is?.("painting"); + if (isNopaintActive) { + console.log(`🚫 CROSS-TAB INTERFERENCE: Receiving painting update during nopaint operation - this may disrupt live preview!`, { + msgPreview: msg.substring(0, 100) + '...', + nopaintState: 'painting' + }); + } + await handlePaintingUpdate(msg); + return; + } + + if (msg.startsWith("handle:updated")) { + // 👰‍♀️ Update the user handle if it changed. + const newHandle = msg.split(":").pop(); + HANDLE = "@" + newHandle; + if (typeof window !== 'undefined') window.acHANDLE = HANDLE; // Expose for UDP identity + if (typeof window !== 'undefined' && window.acBootCanvas?.setHandle) window.acBootCanvas.setHandle(HANDLE); + send({ type: "handle", content: HANDLE }); + store["handle:received"] = true; + store["handle"] = newHandle; + // store.persist("handle"); + return; + } + + // Refresh the window if we logged in or out from another tab. + if (msg === "login:success" && !USER) { + $commonApi.net.refresh(); + return; + } + + if (msg === "logout:success" && USER) { + $commonApi.net.refresh(); // 🗒️ This should always be fast enough? + return; + } +} + +// 🎨 Handle painting update messages from other tabs +async function handlePaintingUpdate(msg) { + try { + // Parse the JSON message + const data = JSON.parse(msg); + + if (data.type !== "painting:updated") return; + + // Skip if this message is from the current tab + if (data.tabId === $commonApi._tabId) { + console.log(`🎨 SKIPPED: Own message (${data.action})`); + return; + } + + if (!$commonApi.system) { + console.log(`🎨 SKIPPED: No system available`); + return; + } + + // 📐 Handle resize events specifically + if ((data.action === "resized" && data.metadata?.source === "screen_resize") || + (data.action === "updated" && (data.source === "resize" || data.source === "crop"))) { + const width = data.width || data.metadata?.width; + const height = data.height || data.metadata?.height; + console.log(`📐 DIMENSION CHANGE EVENT: ${width}x${height} (source: ${data.source || data.metadata?.source})`); + + // For dimension change events, we need to wait for a "storage_complete" event + $commonApi._processingResize = true; + $commonApi._awaitingResizeStorage = { width, height, timestamp: data.timestamp }; + + console.log(`📐 Waiting for storage completion for ${data.source} ${width}x${height}...`); + return; // Don't continue with regular painting sync for dimension change events + } + + // 💾 Handle storage completion events + if (data.action === "storage_complete" && (data.source === "resize" || data.source === "crop")) { + const awaitingResize = $commonApi._awaitingResizeStorage; + console.log(`🪝 RECEIVED storage_complete:`, { + source: data.source, + timestamp: data.timestamp, + awaitingResize: awaitingResize, + timestampMatch: awaitingResize && data.timestamp === awaitingResize.timestamp + }); + + if (awaitingResize && data.timestamp === awaitingResize.timestamp) { + console.log(`📐 Storage completed for ${data.source} ${awaitingResize.width}x${awaitingResize.height}, applying...`); + + try { + const storedPainting = await store.retrieve("painting", "local:db"); + if (storedPainting) { + // Update system painting with the stored version + $commonApi.system.painting = { + width: storedPainting.width, + height: storedPainting.height, + pixels: new Uint8ClampedArray(storedPainting.pixels) + }; + + // Store the painting data + store["painting"] = { + width: storedPainting.width, + height: storedPainting.height, + pixels: storedPainting.pixels + }; + + // Update painting hash + lastPaintingHash = generatePaintingHash($commonApi.system.painting); + + // Update KidLisp with new painting reference + const kidlispInstance = getGlobalKidLisp(); + if (kidlispInstance) { + kidlispInstance.setAPI($commonApi); + console.log(`🎯 KidLisp API refreshed with ${data.source} painting: ${storedPainting.width}x${storedPainting.height}`); + } + + // Force repaint with new dimensions + if ($commonApi.system.nopaint) { + $commonApi.system.nopaint.needsPresent = true; + } + $commonApi.needsPaint(); + + console.log(`📐 Screen updated after ${data.source}: ${storedPainting.width}x${storedPainting.height}`); + } + } catch (error) { + console.error(`📐 ERROR applying ${data.source} after storage completion:`, error); + } + + // Clean up + delete $commonApi._awaitingResizeStorage; + $commonApi._processingResize = false; + return; + } + } + + // Set processing flag to prevent feedback loops + $commonApi._processingBroadcast = true; + + try { + // Load painting from storage (lightweight messages don't contain pixel data) + const storedPainting = await store.retrieve("painting", "local:db"); + if (storedPainting) { + $commonApi.system.painting = { + width: storedPainting.width, + height: storedPainting.height, + pixels: new Uint8ClampedArray(storedPainting.pixels) + }; + + // Store only the pixel data, not the full painting object + store["painting"] = { + width: storedPainting.width, + height: storedPainting.height, + pixels: storedPainting.pixels + }; + + // Update painting hash to prevent false positives + lastPaintingHash = generatePaintingHash($commonApi.system.painting); + + // 🎯 Update KidLisp with the new painting reference + const kidlispInstance = getGlobalKidLisp(); + if (kidlispInstance) { + kidlispInstance.setAPI($commonApi); + console.log(`🎯 KidLisp API refreshed with updated painting`); + } + + // Force repaint + if ($commonApi.system.nopaint) { + $commonApi.system.nopaint.needsPresent = true; + } + $commonApi.needsPaint(); + + // 🆕 Force nopan (reset transform) for clear/new actions or explicit flag + if (((data.action === "cleared" || data.action === "new") || data.resetTransform) && $commonApi.system.nopaint) { + console.log(`🎯 ${data.action?.toUpperCase() || 'TRANSFORM_RESET'}: Forcing nopan (reset transform) on receiver`); + $commonApi.system.nopaint.resetTransform({ + system: $commonApi.system, + screen: $commonApi.screen + }); + } + + console.log(`🎨 PAINTING SYNCED: ${storedPainting.width}x${storedPainting.height}`, { + hash: generatePaintingHash($commonApi.system.painting)?.substr(0, 8), + fromHash: data.hash || 'unknown', + firstPixels: Array.from($commonApi.system.painting.pixels.slice(0, 12)) // First 3 pixels (RGBA) + }); + } else { + // No painting found in storage + } + } catch (storageError) { + console.error(`🎨 ERROR loading from storage:`, storageError); + } + + } catch (error) { + console.error(`🎨 ERROR handling painting update:`, error); + + // Fallback to old string-based format + if (typeof msg === "string" && msg.startsWith("painting:")) { + const action = msg.split(":")[1]; + console.log(`🎨 FALLBACK: Processing legacy format ${action}`); + + // Legacy handling - load from storage + try { + const storedPainting = await store.retrieve("painting", "local:db"); + if (storedPainting) { + $commonApi.system.painting = { + width: storedPainting.width, + height: storedPainting.height, + pixels: new Uint8ClampedArray(storedPainting.pixels) + }; + + // Store only the pixel data, not the full painting object + store["painting"] = { + width: storedPainting.width, + height: storedPainting.height, + pixels: storedPainting.pixels + }; + + const kidlispInstance = getGlobalKidLisp(); + if (kidlispInstance) { + kidlispInstance.setAPI($commonApi); + } + + if ($commonApi.system.nopaint) { + $commonApi.system.nopaint.needsPresent = true; + } + $commonApi.needsPaint(); + } + } catch (storageError) { + console.error(`🎨 ERROR in fallback storage load:`, storageError); + } + } + } finally { + // Clear processing flag regardless of success or error + setTimeout(() => { + $commonApi._processingBroadcast = false; + }, 50); + } +} + +$commonApi.broadcast = (msg) => { + processMessage(msg); // Process locally. + channel.postMessage(msg); +}; + +// 🎨 Broadcast painting updates to other tabs +$commonApi.broadcastPaintingUpdate = (action, data = {}) => { + if (!$commonApi.system?.painting) return; + + // Check if we're in a nopaint operation and log it + const isNopaintActive = $commonApi.system?.nopaint?.is?.("painting"); + if (isNopaintActive) { + console.log(`🚫 NOPAINT INTERFERENCE: Attempting to broadcast "${action}" during nopaint operation - this may disrupt live preview!`, { + action, + source: data.source, + nopaintState: 'painting' + }); + } + + // Throttle broadcasts to prevent excessive messages + const now = Date.now(); + if (now - lastBroadcastTime < broadcastThrottleDelay) { + return; + } + lastBroadcastTime = now; + + // Generate unique tab ID to prevent self-processing + if (!$commonApi._tabId) { + $commonApi._tabId = Math.random().toString(36).substr(2, 9); + } + + // Create lightweight notification message (no pixel data) + const message = { + type: "painting:updated", + action, + tabId: $commonApi._tabId, + timestamp: now, + width: $commonApi.system.painting.width, + height: $commonApi.system.painting.height, + ...data + }; + + // Only send to other tabs (exclude self) + channel.postMessage(JSON.stringify(message)); +}; + +// 🚀 Immediate painting broadcast - bypasses throttling for instant sync +$commonApi.broadcastPaintingUpdateImmediate = (action, data = {}) => { + if (!$commonApi.system?.painting || $commonApi._processingBroadcast) return; + + // Check for nopaint interference + const isNopaintActive = $commonApi.system?.nopaint?.is?.("painting"); + if (isNopaintActive) { + console.log(`🚫 IMMEDIATE NOPAINT INTERFERENCE: Attempting immediate broadcast "${action}" during nopaint operation!`, { + action, + source: data.source, + nopaintState: 'painting' + }); + } + + // Performance: Skip if we just broadcasted very recently (debounce) + const now = Date.now(); + if (now - (lastBroadcastTime || 0) < 16) { // ~60fps max broadcast rate + return; + } + + // Generate unique tab ID if needed + if (!$commonApi._tabId) { + $commonApi._tabId = Math.random().toString(36).substr(2, 9); + } + + const paintingHash = generatePaintingHash($commonApi.system.painting); + const message = { + type: "painting:updated", + action, + tabId: $commonApi._tabId, + timestamp: now, + width: $commonApi.system.painting.width, + height: $commonApi.system.painting.height, + hash: paintingHash?.substr(0, 8), + immediate: true, + ...data + }; + + // Immediate broadcasting silently + + // Store painting immediately (async to not block) + setTimeout(() => { + store["painting"] = { + width: $commonApi.system.painting.width, + height: $commonApi.system.painting.height, + pixels: $commonApi.system.painting.pixels, + }; + store.persist("painting", "local:db"); + + // 🪝 Fire storage completion hook for resize and crop events + if (data.source === "resize" || data.source === "crop") { + const storageCompleteMessage = { + type: "painting:updated", + action: "storage_complete", + source: data.source, + tabId: message.tabId, + timestamp: message.timestamp, + width: $commonApi.system.painting.width, + height: $commonApi.system.painting.height + }; + + channel.postMessage(JSON.stringify(storageCompleteMessage)); + console.log(`🪝 STORAGE COMPLETE hook fired for ${data.source} ${$commonApi.system.painting.width}x${$commonApi.system.painting.height}`, { + timestamp: message.timestamp, + tabId: message.tabId.substr(0, 4) + '...' + }); + } + + }, 0); + + // Update hash to prevent duplicate detection + lastPaintingHash = paintingHash; + lastBroadcastTime = now; + + channel.postMessage(JSON.stringify(message)); +}; + +// 🎨 Painting change detection system +function generatePaintingHash(painting) { + if (!painting?.pixels) return null; + + // Simple hash using width, height, and sample of pixels + let hash = painting.width * 31 + painting.height * 37; + + // Sample every 100th pixel to create a lightweight hash + for (let i = 0; i < painting.pixels.length; i += 100) { + hash = ((hash << 5) - hash + painting.pixels[i]) & 0xffffffff; + } + + return hash.toString(36); +} + +function startPaintingChangeMonitoring() { + if (paintingChangeCheckInterval) return; // Already monitoring + + // Painting change monitoring initialized silently + + // Initialize hash baseline if painting exists + if ($commonApi.system?.painting) { + lastPaintingHash = generatePaintingHash($commonApi.system.painting); + } + + // Enable frame-based monitoring for immediate detection + enableFrameBasedMonitoring(); + + // Keep interval-based monitoring as backup (lower frequency) + paintingChangeCheckInterval = setInterval(() => { + if (!$commonApi.system?.painting || frameBasedMonitoring) return; // Skip if frame monitoring is active + + const currentHash = generatePaintingHash($commonApi.system.painting); + + // Only broadcast if hash actually changed AND we're not currently processing a broadcast + if (lastPaintingHash !== null && + currentHash !== lastPaintingHash && + !$commonApi._processingBroadcast) { + + // Set flag to prevent feedback loops during broadcast processing + $commonApi._processingBroadcast = true; + + // Store updated painting + store["painting"] = { + width: $commonApi.system.painting.width, + height: $commonApi.system.painting.height, + pixels: $commonApi.system.painting.pixels, + }; + store.persist("painting", "local:db"); + + // Broadcast the change + $commonApi.broadcastPaintingUpdate("updated", { + source: "interval_monitor", + hash: currentHash.substr(0,8) + }); + + // Clear the processing flag after a brief delay + setTimeout(() => { + $commonApi._processingBroadcast = false; + }, 100); + } + + lastPaintingHash = currentHash; + }, 1000); // Slower backup monitoring (1 second) +} + +function stopPaintingChangeMonitoring() { + frameBasedMonitoring = false; // Stop frame-based monitoring + if (paintingChangeCheckInterval) { + clearInterval(paintingChangeCheckInterval); + paintingChangeCheckInterval = null; + } +} + +// Spawn a session backend for a piece. +async function session(slug, forceProduction = false, service) { + let endPoint = "/session/" + slug; + const params = { service }; + if (forceProduction) params.forceProduction = 1; + endPoint += "?" + new URLSearchParams(params); + + const req = await fetch(endPoint); + + let session; + if (req.status === 200 || req.status === 304) { + session = await req.text().then((text) => { + try { + return JSON.parse(text); + } catch (e) { + return text; + } + }); + } else { + session = await req.text(); + } + + if (typeof session === "string") return session; + + // Session server is always ready (no Jamsocket polling needed) + // Just return the session object with URLs + return session; +} + +// Just for "update". +const $updateApi = {}; + +// 🖼 Painting + +// Pre-fab models: +const QUAD = { + type: "quad", + positions: [ + // Triangle 1 (Left Side) + [-1, -1, 0, 1], // Bottom Left + [-1, 1, 0, 1], // Top Left + [1, 1, 0, 1], // Top Right + // Triangle 2 (Right Side) + [-1, -1, 0, 1], // Bottom Left + [1, -1, 0, 1], // Bottom Right + [1, 1, 0, 1], // Top Right + ], + indices: [ + // These are not re-used for now. + // One + 0, 1, 2, + //Two + 3, 4, 5, + ], +}; + +// A cube of lines. +const CUBEL = { + type: "line", + positions: [ + // Back + [-0.5, -0.5, 0.5, 1], // Down + [-0.5, 0.5, 0.5, 1], + + [-0.5, 0.5, 0.5, 1], // Across + [0.5, 0.5, 0.5, 1], + + [0.5, 0.5, 0.5, 1], // Up + [0.5, -0.5, 0.5, 1], + + [0.5, -0.5, 0.5, 1], // Back + [-0.5, -0.5, 0.5, 1], + // Front + [-0.5, -0.5, -0.5, 1], // Down + [-0.5, 0.5, -0.5, 1], + + [-0.5, 0.5, -0.5, 1], // Across + [0.5, 0.5, -0.5, 1], + + [0.5, 0.5, -0.5, 1], // Up + [0.5, -0.5, -0.5, 1], + + [0.5, -0.5, -0.5, 1], // Back + [-0.5, -0.5, -0.5, 1], + // Bars (back to front) + [-0.5, -0.5, 0.5, 1], // Top Left + [-0.5, -0.5, -0.5, 1], + + [-0.5, 0.5, 0.5, 1], // Bottom Left + [-0.5, 0.5, -0.5, 1], + + [0.5, 0.5, 0.5, 1], // Up + [0.5, 0.5, -0.5, 1], + + [0.5, -0.5, 0.5, 1], // Back + [0.5, -0.5, -0.5, 1], + ], +}; + +const ORIGIN = { + type: "line", + positions: [ + [-0.5, 0, 0, 1], // Horizontal X + [0.5, 0, 0, 1], + [0, 0, -0.5, 1], // Horizontal Z + [0, 0, 2, 1], + [0, -0.5, 0, 1], // Vertical + [0, 0.5, 0, 1], + ], + colors: [ + [255, 0, 0, 255], + [255, 0, 0, 255], + [0, 255, 0, 255], + [0, 255, 0, 255], + [0, 0, 255, 255], + [0, 0, 255, 255], + ], +}; + +const TRI = { + type: "triangle", + positions: [ + [-1, -1, 0, 1], // Bottom Left + [0, 1, 0, 1], // Top Left + [1, -1, 0, 1], // Top Right + // Triangle 2 (Right Side) + ], + indices: [0, 1, 2], +}; + +const LINE = { + type: "line", + positions: [ + [0, 0, 0, 1], // Bottom + [0, 1, 0, 1], // Top + ], + indices: [0, 1], +}; + +// Inputs: (r, g, b), (r, g, b, a) or an array of those. +// (rgb) for grayscale or (rgb, a) for grayscale with alpha. +// Or hex with "#000000" or "0x000000" or 0x000000. +// TODO: Add `erase` anc all css color alpha support. 23.07.20.14.45 +// TODO: Add transparency and short hex to hex support. +// TODO: Add better hex support via: https://stackoverflow.com/a/53936623/8146077 + +function ink() { + const foundColor = graph.findColor(...arguments); + if (inkFloodLoggingEnabled()) { + console.log( + `${inkFloodLogPrefix()}🖍️ INK DEBUG`, + { + args: cloneArgsForLog(arguments), + resolved: cloneColorForLog(foundColor) + } + ); + } + const result = graph.color(...foundColor); + if (inkFloodLoggingEnabled()) { + console.log( + `${inkFloodLogPrefix()}🖍️ INK APPLIED`, + { + color: cloneColorForLog(result) + } + ); + } + return result; +} + +function ink2() { + if (arguments[0] === null) { + return graph.color2(null); + } else { + return graph.color2(...graph.findColor(...arguments)); + } +} + +// 🎯 Global KidLisp Instance Management +function initializeGlobalKidLisp(api) { + if (!globalKidLispInstance) { + // console.log("🚀 Initializing global KidLisp instance"); + if (typeof lisp.ensureGlobalInstance === "function") { + globalKidLispInstance = lisp.ensureGlobalInstance(); + } else if (typeof lisp.getGlobalInstance === "function") { + globalKidLispInstance = lisp.getGlobalInstance() || new lisp.KidLisp(); + } else { + globalKidLispInstance = new lisp.KidLisp(); + } + globalKidLispInstance.setAPI(api); + // Expose to window for bios.mjs acCLEAR_BAKE_LAYERS + if (typeof window !== 'undefined') { + window.__acGlobalKidLispInstance = globalKidLispInstance; + } + // Enable performance HUD if PERF_MODE is active + if (PERF_MODE) { + globalKidLispInstance.startPerformanceMonitoring(); + } + // Enable auto-density scaling if AUTO_SCALE_MODE is active + if (AUTO_SCALE_MODE) { + globalKidLispInstance.enableAutoDensity(); + } + } + return globalKidLispInstance; +} + +function getGlobalKidLisp() { + return globalKidLispInstance; +} + +// 🎵 Update KidLisp audio globals (safe for worker contexts) +function updateKidLispAudio(audioData) { + const payload = + audioData && typeof audioData === "object" + ? { ...audioData, __source: audioData.__source || "embedded:paintApi" } + : audioData; + if (globalKidLispInstance && globalKidLispInstance.updateAudioGlobals) { + globalKidLispInstance.updateAudioGlobals(payload); + } else if (payload?.amp > 0) { + console.warn("🔊 updateKidLispAudio: no instance!", !!globalKidLispInstance, !!globalKidLispInstance?.updateAudioGlobals); + } +} + +// 🔎 PAINTAPI +const $paintApi = { + // 1. Composite functions (that use $activePaintApi) + // (Must be unwrapped) + // Prints a line of text using the default / current global font. + // Argument options: + // text, pos: {x, y, center}, bg (optional) + + // Parameters: + // text, x, y, options, wordWrap, customTypeface + // text, pos, bg, bounds, wordWrap = true, customTypeface + write: function () { + let text = arguments[0], + pos, + bg, + bounds, + wordWrap = true, + customTypeface = null, + rotation = 0; + + if (text === undefined || text === null || text === "" || !tf) + return $activePaintApi; // Fail silently if no text. + + text = + typeof text === "object" && text !== null + ? JSON.stringify(text) + : text.toString(); + + // Assume: text, x, y, options, wordWrap, customTypeface + if (typeof arguments[1] === "number") { + pos = { x: arguments[1], y: arguments[2] }; + const options = arguments[3]; + bg = options?.bg; + bounds = options?.bounds; + wordWrap = options?.wordWrap === undefined ? wordWrap : options.wordWrap; + customTypeface = options?.typeface; + rotation = options?.rotation ?? options?.angle ?? 0; + } else { + pos = arguments[1]; + bg = arguments[2]; + bounds = arguments[3]; + wordWrap = arguments[4] === undefined ? wordWrap : arguments[4]; + customTypeface = arguments[5]; + // Extract rotation from pos object if it exists + rotation = pos?.rotation ?? pos?.angle ?? 0; + } + + if (customTypeface) { + const resolvedTypeface = resolveTypefaceInstance(customTypeface); + if (resolvedTypeface) { + ensureTypefaceLoaded(resolvedTypeface); + customTypeface = resolvedTypeface; + } + } + + // 🎨 Color code processing + const hasColorCodes = textContainsColorCodes(text); + + if (hasColorCodes) { + + + // Remember the current ink color to restore it later + const originalColor = $activePaintApi.inkrn(); + + // Process color codes into per-character color array + let cleanText = ""; + let charColors = []; + let currentColor = null; + + // Split text by color codes and process each segment + COLOR_CODE_MATCH_REGEX.lastIndex = 0; + const segments = text.split(COLOR_CODE_MATCH_REGEX); + + for (let i = 0; i < segments.length; i++) { + if (i % 2 === 0) { + // This is regular text + const segment = segments[i]; + for (let j = 0; j < segment.length; j++) { + cleanText += segment[j]; + charColors.push(currentColor); + + + } + } else { + // This is a color name or RGB value (from the captured group) + const colorStr = segments[i]; + + if (!colorStr) { + continue; + } + + const normalized = colorStr.trim(); + const lower = normalized.toLowerCase(); + + if (!normalized) { + continue; + } + + if (lower === "reset" || lower === "default" || lower === "base") { + currentColor = null; + continue; + } + + if (normalized.includes(",")) { + // RGB/RGBA format like "255,0,0" or "255,0,0,128" + const parts = normalized.split(",").map((n) => { + const parsed = parseInt(n.trim(), 10); + return Number.isFinite(parsed) ? parsed : 0; + }); + while (parts.length < 3) parts.push(0); + if (parts.length === 3) parts.push(255); + currentColor = parts.slice(0, 4); + } else if (lower === "transparent" || lower === "clear") { + currentColor = [0, 0, 0, 0]; + } else { + const resolved = graph.findColor(normalized); + if (Array.isArray(resolved)) { + currentColor = resolved.slice(); + } else if (resolved && typeof resolved === "object") { + currentColor = { ...resolved }; + } else if (resolved !== undefined) { + currentColor = resolved; + } else { + currentColor = null; + } + } + + + } + } + + COLOR_CODE_MATCH_REGEX.lastIndex = 0; + + // Check if we have any actual text to display after removing color codes + if (cleanText.trim().length === 0) { + return $activePaintApi; // Exit silently if no text content remains + } + + + + // Render with colors - same logic as original text processing + const scale = pos?.size || 1; + + if (bounds) { + const tb = $commonApi.text.box(cleanText, pos, bounds, scale, wordWrap, customTypeface); + if (!tb || !tb.lines) { + return $activePaintApi; // Exit silently if text.box fails + } + + const charMap = tb.charMap || []; + + tb.lines.forEach((lineText, index) => { + const renderedLine = typeof lineText === "string" ? lineText : lineText?.join?.(" ") || ""; + const sourceIndices = charMap[index] || []; + const lineColors = []; + + for (let i = 0; i < renderedLine.length; i++) { + const sourceIndex = sourceIndices[i]; + if ( + typeof sourceIndex === "number" && + sourceIndex >= 0 && + sourceIndex < charColors.length + ) { + lineColors.push(charColors[sourceIndex]); + } else { + lineColors.push(null); + } + } + + // Add rotation to tb.pos + const posWithRotation = { ...tb.pos, rotation }; + + (customTypeface || tf)?.print( + $activePaintApi, + posWithRotation, + index, + renderedLine, + bg, + lineColors, + ); + }); + } else { + // Break on `\n` and handle separate lines + if (cleanText.indexOf("\n") !== -1) { + const lines = cleanText.split("\n"); + const lineHeightGap = 2; + let charIndex = 0; + + lines.forEach((line, index) => { + const lineColors = charColors?.slice( + charIndex, + charIndex + line.length, + ); + (customTypeface || tf)?.print( + $activePaintApi, + { + x: pos?.x, + y: pos + ? pos.y + index * (customTypeface || tf).blockHeight + lineHeightGap + : undefined, + rotation, + }, + 0, + line, + bg, + lineColors, + ); + charIndex += line.length + 1; // +1 for the newline character + }); + } else { + // Add rotation to pos + const posWithRotation = { ...pos, rotation }; + (customTypeface || tf)?.print($activePaintApi, posWithRotation, 0, cleanText, bg, charColors); + } + } + + const typefaceName = typeof customTypeface === "string" ? customTypeface : customTypeface?.name; + if (typefaceName === "MatrixChunky8" || typefaceName === "unifont") { + const hasColorAssignments = charColors?.some((color) => { + if (!color) return false; + if (Array.isArray(color)) { + return color.some((component) => component !== null && component !== undefined); + } + if (typeof color === "object") { + return Object.keys(color).length > 0; + } + return typeof color === "string"; + }); + + if (!hasColorAssignments) { + const snippet = cleanText.length > 120 ? `${cleanText.slice(0, 117)}…` : cleanText; + if (lastMatrixChunkyWriteDiagnosticLog !== snippet) { + lastMatrixChunkyWriteDiagnosticLog = snippet; + console.warn("🎨 MatrixChunky8 HUD charColors missing", { + snippet, + charColorsLength: charColors?.length, + cleanTextLength: cleanText.length, + }); + } + } + } + + // Restore the original ink color + $activePaintApi.ink(...originalColor); + return $activePaintApi; + } + + // 🎁 Original code for text without color codes + // See if the text length is greater than the bounds, and if it is then + // print on a new line. + const scale = pos?.size || 1; + + if (bounds) { + const tb = $commonApi.text.box(text, pos, bounds, scale, wordWrap, customTypeface); // TODO: Get the current ink color, memoize it, and make it static here. + // 23.10.12.22.04 + tb.lines.forEach((lineText, index) => { + const renderedLine = typeof lineText === "string" ? lineText : lineText?.join?.(" ") || ""; + // Add rotation to tb.pos + const posWithRotation = { ...tb.pos, rotation }; + (customTypeface || tf)?.print($activePaintApi, posWithRotation, index, renderedLine, bg); + }); + } else { + // Break on `\n` and handle separate lines + if (text.indexOf("\n") !== -1) { + const lines = text.split("\n"); // Split text on new line characters + const lineHeightGap = 2; + lines.forEach((line, index) => { + (customTypeface || tf)?.print( + $activePaintApi, + { + x: pos?.x, + y: pos + ? pos.y + index * (customTypeface || tf).blockHeight + lineHeightGap + : undefined, + rotation, + }, + 0, + line, + bg, + ); + // Adjust `lineHeight` as needed based on your text spacing + }); + } else { + //if (text === "POW") console.log($activePaintApi.screen); 24.12.10.07.26 - Get write working with deferred rendering and page. + + const actualFont = customTypeface || tf; + // Add rotation to pos + const posWithRotation = { ...pos, rotation }; + actualFont?.print($activePaintApi, posWithRotation, 0, text, bg); // Or print a single line. + } + } + + return $activePaintApi; + }, + // 2. Image Utilities + clonePixels: graph.cloneBuffer, + colorsMatch: graph.colorsMatch, + color: graph.findColor, + resize: graph.resize, + // 3. 3D Classes & Objects + Camera: graph.Camera, + Form: graph.Form, + Dolly: graph.Dolly, + sign: graph.sign, + cacheSignGlyph: graph.cacheSignGlyph, + // Helper to get glyphs from a typeface for use with sign() + glyphs: (fontName = "MatrixChunky8") => { + const typeface = typefaceCache.get(fontName); + return typeface?.glyphs || {}; + }, + TRI, + QUAD, + LINE, + CUBEL, + ORIGIN, + // Project text glyphs onto a 3D plane, drawing each character's line/point + // commands through a projection function. + // Usage: ink(r,g,b,a).write3D("text", { origin, right, down, project, typeface }, scale) + // origin: [x,y,z] — top-left of the first character in world space + // right: [x,y,z] — unit-ish direction for text flow (gets scaled by glyph size) + // down: [x,y,z] — unit-ish direction for descending lines + // project: ([x,y,z]) => [screenX, screenY] — world-to-screen projection + // typeface: optional typeface name (e.g. "MatrixChunky8") — defaults to active tf + // scale: multiplier for glyph coordinate spacing (default 1) + write3D: function (text, plane, scale = 1) { + if (!text || !plane) return $activePaintApi; + const { origin, right, down, project } = plane; + if (!origin || !right || !down || !project) return $activePaintApi; + + // Resolve typeface: use specified one from cache, or fall back to active tf + let face = tf; + if (plane.typeface && typefaceCache.has(plane.typeface)) { + face = typefaceCache.get(plane.typeface); + } + if (!face) return $activePaintApi; + + const blockW = face.blockWidth || face.data?.glyphWidth || 6; + const fontData = face.data || {}; + const advances = fontData.advances || {}; + let cursorX = 0; + + for (let ci = 0; ci < text.length; ci++) { + const char = text[ci]; + const glyph = face.getGlyph?.(char) || face.glyphs?.[char]; + const advance = advances[char] || face.getAdvance?.(char) || blockW; + + if (glyph) { + // Apply glyph offset (baselineOffset preferred, then offset) + let offX = 0, offY = 0; + if (glyph.baselineOffset) { + offX = glyph.baselineOffset[0] * scale; + offY = glyph.baselineOffset[1] * scale; + } else if (glyph.offset) { + offX = glyph.offset[0] * scale; + offY = glyph.offset[1] * scale; + } + + // Compute projected pixel size once for this glyph + // by measuring the screen distance of one glyph-pixel step + const projectPt = (gx, gy) => { + const px = cursorX + (gx * scale + offX); + const py = gy * scale + offY; + const wx = origin[0] + px * right[0] + py * down[0]; + const wy = origin[1] + px * right[1] + py * down[1]; + const wz = origin[2] + px * right[2] + py * down[2]; + return project([wx, wy, wz]); + }; + + // Measure projected pixel size (distance for 1 glyph-pixel in each axis) + const [refX, refY] = projectPt(0, 0); + const [refRX, refRY] = projectPt(1, 0); + const [refDX, refDY] = projectPt(0, 1); + const pxW = Math.sqrt((refRX - refX) ** 2 + (refRY - refY) ** 2) || 1; + const pxH = Math.sqrt((refDX - refX) ** 2 + (refDY - refY) ** 2) || 1; + const pxSize = Math.max(1, Math.round((pxW + pxH) / 2)); + + // Optional per-pixel callback (lets caller vary ink per pixel) + const pixelCb = plane.pixelCallback; + + // Render each "on" pixel as a box at its projected center + const renderPixel = (gx, gy) => { + const [sx, sy] = projectPt(gx + 0.5, gy + 0.5); + const bx = Math.floor(sx - pxSize / 2); + const by = Math.floor(sy - pxSize / 2); + if (pixelCb) pixelCb(bx, by, gx, gy, ci); + $activePaintApi.box(bx, by, pxSize, pxSize); + }; + + // BDF pixel-based glyphs (MatrixChunky8, etc.) + if (glyph.pixels) { + for (let row = 0; row < glyph.pixels.length; row++) { + const pixelRow = glyph.pixels[row]; + if (!Array.isArray(pixelRow)) continue; + for (let col = 0; col < pixelRow.length; col++) { + if (pixelRow[col] === 1) renderPixel(col, row); + } + } + } + + // Vector command-based glyphs (font_1, etc.) + if (glyph.commands) { + for (const { name, args } of glyph.commands) { + if (name === "point") { + renderPixel(args[0], args[1]); + } else if (name === "line") { + // Render pixel boxes at both endpoints + line between + renderPixel(args[0], args[1]); + renderPixel(args[2], args[3]); + const [s1x, s1y] = projectPt(args[0], args[1]); + const [s2x, s2y] = projectPt(args[2], args[3]); + $activePaintApi.line( + Math.floor(s1x), Math.floor(s1y), + Math.floor(s2x), Math.floor(s2y), + ); + } + } + } + } + cursorX += advance * scale; + } + return $activePaintApi; + }, +}; + +// TODO: Eventually move this to `num`. 24.07.23.18.52 +function normalizeAngle(angle) { + return ((angle % 360) + 360) % 360; +} + +let turtleAngle = 270; +let turtleDown = false; +let turtlePosition = { x: 0, y: 0 }; + +// This is where I map the API functions that anyone can use, to the internal +// code that represents them... + +// Rendering of 3D forms. + +const formsToClear = []; +let backgroundColor3D = [0, 0, 0, 255]; +let formsSent = {}; // TODO: This should be cleared more often... + +// `cpu: true` enabled software rendering +function form( + forms, + cam = $commonApi.system.fps.doll.cam, + { cpu, background } = { + cpu: true, + keep: true, + background: backgroundColor3D, + }, +) { // Exit silently if no forms are present. + if (forms == null || forms?.length === 0) return; + + if (cpu === true) { + if (formReframing) { + cam.resize(); + formReframing = false; + } + if (Array.isArray(forms)) + forms.filter(Boolean).forEach((form) => form.graph(cam)); + else forms.graph(cam); + } else { + // GPU forms. + if (!Array.isArray(forms)) forms = [forms]; + + // Clear out any forms that need deleting. + formsToClear.forEach((id) => delete formsSent[id]); + formsToClear.length = 0; + + // Build a list of forms to send, ignoring already sent ones by UID. + const formsToSend = []; + + forms.filter(Boolean).forEach((form) => { + // Clear out any trash in `formsSent` that do not have IDs left in forms. + //if (formsSent[forms.uid]) + + // A. If the form has not been sent yet... + if (formsSent[form.uid] === undefined && form.vertices.length > 0) { + // Set the form to expire automatically if keep is false. + formsToSend.push(form); + formsSent[form.uid] = form; + //console.log("Forms sent:", Object.keys(formsSent).length); + form.gpuVerticesSent = form.vertices.length; + form.gpuReset = false; + } else { + // B. If the form has been sent, but the form has changed and + // needs a partial state update or is simply being redrawn. + let msgCount = 0; + + if (form.gpuRecolored === true) { + formsToSend.push({ + update: "form:color", + uid: form.uid, + color: form.color, + }); + msgCount += 1; + form.gpuRecolored = false; + } + + // Transform the geometry. + if (form.gpuTransformed === true) { + formsToSend.push({ + update: "form:transform", + uid: form.uid, + rotation: form.rotation, + position: form.position, + scale: form.scale, + }); + form.gpuTransformed = false; + msgCount += 1; + } + + if (form.vertices.length > form.gpuVerticesSent || form.gpuReset) { + // Add vertices to buffered forms. + formsToSend.push({ + update: "form:buffered:add-vertices", + uid: form.uid, + reset: form.gpuReset, + vertices: form.vertices.slice(form.gpuVerticesSent), + length: form.vertices.length, // TODO: These aren't being used anymore / they are generated from the GPU. + pastLength: form.gpuVerticesSent, + }); + + // Update form state now that we are sending the message. + // TODO: Put these both under a "gpu" object in form. + //console.log(form.gpuReset); + form.gpuReset = false; + form.gpuVerticesSent = form.vertices.length; + msgCount += 1; + } + + if (msgCount === 0) { + // Simply tell the system we are still drawing the form... otherwise + // it will get cleared. + formsToSend.push({ + update: "form:touch", + uid: form.uid, + }); + } + } + }); + + if (formsToSend.length === 0) return; + + // console.log("Sending form...", performance.now()) + + // Only send a background update if the value changed. + if (background !== backgroundColor3D) { + send({ + type: "gpu-event", + content: { + type: "background-change", + content: background, + }, + }); + backgroundColor3D = background; + } + + send({ + type: "forms", + content: { + forms: formsToSend, + cam: { + position: cam.position, + rotation: cam.rotation, + scale: cam.scale, + fov: cam.fov, + near: cam.near, + far: cam.far, + }, + color: graph.color(), + }, + }); + + // paintFormsResolution?.(); + // return new Promise((resolve) => { + // paintFormsResolution = resolve; + // }); + } +} + +// 🔑 Code resolution cache for #code -> @handle/slug lookups +const codeCache = { + memory: new Map(), // code -> @handle/slug resolution cache + reverseMemory: new Map(), // @handle/slug -> code reverse lookup cache + + async resolve(code) { + // Return cached resolution if available + if (this.memory.has(code)) { + return this.memory.get(code); + } + + // Query painting-code API which returns slug and handle + const response = await fetch(`/api/painting-code?code=${code}`); + if (!response.ok) { + throw new Error(`Code #${code} not found`); + } + + const data = await response.json(); + + // Build the path format: @handle/slug or just slug for anonymous + let path; + if (data.handle && data.handle !== 'anon') { + path = `@${data.handle}/${data.slug}`; + } else { + path = data.slug; + } + + this.memory.set(code, path); + this.reverseMemory.set(path, code); + return path; + }, + + // Store a mapping from timestamp to code (called by profile.mjs and other pieces) + storeCode(slug, handle, code) { + if (!slug || !code) return; + + // Normalize the key format to match what we use elsewhere + const normalizedHandle = handle?.replace(/^@+/, ''); + const key = normalizedHandle ? `@${normalizedHandle}/${slug}` : slug; + + this.reverseMemory.set(key, code); + this.memory.set(code, key); + }, + + // Get code from timestamp+handle + getCode(slug, handle) { + const normalizedHandle = handle?.replace(/^@+/, ''); + const key = normalizedHandle ? `@${normalizedHandle}/${slug}` : slug; + return this.reverseMemory.get(key); + } +}; + + +// 🔑 Expose code cache debug utility +if (typeof window !== "undefined") { + window.acCodeCacheStats = () => { + console.log("🔑 Code resolution cache stats:", { + cacheSize: codeCache.memory.size, + codes: Array.from(codeCache.memory.entries()).map(([code, path]) => ({ + code: `#${code}`, + resolvedTo: path + })) + }); + }; +} + +// Used by `paste` and `stamp` to prefetch bitmaps of the network as needed. +// Occurs also when loading a piece's source code. +async function prefetchPicture(code) { + const originalCode = code; + + // Strip # prefix if present to get the actual code + let actualCode = code; + if (code.startsWith('#')) { + actualCode = code.slice(1); + } + + if (paintings[code] === "fetching") return; + + // 🖼️ Check multi-level cache first + // Level 1: Check if already in paintings cache + if (paintings[code] && paintings[code] !== "fetching") { + return; + } + + // Level 2: Check in-memory image cache + if (imageCache.has(code)) { + paintings[code] = imageCache.get(code); + return; + } + + // Level 3: Check persistent storage (IndexedDB) + if (imageCache.isCacheable(code)) { + const cachedImage = await imageCache.loadFromPersistent(code); + if (cachedImage) { + // console.log("🖼️ Loaded from cache:", code); + paintings[code] = cachedImage; + return; + } + } + + // Level 3.5: Check for embedded painting bitmaps (pack mode / bundled HTML) + const globalScope = (function() { + if (typeof globalThis !== 'undefined') return globalThis; + if (typeof self !== 'undefined') return self; + if (typeof window !== 'undefined') return window; + return {}; + })(); + + if (globalScope.acEMBEDDED_PAINTING_BITMAPS) { + // Check with various key formats + const embeddedBitmap = globalScope.acEMBEDDED_PAINTING_BITMAPS[code] || + globalScope.acEMBEDDED_PAINTING_BITMAPS['#' + actualCode] || + globalScope.acEMBEDDED_PAINTING_BITMAPS[actualCode]; + if (embeddedBitmap) { + console.log("🖼️ Loaded from embedded bundle:", code); + paintings[code] = embeddedBitmap; + return; + } + } + + // Level 4: Fetch from network + // console.log("🖼️ Prefetching from network...", code); + paintings[code] = "fetching"; + + const cacheAndStore = async (img) => { + paintings[code] = img; + // Only cache images from aesthetic.computer media backend + if (imageCache.isCacheable(code)) { + await imageCache.set(code, img); + } + }; + + if (actualCode.startsWith("http")) { + $commonApi.get + .picture(actualCode) + .then(({ img }) => cacheAndStore(img)) + .catch(() => { + delete paintings[code]; + }); + } else if (actualCode.includes("/")) { + // Format: @handle/timestamp or handle/timestamp + const [author, paintingSlug] = actualCode.split("/"); + $commonApi.get + .painting(paintingSlug) + .by(author) + .then(({ img }) => cacheAndStore(img)) + .catch(() => { + delete paintings[code]; + }); + } else if (actualCode.match(/^\d{4}\.\d{2}\.\d{2}\.\d{2}\.\d{2}\.\d{2}\.\d{3}$/)) { + // Timestamp format without handle (e.g., "2025.11.01.23.55.14.266") + // This shouldn't happen - timestamps need a handle + console.warn(`⚠️ Painting timestamp without handle: ${actualCode}`); + delete paintings[code]; + } else if (actualCode.length === 3 && actualCode.match(/^[a-zA-Z0-9]{3}$/)) { + // 3-letter code format (e.g., "mzc", "qkm") + // Need to look up the handle/slug first via the painting-code API + try { + const response = await fetch(`/api/painting-code?code=${actualCode}`); + if (!response.ok) { + throw new Error(`Painting code not found: ${actualCode}`); + } + const data = await response.json(); + + // Now load using handle/slug + const handle = data.handle || 'anon'; + $commonApi.get + .painting(data.slug) + .by(handle) + .then(({ img }) => cacheAndStore(img)) + .catch(() => { + delete paintings[code]; + }); + } catch (err) { + console.error(`Failed to lookup painting code ${actualCode}:`, err); + delete paintings[code]; + } + } else { + // Unknown format - try as direct code anyway + $commonApi.get + .painting(actualCode) + .by() // No author needed - endpoint resolves it + .then(({ img }) => cacheAndStore(img)) + .catch(() => { + delete paintings[code]; + }); + } +} + +const $paintApiUnwrapped = { + // Turtle graphics: 🐢 crawl, left, right, up, down, goto, face + // Move the turtle forward based on angle, wrapping at screen edges. + crawl: (steps = 1) => { + const w = screen.width, h = screen.height; + const x2 = turtlePosition.x + steps * cos(num.radians(turtleAngle)); + const y2 = turtlePosition.y + steps * sin(num.radians(turtleAngle)); + const wx2 = ((x2 % w) + w) % w; + const wy2 = ((y2 % h) + h) % h; + // Only draw if we didn't cross a screen edge (avoids diagonal wrap lines). + const wrapped = Math.abs(wx2 - x2) > 0.5 || Math.abs(wy2 - y2) > 0.5; + if (turtleDown && !wrapped) { + graph.line(turtlePosition.x, turtlePosition.y, wx2, wy2); + } + turtlePosition.x = wx2; + turtlePosition.y = wy2; + return { x: turtlePosition.x, y: turtlePosition.y }; + }, + // Turn turtle left n degrees. + left: (d = 1) => { + turtleAngle = normalizeAngle(turtleAngle - d); + return turtleAngle; + }, + // Turn turtle right n degrees. + right: (d = 1) => { + turtleAngle = normalizeAngle(turtleAngle + d); + return turtleAngle; + }, + // Turtle pen up. + up: () => { + turtleDown = false; + // console.log("🐢 Up"); + }, + // Turtle pen down. + down: () => { + turtleDown = true; + // console.log("🐢 Down"); + }, + // Teleport the turtle position (wraps to screen bounds). + goto: (x = screen.width / 2, y = screen.height / 2) => { + const w = screen.width, h = screen.height; + const wx = ((x % w) + w) % w; + const wy = ((y % h) + h) % h; + if (turtleDown) { + graph.line(turtlePosition.x, turtlePosition.y, wx, wy); + } + turtlePosition.x = wx; + turtlePosition.y = wy; + return { x: turtlePosition.x, y: turtlePosition.y }; + }, + face: (angle = 0) => { + turtleAngle = normalizeAngle(angle); + return turtleAngle; + }, + // Shortcuts + // l: graph.line, + // i: ink, + // Defaults + blend: graph.blendMode, + setEraseTarget: graph.setEraseTarget, + page: function () { + if (arguments[0]?.api) { + // console.log("New paint api?", arguments[0].api); + // $activePaintApi = arguments[0].api; + } + + const buf = arguments[0]; + + // If this is the main screen object (by identity), restore its true state. + // Brushes like smear call page(system.painting) which corrupts screen.pixels/ + // width/height. When they later call page(screen), we recognise the original + // screen object and restore from the frame-start snapshot so subsequent code + // (including other brushes and the nopaint pre-processing restore at line ~12788) + // sees correct dimensions immediately rather than at end-of-frame. + if (buf === _mainScreenObject && _trueScreenPixels) { + buf.pixels = _trueScreenPixels; + buf.width = _trueScreenWidth; + buf.height = _trueScreenHeight; + } else { + // Switching to a sub-buffer: propagate its dimensions into screen so that + // code reading screen.width/height while on this buffer gets correct values. + $activePaintApi.screen.width = buf.width; + $activePaintApi.screen.height = buf.height; + $activePaintApi.screen.pixels = buf.pixels; + } + + graph.setBuffer(buf); + }, + edit: graph.changePixels, // Edit pixels by pasing a callback. + // Color + ink: function () { + const out = ink(...arguments); + twoDCommands.push(["ink", ...out]); + }, + ink2: function () { + const out = ink2(...arguments); + twoDCommands.push(["ink2", ...(out || [])]); + }, + // inkrn: () => graph.c.slice(), // Get current inkColor. + // 2D + wipe: function () { + const cc = graph.c.slice(0); + + // Preserve fade alpha during wipe operations to prevent clearing it + const preserveFadeAlpha = getPreserveFadeAlpha?.() || false; + if (!preserveFadeAlpha && typeof setPreserveFadeAlpha === 'function') { + setPreserveFadeAlpha(true); + } + + // Default to white if no arguments provided + if (arguments.length === 0) { + ink(255, 255, 255); + } else { + ink(...arguments); + } + + // 🎨 WebGPU clear if enabled + if ($commonApi.webgpu.enabled) { + send({ + type: "webgpu-command", + content: { + type: "clear", + color: graph.c.slice(0) + } + }); + ink(...cc); + if (!preserveFadeAlpha && typeof setPreserveFadeAlpha === 'function') { + setPreserveFadeAlpha(false); + } + return; + } + + // 🚀 Use optimized clear() which writes directly to pixels array + // clear() already handles fade colors and uses copyWithin doubling for solid colors + // This is much faster than box() which goes through plot/blend per pixel + graph.clear(); + + twoDCommands.push(["wipe", ...graph.c]); + ink(...cc); + + // Restore previous preservation state + if (!preserveFadeAlpha && typeof setPreserveFadeAlpha === 'function') { + setPreserveFadeAlpha(false); + } + + // 🍞 LAYER 0: Also clear layer 0 if it exists (for KidLisp) + // This ensures wipe() clears the persistent layer, not just the screen + if (this.kidlispInstance?.layer0) { + this.kidlispInstance.layer0.pixels.fill(0); + } + }, + // Set background fill color for reframe operations (especially for KidLisp pieces) + backgroundFill: function (color) { + // This function should fill transparent areas with the background color + // without clearing existing painted content. + // + // For now, we use a simple approach: only do a full wipe during initial boot, + // but skip it during reframe to preserve content. + + const cc = graph.c.slice(0); // Save current ink color + + if (arguments.length === 0) { + ink(255, 255, 255); // Default to white + } else { + ink(...arguments); + } + + // TODO: Implement proper transparent-area-only filling + // For now, always do a full clear + graph.clear(); + twoDCommands.push(["backgroundFill", ...graph.c]); + ink(...cc); // Restore previous ink color + }, + // Erase the screen. + clear: function () { + const cc = graph.c.slice(0); + ink(0, 0); + graph.clear(); + ink(...cc); + }, + copy: graph.copy, + paste: function paste() { + if (typeof arguments[0] === "string") { + // Check to see if the bitmap has been cached by this piece already. + const code = arguments[0]; + if (paintings[code] && paintings[code] !== "fetching") { + graph.paste(paintings[code], ...[...arguments].slice(1)); + } else if (paintings[code] !== "fetching") { + prefetchPicture(code); + } + } else { + graph.paste(...arguments); + } + }, + // Similar to paste, but always draws from the center of x, y. + // Has partial support for {center, bottom}. 24.02.15.12.19 + stamp: function stamp() { + let params; + // Parse the parameters and lay out the stamp. + function makeLayout() { + if (typeof params[0] === "object") { + const layout = params[0]; + if (layout.center === "x") { + params[0] = $activePaintApi.screen.width / 2; + } else { + params[0] = 0; + } + if (layout.bottom !== undefined) { + params[1] = + $activePaintApi.screen.height - + layout.bottom - + paintings[code].height / 2; + } else { + params[1] = 0; + } + } + } + if (typeof arguments[0] === "string") { + // Check to see if the bitmap has been cached by this piece already. + const code = arguments[0]; + params = [...arguments].slice(1); + if (paintings[code] && paintings[code] !== "fetching") { + makeLayout(); + graph.stamp(paintings[code], ...params); + } else if (paintings[code] !== "fetching") { + prefetchPicture(code); + } + } else { + params = [...arguments].slice(1); + if (params.length === 0) params = [0, 0]; + makeLayout(); + graph.stamp(arguments[0], ...params); + } + }, + pixel: graph.pixel, + plot: function () { + if (arguments.length === 1) { + graph.plot(arguments[0].x, arguments[0].y); + } else { + graph.plot(...arguments); + } + }, // TODO: Should this be renamed to set? + flood: graph.flood, + compositeLayers: graph.compositeLayers, // GPU-accelerated multi-layer compositing + batchedEffects: graph.batchedEffects, // GPU-accelerated batched effects (zoom+scroll+contrast+brightness in one pass) + point: function () { + const out = graph.point(...arguments); + twoDCommands.push(["point", ...out]); + }, + line: function() { + // 🎨 WebGPU line if enabled + if ($commonApi.webgpu.enabled && arguments.length >= 4) { + const [x1, y1, x2, y2] = arguments; + const color = graph.c.slice(0); + send({ + type: "webgpu-command", + content: { + type: "line", + x1, y1, x2, y2, + color + } + }); + return; + } + return graph.line(...arguments); + }, + lineAngle: graph.lineAngle, + pline: graph.pline, + // Set the alpha of every non-transparent pixel in a buffer to a uniform value. + // Useful for per-stroke alpha — must be called after drawing on the buffer. + setBufferAlpha: function (buffer, alpha) { + if (!buffer || !buffer.pixels) return; + const target = Math.round(alpha * 255); + const px = buffer.pixels; + for (let i = 3; i < px.length; i += 4) { + if (px[i] > 0) px[i] = target; + } + }, + pppline: graph.pixelPerfectPolyline, + oval: graph.oval, + circle: graph.circle, + pie: graph.pie, + tri: graph.tri, + poly: graph.poly, + box: graph.box, + shape: graph.shape, + grid: graph.grid, + draw: graph.draw, + setShowClippedWireframes: graph.setShowClippedWireframes, + clearWireframeBuffer: graph.clearWireframeBuffer, + drawBufferedWireframes: graph.drawBufferedWireframes, + getRenderStats: graph.getRenderStats, + printLine: graph.printLine, // TODO: This is kind of ugly and I need a state machine for type. + form, + pan: graph.pan, + unpan: graph.unpan, + savepan: graph.savepan, + loadpan: graph.loadpan, + mask: graph.mask, + unmask: graph.unmask, + steal: graph.steal, + putback: graph.putback, skip: graph.skip, + scroll: graph.scroll, + flip: graph.flip, + spin: graph.spin, + sort: graph.sort, + zoom: graph.zoom, + suck: graph.suck, + blur: function(radius = 1) { + // 🔧 FIX: Ensure blur operates on current buffer context + // When called from within a painting() context, the graph module + // should already have the correct buffer set via setBuffer() + return graph.blur(radius); + }, + sharpen: function(strength = 1) { + // Apply sharpening filter to enhance edges and details + // When called from within a painting() context, the graph module + // should already have the correct buffer set via setBuffer() + return graph.sharpen(strength); + }, + invert: function() { + // Invert RGB colors (255 - value) while preserving alpha + // When called from within a painting() context, the graph module + // should already have the correct buffer set via setBuffer() + return graph.invert(); + }, + contrast: graph.contrast, + shear: graph.shear, + resetScrollState: function() { + // Reset scroll accumulator - used by burn to ensure clean state + return graph.resetScrollState(); + }, + noise16: graph.noise16, + noise16DIGITPAIN: graph.noise16DIGITPAIN, + noise16Aesthetic: graph.noise16Aesthetic, + noise16Sotce: graph.noise16Sotce, + noiseTinted: graph.noiseTinted, + // 🎨 Alpha-blended paste for crossfade compositing + pasteWithAlpha: function pasteWithAlpha(source, x, y, alpha) { + if (!source || !source.pixels || alpha <= 0) return; + const dst = $activePaintApi.screen; + if (!dst || !dst.pixels) return; + const srcPixels = source.pixels; + const dstPixels = dst.pixels; + const sw = source.width, sh = source.height; + const dw = dst.width, dh = dst.height; + if (alpha >= 255) { + $activePaintApi.paste(source, x, y); + return; + } + const alphaFactor = alpha / 255.0; + for (let sy = 0; sy < sh; sy++) { + const dy = sy + y; + if (dy < 0 || dy >= dh) continue; + for (let sx = 0; sx < sw; sx++) { + const dx = sx + x; + if (dx < 0 || dx >= dw) continue; + const si = (sy * sw + sx) * 4; + const di = (dy * dw + dx) * 4; + let srcA = srcPixels[si + 3]; + if (srcA === 0 && (srcPixels[si] || srcPixels[si + 1] || srcPixels[si + 2])) { + srcA = 255; // Promote opaque RGB with zero alpha + } + if (srcA === 0) continue; + const ea = (srcA * alphaFactor + 1) | 0; + const inv = 256 - ea; + dstPixels[di] = (ea * srcPixels[si] + inv * dstPixels[di]) >> 8; + dstPixels[di + 1] = (ea * srcPixels[si + 1] + inv * dstPixels[di + 1]) >> 8; + dstPixels[di + 2] = (ea * srcPixels[si + 2] + inv * dstPixels[di + 2]) >> 8; + dstPixels[di + 3] = Math.min(255, dstPixels[di + 3] + ea); + } + } + }, + // 🎯 Simplified KidLisp integration using global singleton instance + kidlisp: function kidlisp(x = 0, y = 0, width, height, source, options = {}) { + // Initialize global instance if needed + if (!globalKidLispInstance) { + initializeGlobalKidLisp($activePaintApi); + } + + // Default dimensions to screen size if not provided + if (!width) width = $activePaintApi.screen.width; + if (!height) height = $activePaintApi.screen.height; + + // console.log(`🎯 Simple kidlisp call: ${width}x${height} at (${x},${y})`); + + // Extract options + const { noCache = false, accumulate: forceAccumulate = false, noPaste = false } = options; + + try { + // Initialize persistent paintings cache if needed + if (!globalKidLispInstance.persistentPaintings) { + globalKidLispInstance.persistentPaintings = new Map(); + } + + // For dollar codes, check if we already have a resolved version cached + let resolvedSource = source; + if (source && source.startsWith && source.startsWith('$') && source.length > 1) { + const cacheId = source.slice(1); + + // Use a singleton-specific cache to avoid conflicts with normal prompt loading + if (!globalKidLispInstance.singletonDollarCodeCache) { + globalKidLispInstance.singletonDollarCodeCache = new Map(); + } + + // Track loading states to prevent concurrent requests + if (!globalKidLispInstance.loadingDollarCodes) { + globalKidLispInstance.loadingDollarCodes = new Set(); + } + + // First check if we have a painting with the resolved source already + if (globalKidLispInstance.singletonDollarCodeCache.has(cacheId)) { + resolvedSource = globalKidLispInstance.singletonDollarCodeCache.get(cacheId); + // Note: regionKey will be set later after accumulation detection + + // If we already have this resolved painting, we'll check after regionKey is set + // console.log(`🎯 Using cached source: ${resolvedSource}`); + } else if (globalKidLispInstance.loadingDollarCodes.has(cacheId)) { + // Already loading this dollar code, wait for it to complete + // console.log(`🎯 ${source} already loading, waiting...`); + return null; + } else { + // Start loading and mark as loading + console.log(`🎯 Loading ${source} for first time...`); + globalKidLispInstance.loadingDollarCodes.add(cacheId); + + getCachedCodeMultiLevel(cacheId).then(loadedSource => { + if (loadedSource) { + console.log(`🎯 ✅ Loaded source for ${cacheId}: ${loadedSource.length} chars`); + globalKidLispInstance.singletonDollarCodeCache.set(cacheId, loadedSource); + } else { + console.warn(`❌ Could not load source for ${cacheId}`); + } + }).catch(error => { + console.error(`❌ Error loading ${cacheId}:`, error); + }).finally(() => { + // Remove from loading set when done (success or failure) + console.log(`🎯 Finished loading attempt for ${cacheId}`); + globalKidLispInstance.loadingDollarCodes.delete(cacheId); + }); + + // Return early - no painting this frame, wait for cache + return null; + } + } + + // Guard: if no resolved source, bail early (e.g., disk-based piece, not raw KidLisp) + if (!resolvedSource || typeof resolvedSource !== "string") { + return null; + } + + // 🎨 AUTO-DETECT ACCUMULATION: Check if source starts with a color word or forced via option + const colorWords = ['red', 'blue', 'green', 'yellow', 'purple', 'orange', 'cyan', 'magenta', 'black', 'white', 'gray', 'grey', 'brown', 'pink']; + const firstWord = resolvedSource.trim().split(/\s+/)[0]?.toLowerCase(); + const startsWithColor = colorWords.includes(firstWord); + + // Auto-generate accumulation settings (can be forced via option) + const accumulate = forceAccumulate || startsWithColor; + const accumulateKey = accumulate ? `auto_${x}_${y}_${width}_${height}_${firstWord}` : null; + + // Create persistent painting key with accumulation support + let regionKey; + // Build a dimension-independent content key for finding previous paintings after resize + let contentKey; + if (accumulate && accumulateKey) { + regionKey = `${x},${y},${width},${height}:ACCUMULATE:${accumulateKey}`; + contentKey = `ACCUMULATE:auto_${x}_${y}_${firstWord}`; + } else { + regionKey = `${x},${y},${width},${height}:${resolvedSource}`; + contentKey = resolvedSource; + } + + // 🔄 RESIZE RESILIENCE: If no cached painting at current dimensions, + // look for a previous painting with the same source at different dimensions. + // This preserves animation continuity when the screen resizes. + let resizedPreviousPainting = null; + let resizedPreviousKey = null; + if (!globalKidLispInstance.persistentPaintings.has(regionKey)) { + for (const [key, val] of globalKidLispInstance.persistentPaintings) { + // Match keys that share the same content suffix but differ in dimensions + if (key !== regionKey && key.endsWith(`:${contentKey}`)) { + resizedPreviousPainting = val; + resizedPreviousKey = key; + break; + } + } + } + + // Check if the code contains frame-dependent randomization commands + const hasFrameDependentCommands = /\(\s*ink\s*\)|\(\s*color\s*\)|\(\s*rand\s*\)/.test(resolvedSource); + + // Check if we need to reset (resolved source contains 'wipe') + const shouldReset = resolvedSource.includes('wipe') && !accumulate; // Don't reset in accumulation mode + + // Check if source contains animation-related commands that need fresh execution + const animationCommands = ['rainbow', 'zebra', 'time', 'random', 'noise', 'clock', 'scroll', 'zoom', 'flip', 'contrast', 'fade']; + const hasTimingCommands = /\d+\.?\d*s\b/.test(resolvedSource); // Detect timing like "0.15s", "1s", etc. + const hasAnimationCommands = animationCommands.some(cmd => resolvedSource.includes(cmd)); + const isDollarCode = source && source.startsWith && source.startsWith('$'); // Dollar codes should always refresh + // Don't force fresh execution for timing commands - they need to run continuously + // In accumulate mode, prefer building on existing surface unless explicitly forced + const needsFreshExecution = !accumulate && (noCache || (isDollarCode && !hasTimingCommands) || (hasAnimationCommands && !hasTimingCommands)); + + let painting; + + // Create fresh painting if: reset needed, no cached version exists, or animation commands need updating + if (shouldReset || needsFreshExecution || !globalKidLispInstance.persistentPaintings.has(regionKey)) { + // Create fresh painting (first time, after wipe, or for animations) + const reason = shouldReset ? 'wipe command' : needsFreshExecution ? 'animation content' : 'first time'; + // console.log(`🎨 Creating fresh painting for key: ${regionKey.slice(0, 50)}... (${reason})`); + + // For animations, start with previous frame if available (unless wiping) + // Also check for resized previous painting from a different dimension + const previousPainting = !shouldReset && globalKidLispInstance.persistentPaintings.has(regionKey) + ? globalKidLispInstance.persistentPaintings.get(regionKey) + : (!shouldReset ? resizedPreviousPainting : null); + + // Clean up the old dimension key if we found a resized previous + if (resizedPreviousKey && resizedPreviousPainting) { + globalKidLispInstance.persistentPaintings.delete(resizedPreviousKey); + } + + painting = $activePaintApi.painting(width, height, (api) => { + // Paste previous frame to maintain animation continuity (including across resizes) + if (previousPainting && needsFreshExecution && !shouldReset) { + api.paste(previousPainting); + } + + globalKidLispInstance.setAPI(api); + + // Add basic timing support for KidLisp commands + if (!api.clock) { + api.clock = { time: () => new Date() }; + } + // Override zoom and scroll functions to implement actual effects in painting context + const originalZoom = api.zoom; + const originalScroll = api.scroll || $activePaintApi.scroll; + + api.zoom = (...args) => { + // console.log(`🔍 Zoom called in painting context: args=${JSON.stringify(args)}`); + // Just call the original zoom function - it will operate on the current buffer + if (originalZoom && typeof originalZoom === 'function') { + return originalZoom(...args); + } + }; + + api.scroll = (dx, dy) => { + // console.log(`📜 Scroll called in painting context: dx=${dx}, dy=${dy}`); + // Call the real scroll function from the main paint API + if (originalScroll && typeof originalScroll === 'function') { + return originalScroll(dx, dy); + } else if ($activePaintApi.scroll && typeof $activePaintApi.scroll === 'function') { + return $activePaintApi.scroll(dx, dy); + } + }; + // Add randomization support for ink() and other commands + if (!api.num) { + api.num = $activePaintApi.num || { + random: () => Math.random(), + randInt: (min, max) => Math.floor(Math.random() * (max - min + 1)) + min, + rainbow: num.rainbow, // Use the actual rainbow function from num.mjs + zebra: num.zebra // Use the actual zebra function from num.mjs + }; + } + // Add color support for proper ink randomization + if (!api.color) { + api.color = $activePaintApi.color || { + random: () => [ + Math.floor(Math.random() * 256), + Math.floor(Math.random() * 256), + Math.floor(Math.random() * 256) + ] + }; + } + + // 🎨 Add CSS color functions to make unquoted color words work + // Import CSS colors from num.mjs and add them as functions to the API + const cssColors = num.cssColors; + if (cssColors) { + Object.keys(cssColors).forEach(colorName => { + if (!api[colorName]) { + api[colorName] = () => cssColors[colorName]; + } + }); + } + + // Add rainbow and zebra as top-level functions for unquoted usage + if (!api.rainbow) { + api.rainbow = () => num.rainbow(); + } + if (!api.zebra) { + api.zebra = () => num.zebra(); + } + // Reset KidLisp color state to prevent cross-contamination between regions + globalKidLispInstance.currentInk = null; + + // Save original state before configuring execution mode + const originalInEmbedPhase = globalKidLispInstance.inEmbedPhase; + const originalIsNestedInstance = globalKidLispInstance.isNestedInstance; + const originalEmbeddedLayers = globalKidLispInstance.embeddedLayers; + const originalIsEmbeddedContext = globalKidLispInstance.isEmbeddedContext; + + // 🎯 CRITICAL: Set isEmbeddedContext=true so effects (zoom, blur, scroll, etc.) + // execute immediately instead of being deferred to postCompositeCommands + // This aligns embedded kidlisp() with the main KidLisp paint behavior + globalKidLispInstance.isEmbeddedContext = true; + + // Check if source contains timing expressions that need proper scheduling + const hasTimingExpressions = /\d+\.?\d*s(\.\.\.|!)?/.test(resolvedSource); + // Check if source contains scroll/zoom/flip that needs deferred execution + const hasScrollZoom = /\(\s*(scroll|zoom|flip)\s/.test(resolvedSource); + + + + if (hasTimingExpressions) { + // console.log(`🎯 Detected timing expressions, preserving normal execution flow`); + // Don't force immediate execution for timing expressions + globalKidLispInstance.embeddedLayers = null; // Still clear embedded layers + } else if (hasScrollZoom) { + // console.log(`🎯 Detected scroll/zoom/flip, allowing deferred execution`); + // Allow scroll/zoom/flip to use deferred execution but clear embedded layers + globalKidLispInstance.inEmbedPhase = false; // Allow deferring for scroll/zoom/flip + globalKidLispInstance.isNestedInstance = false; // Allow deferring for scroll/zoom/flip + globalKidLispInstance.embeddedLayers = null; // Clear any leftover embedded layers + } else { + // console.log(`🎯 No timing/scroll/zoom/flip detected, forcing immediate execution`); + // Force immediate execution mode for simplified kidlisp() calls without timing or scroll/zoom/flip + globalKidLispInstance.inEmbedPhase = true; // Prevent deferring + globalKidLispInstance.isNestedInstance = true; // Enable immediate execution + globalKidLispInstance.embeddedLayers = null; // Clear any leftover embedded layers + } + + executeLispCode(resolvedSource, api, false); // false = not accumulating, fresh painting + + // Restore original state to avoid interfering with other KidLisp operations + globalKidLispInstance.inEmbedPhase = originalInEmbedPhase; + globalKidLispInstance.isNestedInstance = originalIsNestedInstance; + globalKidLispInstance.embeddedLayers = originalEmbeddedLayers; + globalKidLispInstance.isEmbeddedContext = originalIsEmbeddedContext; + + globalKidLispInstance.setAPI($activePaintApi); + }); + // Always cache the painting for next frame continuity (even for animations) + globalKidLispInstance.persistentPaintings.set(regionKey, painting); + } else { + // Build on existing painting (accumulate effects) + const existingPainting = globalKidLispInstance.persistentPaintings.get(regionKey); + // console.log(`🎨 Accumulating on existing painting for key: ${regionKey.slice(0, 50)}...`); + + painting = $activePaintApi.painting(width, height, (api) => { + // Paste previous state first + api.paste(existingPainting); + // Then add new effects on top + globalKidLispInstance.setAPI(api); + + // Add basic timing support for KidLisp commands + if (!api.clock) { + api.clock = { time: () => new Date() }; + } + // Add scroll and zoom support for animations + const originalZoom = api.zoom; + const originalScroll = api.scroll || $activePaintApi.scroll; + + api.zoom = (...args) => { + // console.log(`🔍 Zoom called in accumulation context: args=${JSON.stringify(args)}`); + if (originalZoom && typeof originalZoom === 'function') { + return originalZoom(...args); + } + }; + + api.scroll = (dx, dy) => { + // console.log(`📜 Scroll called in accumulation context: dx=${dx}, dy=${dy}`); + if (originalScroll && typeof originalScroll === 'function') { + return originalScroll(dx, dy); + } else if ($activePaintApi.scroll && typeof $activePaintApi.scroll === 'function') { + return $activePaintApi.scroll(dx, dy); + } + }; + // Add randomization support for ink() and other commands + if (!api.num) { + api.num = $activePaintApi.num || { + random: () => Math.random(), + randInt: (min, max) => Math.floor(Math.random() * (max - min + 1)) + min, + rainbow: num.rainbow, // Use the actual rainbow function from num.mjs + zebra: num.zebra // Use the actual zebra function from num.mjs + }; + } + // Add color support for proper ink randomization + if (!api.color) { + api.color = $activePaintApi.color || { + random: () => [ + Math.floor(Math.random() * 256), + Math.floor(Math.random() * 256), + Math.floor(Math.random() * 256) + ] + }; + } + + // 🎨 Add CSS color functions to make unquoted color words work + // Import CSS colors from num.mjs and add them as functions to the API + const cssColors = num.cssColors; + if (cssColors) { + Object.keys(cssColors).forEach(colorName => { + if (!api[colorName]) { + api[colorName] = () => cssColors[colorName]; + } + }); + } + + // Add rainbow and zebra as top-level functions for unquoted usage + if (!api.rainbow) { + api.rainbow = () => num.rainbow(); + } + if (!api.zebra) { + api.zebra = () => num.zebra(); + } + // Reset KidLisp color state to prevent cross-contamination between regions + globalKidLispInstance.currentInk = null; + + // Save original state before configuring execution mode + const originalInEmbedPhase = globalKidLispInstance.inEmbedPhase; + const originalIsNestedInstance = globalKidLispInstance.isNestedInstance; + const originalEmbeddedLayers = globalKidLispInstance.embeddedLayers; + const originalIsEmbeddedContext = globalKidLispInstance.isEmbeddedContext; + + // 🎯 CRITICAL: Set isEmbeddedContext=true so effects (zoom, blur, scroll, etc.) + // execute immediately instead of being deferred to postCompositeCommands + // This aligns embedded kidlisp() with the main KidLisp paint behavior + globalKidLispInstance.isEmbeddedContext = true; + + // Check if source contains timing expressions that need proper scheduling + const hasTimingExpressions = /\d+\.?\d*s(\.\.\.|!)?/.test(resolvedSource); + // Check if source contains scroll/zoom/flip that needs deferred execution + const hasScrollZoom = /\(\s*(scroll|zoom|flip)\s/.test(resolvedSource); + + // Only log occasionally to reduce console spam + + + if (hasTimingExpressions) { + // console.log(`🎯 Detected timing expressions in accumulation, preserving normal execution flow`); + // Don't force immediate execution for timing expressions + globalKidLispInstance.embeddedLayers = null; // Still clear embedded layers + } else if (hasScrollZoom) { + // console.log(`🎯 Detected scroll/zoom/flip in accumulation, allowing deferred execution`); + // Allow scroll/zoom/flip to use deferred execution but clear embedded layers + globalKidLispInstance.inEmbedPhase = false; // Allow deferring for scroll/zoom/flip + globalKidLispInstance.isNestedInstance = false; // Allow deferring for scroll/zoom/flip + globalKidLispInstance.embeddedLayers = null; // Clear any leftover embedded layers + } else { + // console.log(`🎯 No timing/scroll/zoom/flip in accumulation, forcing immediate execution`); + // Force immediate execution mode for simplified kidlisp() calls without timing or scroll/zoom/flip + globalKidLispInstance.inEmbedPhase = true; // Prevent deferring + globalKidLispInstance.isNestedInstance = true; // Enable immediate execution + globalKidLispInstance.embeddedLayers = null; // Clear any leftover embedded layers + } + + executeLispCode(resolvedSource, api, true); // true = accumulating on existing painting + + // Restore original state to avoid interfering with other KidLisp operations + globalKidLispInstance.inEmbedPhase = originalInEmbedPhase; + globalKidLispInstance.isNestedInstance = originalIsNestedInstance; + globalKidLispInstance.embeddedLayers = originalEmbeddedLayers; + globalKidLispInstance.isEmbeddedContext = originalIsEmbeddedContext; + + globalKidLispInstance.setAPI($activePaintApi); + }); + // Update the cache with the new accumulated state (unless noCache is true or animation content) + if (!noCache || !needsFreshExecution) { + globalKidLispInstance.persistentPaintings.set(regionKey, painting); + } + } + + // Paste the painting to the specified location (unless noPaste is set) + if (!noPaste && $activePaintApi.paste && painting) { + // console.log(`🎯 Pasting painting to (${x},${y})`); + $activePaintApi.paste(painting, x, y); + } + + return painting; + + } catch (error) { + console.error("🚫 Simple KidLisp error:", error); + + // Draw error directly to main screen (skip in projection/embed mode like kidlisp.com) + if (!projectionMode) { + const originalInk = $activePaintApi.ink(); + $activePaintApi.ink(255, 0, 0); + if ($activePaintApi.write) { + $activePaintApi.write("KidLisp Error", x, y); + } + $activePaintApi.ink(originalInk); + } + + return null; + } + }, + // 🎵 Update KidLisp audio globals (accessible to all pieces) + updateKidLispAudio: updateKidLispAudio, + // glaze: ... +}; + +// 🎨 Expose wipe function globally for KidLisp reframe operations +if (typeof globalThis !== "undefined") { + globalThis.$paintApiUnwrapped = $paintApiUnwrapped; + globalThis.wipe = $paintApiUnwrapped.wipe; +} + +// Helper function to execute KidLisp code +function executeLispCode(source, api, isAccumulating = false) { + try { + // Check if trace is enabled (set by kidlisp.com visualization) + const shouldTrace = $commonApi?.kidlispEnableTrace; + if (shouldTrace) { + enableKidlispTrace(); + clearExecutionTrace(); + } else { + disableKidlispTrace(); + } + + // Parse and evaluate the source directly without going through module lifecycle + // console.log(`🔍 Parsing KidLisp source:`, source); + + // Clear previous first-line color state for fresh detection + + globalKidLispInstance.firstLineColor = null; + + // Store source for error location lookup + globalKidLispInstance.currentSource = source; + + globalKidLispInstance.parse(source); + // console.log(`🔍 Generated AST:`, globalKidLispInstance.ast); + + if (globalKidLispInstance.ast) { + // Detect first-line color but only apply it if not accumulating + globalKidLispInstance.detectFirstLineColor(); + + if (globalKidLispInstance.firstLineColor && !isAccumulating) { + // console.log(`🎨 Applying first-line color background: ${globalKidLispInstance.firstLineColor}`); + api.wipe(globalKidLispInstance.firstLineColor); + } + + // Execute the parsed AST using the main evaluate method + // console.log(`🔍 Executing AST using main evaluate method...`); + // console.log(`🔍 AST contains:`, globalKidLispInstance.ast.map(expr => Array.isArray(expr) ? expr[0] : expr)); + + // Set up proper timing environment by ensuring clock and frameCount are available + if (!api.clock) { + api.clock = { time: () => new Date() }; + } + + // Increment frame count for timing expressions + if (typeof globalKidLispInstance.frameCount !== 'number') { + globalKidLispInstance.frameCount = 0; + } + globalKidLispInstance.frameCount++; + + // Normal KidLisp evaluation (dollar codes already resolved) + const result = globalKidLispInstance.evaluate(globalKidLispInstance.ast, api, globalKidLispInstance.localEnv); + // console.log(`🔍 Evaluation result:`, result); + + // Post execution trace if enabled (only on first frame to avoid spam) + if (shouldTrace && globalKidLispInstance.frameCount === 1) { + postExecutionTrace(); + } + } else { + // console.log(`⚠️ No AST generated from source`); + } + } catch (evalError) { + console.error("🚫 KidLisp evaluation error:", evalError); + // Draw error indicator (skip in projection/embed mode like kidlisp.com) + if (!projectionMode) { + api.wipe(60, 0, 0); + api.ink(255, 255, 255); + if (api.write) { + api.write("KidLisp Eval Error", 5, 15); + } + } + } +} + +// TODO: Eventually restructure this a bit. 2021.12.16.16.0 +// Should global state like color and transform be stored here? + +let $activePaintApi; + +let paintingAPIid = 0n; + +const twoDCommands = []; +graph.twoD(twoDCommands); // Set a global for passing 2d commands here. + +class Painting { + #layers = []; + #layer = 0; + api = {}; + inkrn; + + // panrn; // In order for this feature to work, translation needs to be stored outside of graph / captured differently? + + constructor() { + Object.assign(this.api, $paintApi); + const p = this; + + p.api.index = paintingAPIid; + paintingAPIid += 1n; + + p.inkrn = graph.c.slice(); // Init global state machine read-outs. + p.pagern = graph.getBuffer(); + // p.panrn = graph.getPan(); + + // Filter for and then wrap every rendering behavior of $paintApi into a + // system to be deferred in groups, using layer. + // ⛓️ This wrapper also makes the paint API chainable. + + function globals(k, args) { + if (k === "page") p.pagern = args[0]; + // TODO: 😅 Add other state globals like line thickness? 23.1.25 + } + + for (const k in $paintApiUnwrapped) { + if (typeof $paintApiUnwrapped[k] === "function") { + // Wrap and then transfer to #api. + p.api[k] = function () { + if (k === "ink") { + $paintApiUnwrapped[k](...arguments); + p.inkrn = graph.c.slice(); + } else { + globals(k, arguments); // Keep track of global state, like page, via `pagern`. + } + // Create layer if necessary. + if (notArray(p.#layers[p.#layer])) p.#layers[p.#layer] = []; + const callArgs = arguments; + // Add each deferred paint api function to the layer, to be run + // all at once in `paint` on each frame update. + p.#layers[p.#layer].push([ + k, + () => { + if (k === "ink") { + $paintApiUnwrapped[k](...callArgs); + p.inkrn = graph.c.slice(); + } else { + globals(k, callArgs); // Update globals again on chainable calls. + $paintApiUnwrapped[k](...callArgs); + } + }, + ]); + return p.api; + }; + } + } + + // Allows grouping & composing painting order using an AofA (Array of Arrays). + // n: 0-n (Cannot be negative.) + // fun: A callback that contains $paintApi commands or any other code. + this.api.layer = function (n) { + p.#layer = n; + // TODO: ❤️‍🔥 Current layer needs to be set on each API state...! + return p.api; + }; + + // Creates a new pixel buffer with its own layering wrapper / context + // on top of the base painting API. + this.api.painting = function painting() { + const oldActivePaintApi = $activePaintApi; + const painting = new Painting(); + $activePaintApi = painting.api; + // Mock out the screen here using the arguments. + $activePaintApi.screen = { + width: arguments[0], + height: arguments[1], + // pix gets added in the makeBuffer... + }; + const pix = graph.makeBuffer(...arguments, painting, $activePaintApi); + $activePaintApi = oldActivePaintApi; + return pix; + }; + + this.api.pixel = function () { + return graph.pixel(...arguments); + }; + + this.api.inkrn = () => this.inkrn; // Return current ink color. + // this.api.panrn = graph.getPan; // Return current panTranslation. + this.api.pagern = () => this.pagern; // Return current page buffer. + + // This links to abstract, solitary graph functions that do not need + // to be wrapped or deferred for rendering. + // TODO: Maybe these functions should be under a graphics algorithms label? + this.api.abstract = { bresenham: graph.bresenham }; + } + + // Paints every layer. + //async paint(immediate = false) { + paint(immediate = false) { + for (let layer of this.#layers) { + layer ||= []; // Evaporate empty layers. + for (const paint of layer) { + // if (immediate) { + // console.log("Label:", paint[0]); + paint[1](); + // } else { + // await paint(); + // } + } + } + this.#layers.length = 0; + this.#layer = 0; + } +} + +const painting = new Painting(); + +let glazeAfterReframe; + +// *** Resolution *** +// Accepts width, height and gap either as numbers or as +// an object with those keys. +// +// Usage: resolution(64); +// resolution(320, 240); +// resolution(display); // "display" is a global object whose width +// and height matches the hardware display +// hosting aesthetic.computer. +let lastGap = 8; +$commonApi.resolution = function (width, height = width, gap = 8) { + if (typeof width === "object") { + const props = width; + height = props.height; + width = props.width || props.height; + gap = props.gap === 0 ? 0 : props.gap || 8; + } + + if (typeof width === "number" && typeof height === "number") { + width = round(width); + height = round(height); + } + + // Don't do anything if there is no change and no gap update. + if (screen.width === width && screen.height === height && gap === lastGap) + return; + + lastGap = gap; + + // width = width || currentDisplay.innerWidth; + // height = height || currentDisplay.innerHeight; + + // TODO: Paint anything that needs to be painted before resizing... + // TODO: Does this even work right now? + painting.paint(); + + if (width === undefined && height === undefined) { + // 1. Generate a new width and height. + width = round(currentDisplay.width / currentDisplay.subdivisions); + height = round(currentDisplay.height / currentDisplay.subdivisions); + // Build a reframe request that will be sent to the main thread, mirroring this. + reframe = { + width: undefined, + height: undefined, + gap, + }; + } else { + // 2. Manually set the width and height. + reframe = { width, height, gap }; + } + + // console.log( + // "🖼 Reframe to:", + // width, + // height, + // "from", + // screen.width, + // screen.height, + // ); + + // 3. Assign the generated or manual width and height. + const oldScreen = { + width: screen.width, + height: screen.height, + pixels: screen.pixels, + }; + + screen.width = width; + screen.height = height; + + // Reset / recreate the depth buffer. (This is only used for the 3D software renderer in `graph`) + graph.depthBuffer.length = screen.width * screen.height; + graph.depthBuffer.fill(Number.MAX_VALUE); + graph.writeBuffer.length = 0; //screen.width * screen.height; + // graph.writeBuffer.fill(Number.MAX_VALUE); + + screen.pixels = new Uint8ClampedArray(screen.width * screen.height * 4); + screen.pixels.fill(255); + + graph.setBuffer(screen); + graph.paste({ + painting: oldScreen, + crop: new geo.Box(0, 0, oldScreen.width, oldScreen.height), + }); + + // 🎨 Fill any new pixels with background color after screen expansion + if (width > oldScreen.width || height > oldScreen.height) { + const persistentColor = getPersistentFirstLineColor(); + console.log("🎨 Resolution function: Screen expansion detected"); + console.log("🎨 Resolution function: Old screen:", oldScreen.width, "x", oldScreen.height); + console.log("🎨 Resolution function: New screen:", width, "x", height); + console.log("🎨 Resolution function: Persistent color found:", persistentColor); + + let fillSpec = resolveBackgroundFillSpec(persistentColor); + if (!fillSpec && typeof globalKidLispInstance?.getBackgroundFillColor === "function") { + const fallbackColor = globalKidLispInstance.getBackgroundFillColor(); + console.log("🎨 Post-reframe: Fallback background candidate:", fallbackColor); + fillSpec = resolveBackgroundFillSpec(fallbackColor); + } + + if (fillSpec) { + if (fillSpec.type === "fade") { + console.log("🎨 Post-reframe: Applying fade background to expansions"); + fillExpandedWithFadePixels( + screen, + width, + height, + oldScreen.width, + oldScreen.height, + fillSpec.fadeInfo, + ); + } else { + console.log("🎨 Post-reframe: Filling expansions with solid color", fillSpec.rgba); + fillExpandedWithSolidPixels( + screen, + width, + height, + oldScreen.width, + oldScreen.height, + fillSpec.rgba, + ); + } + } else { + console.log("🎨 Post-reframe: No background fill spec available for expansion"); + } + } else { + console.log("🎨 Resolution function: No screen expansion needed"); + } +}; + +// Add new content to the DOM. +// (Requires `send`) +class Content { + nodes = []; + #id = 0; + + constructor() {} + + // Make a request to add new content to the DOM. + add(content) { + // if (debug) console.log("📃 Adding content:", content); + this.nodes.push({ id: this.#id }); + this.#id = this.nodes.length - 1; + send({ type: "content-create", content: { id: this.#id, content } }); + return this.nodes[this.nodes.length - 1]; + } + + remove() { + send({ type: "content-remove" }); + this.nodes = []; + this.#id = 0; + } + + receive({ id, response }) { + this.nodes[id].response = response; + } + + //update({ id, msg }) { + // send({ type: "content-update", content: { id, msg } }); + //} +} + +// 🔈 Sound + +// Microphone State (Audio Input) +class Microphone { + amplitude = 0; + waveform = []; + pitch = 0; + connected = false; // Flips to true on a callback message from `bios`. + recording = false; + recordingPromise; + permission = ""; + recordingBuffer = null; // Live recording buffer for preview + + // Note: can send `{monitor: true}` in `options` for audio feedback. + connect(options) { + send({ type: "microphone", content: options }); + return this; + } + + disconnect() { + send({ type: "microphone", content: { detach: true } }); + } + + poll() { + send({ type: "get-microphone-amplitude" }); + send({ type: "get-microphone-waveform" }); + send({ type: "get-microphone-pitch" }); + if (this.recording) { + send({ type: "get-microphone-recording-buffer" }); + } + } + + // Start recording. + rec() { + this.recording = true; + send({ type: "microphone-record" }); + } + + // Stop recording. + cut() { + const prom = new Promise((resolve, reject) => { + this.recordingPromise = { resolve, reject }; + }); + send({ type: "microphone-cut" }); + this.recording = false; + return prom; + } +} + +class Speaker { + waveforms = { left: [], right: [] }; + amplitudes = { left: [], right: [] }; + frequencies = { left: [], right: [] }; + beat = { detected: false, strength: 0, timestamp: 0 }; // Add beat detection data + + poll() { + send({ type: "get-waveforms" }); + send({ type: "get-amplitudes" }); + send({ type: "get-frequencies" }); + } +} + +let sound, + // soundClear, // Used by receivedBeat and defined in first frame update. + soundId = 0n, // Increment each sound / give it an id in the `bios`. + soundTime; // Used by `$sound.synth` for global timing. + +sound = { + bpm: undefined, + sounds: [], + bubbles: [], + kills: [], +}; + +const speaker = new Speaker(); +const microphone = new Microphone(); + +// 🎮 Game Boy Emulator API +const gameboy = { + frame: null, // Current frame data (Uint8ClampedArray) + width: 160, // Game Boy screen width + height: 144, // Game Boy screen height + romName: null, // Currently loaded ROM name + isPlaying: false // Whether emulator is running +}; + +// Resolve a user code (e.g. "ac25namuc") to a handle via the permahandle API. +// Returns the handle string (e.g. "jeffrey") or null if not a user code / not found. +async function resolveUserCode(slug) { + if (!slug || slug.length !== 9 || !slug.startsWith("ac")) return null; + if (!/^ac[0-9]{2}[a-z]{5}$/.test(slug)) return null; + try { + const res = await fetch(`/api/permahandle/${slug}`); + if (!res.ok) return null; + const data = await res.json(); + return data.handle || null; + } catch { + return null; + } +} + +// 2. ✔ Loading the disk. +let originalHost; +let firstLoad = true; + +let notice, noticeTimer, noticeColor, noticeOpts; // Renders a full-screen notice on piece-load if present. + +async function load( + parsed, // If parsed is not an object, then assume it's source code. + fromHistory = false, + alias = false, + devReload = false, + loadedCallback, + forceKidlisp = false, // Force interpretation as kidlisp even without prefix +) { + const loadFunctionStartTime = performance.now(); + diskTimings.loadStarted = Math.round(loadFunctionStartTime - diskTimingStart); + // Silent: load() started + + let fullUrl, source; + let params, + search, + colon, + hash, + path, + host = originalHost, + text, + slug, + clockShortcode = null; // Track original *code for URL display + + // 🕷️ SPIDER MODE: Always log what piece we're loading + if (typeof window !== 'undefined' && window.acSPIDER) { + console.log("🕷️ SPIDER: Starting load() with:", { + parsed, + fromHistory, + alias, + devReload, + forceKidlisp + }); + } + + // Check for stuck loading state before proceeding + checkLoadingTimeout(); + + if (loading === false) { + loading = true; + loadingStartTime = Date.now(); + } else { + // TODO: If the piece is different, then there should be a way to abort + // or ignore a current load. + console.warn( + "Coudn't load:", + parsed.path || parsed.name, + "(Already loading.)", + ); + return true; + } + + // Reload a previously sideloaded piece on subsequent loads. + if ( + !parsed.source && + store["publishable-piece"] && + parsed.piece === store["publishable-piece"].slug + ) { + parsed.source = store["publishable-piece"].source; + parsed.name = store["publishable-piece"].slug; + parsed.ext = store["publishable-piece"].ext; + } + + // 🕸️ Loading over the network from a parsed path object with no source code. + if (!parsed.source) { + params = parsed.params; + path = parsed.path; + search = parsed.search; + colon = parsed.colon; + hash = parsed.hash; + host = parsed.host; + slug = parsed.text; + + if (typeof window !== 'undefined' && window.acSPIDER) { + console.log("🕷️ SPIDER: Parsed slug/path:", { + slug, + path, + parsedText: parsed.text, + parsedPath: parsed.path, + hash, + params + }); + } + + // 👱 Route to the `profile` piece if we are just hitting an empty + // username. + if (slug.startsWith("@") && slug.indexOf("/") === -1) { + params = [slug, ...params]; // Rewrite all params for `@user` slug urls. + //slug = "profile"; // Go to `profile` instead of the `@user`. + const hiddenSlug = "profile"; + // Rewrite path to `profile`. + console.log("Profile Path:", path); + path = [...path.split("/").slice(0, -1), hiddenSlug].join("/"); + } + + // ⏰ Normalize *clock shortcodes to the clock piece and let clock.mjs fetch + // the stored melody by code. This avoids intercepting later in the loader + // and ensures the correct module is fetched. + if (slug && slug.startsWith("*") && slug.length > 1) { + clockShortcode = slug; // Preserve original *code for URL + const cacheId = slug.slice(1); + const clockParam = `*${cacheId}`; + params = [clockParam, ...(params || [])]; + slug = "clock"; + path = "aesthetic.computer/disks/clock"; + parsed.text = slug; + parsed.path = path; + parsed.params = params; + } + + // if (debug) console.log(debug ? "🟡 Development" : "🟢 Production"); + if (host === "") host = originalHost; + loadFailure = undefined; + host = host.replace(/\/$/, ""); // Remove any trailing slash from host. + // Note: This fixes a preview bug on teia.art. 2022.04.07.03.00 if (path === "") path = ROOT_PIECE; // Set bare path to what "/" maps to. + // if (path === firstPiece && params.length === 0) params = firstParams; + + // Check if the path already has a language extension and use it directly, otherwise default to .mjs + // Handle sandboxed environments where location.protocol might be "null:" + // For aesthetic.computer pieces, determine the correct server + const { protocol, hostname } = getSafeUrlParts(); + + // If we're loading an aesthetic.computer piece, choose the appropriate server + let baseUrl; + if (path.startsWith('aesthetic.computer/')) { + // Check if we're in OBJKT mode - use local bundled files + if (typeof window !== 'undefined' && window.acSPIDER) console.log("🕷️ getPackMode():", getPackMode(), "window.acPACK_MODE:", window.acPACK_MODE, "checkPackMode():", checkPackMode()); + if (getPackMode()) { + // In OBJKT mode, use relative paths to load bundled pieces + baseUrl = "."; + } else { + // Check if we're in a development environment (localhost with port) + baseUrl = getBuiltInPieceBaseUrl(); + } + } else { + baseUrl = `${protocol}//${hostname}`; + } + + if (typeof window !== 'undefined' && window.acSPIDER) console.log("�️ SPIDER MODE Debug:", { protocol, hostname, baseUrl, isSandboxed: isSandboxed(), path, isDevelopment: hostname === 'localhost' && typeof location !== 'undefined' && location.port }); + + // Check if path already includes the hostname to avoid double paths + let resolvedPath = path; + if (getPackMode() && path.startsWith('aesthetic.computer/')) { + // In OBJKT mode, keep the full path since files are bundled with directory structure + resolvedPath = path; + } else if (baseUrl === 'https://aesthetic.computer' && path.startsWith('aesthetic.computer/')) { + // Strip "aesthetic.computer/" prefix when using production domain to avoid doubling + resolvedPath = path.substring('aesthetic.computer/'.length); + } + + // if (debug) console.log("🔍 Debug path resolution:", { originalPath: path, resolvedPath, hostname, baseUrl }); + + if (path.endsWith('.lisp') || path.endsWith('.lua')) { + if (getPackMode()) { + // In OBJKT mode, use absolute path from iframe origin + fullUrl = "/" + resolvedPath + "?v=" + Date.now(); + } else { + fullUrl = baseUrl + "/" + resolvedPath + "?v=" + Date.now(); + } + } else { + if (getPackMode()) { + // In OBJKT mode, navigate up from lib directory to aesthetic.computer root, then to target + const relativePath = resolvedPath.startsWith('aesthetic.computer/') + ? resolvedPath.substring('aesthetic.computer/'.length) + : resolvedPath; + fullUrl = "../" + relativePath + ".mjs" + "?v=" + Date.now(); + } else { + fullUrl = baseUrl + "/" + resolvedPath + ".mjs" + "?v=" + Date.now(); + } + } + // The query param `v` busts the cache so that the environment is + // reset if a disk is re-entered while the system is running. + // Note: Hash fragments (#) don't bust browser cache, only query strings (?) do. + // if (debug) console.log("🕸", fullUrl); + } else { + // 📃 Loading with provided source code. + // This could either be JavaScript or LISP. + + if ( + devReload === true && + parsed.codeChannel && + parsed.codeChannel !== codeChannel + ) { + console.warn( + "🙅 Not reloading, code channel invalid:", + codeChannel || "N/A", + ); + return; + } + // console.log("📃 Loading from source:", JSON.stringify(parsed)); + // console.log("📃 Source content to run:", JSON.stringify(parsed.source)); + source = parsed.source; + params = parsed.params; + search = parsed.search; + colon = parsed.colon || []; + hash = parsed.hash; // tood: these probably don't work? 24.07.09.23.46 + host = parsed.host; + slug = parsed.name; // not 'text' for this. + + // ⏰ Normalize *clock shortcodes for source-based loads too. + if (slug && slug.startsWith("*") && slug.length > 1) { + clockShortcode = slug; // Preserve original *code for URL + const cacheId = slug.slice(1); + const clockParam = `*${cacheId}`; + params = [clockParam, ...(params || [])]; + slug = "clock"; + if (slug !== "(...)" && !path) path = "aesthetic.computer/disks/clock"; + parsed.name = slug; + parsed.path = path || "aesthetic.computer/disks/clock"; + parsed.params = params; + } + + // 🔍 Enable tracing for kidlisp.com visualization if requested + if (parsed.enableTrace) { + enableKidlispTrace(); + } else { + disableKidlispTrace(); + } + + if (slug !== "(...)") path = parsed.path; //"aesthetic.computer/disks/" + slug; + // 📓 Might need to fill in hash, path, or slug here. 23.06.24.18.49 + } + + let prefetches; // Will be acted on after `hotSwap`. + + // 🅱️ Load the piece. + + // const moduleLoadTime = performance.now(); + + let blobUrl, sourceCode, originalCode; + try { + // If this is a reload (with no source change) then just create a new + // blobURL off the old source. + // TODO: Cache piece code locally / in an intelligent way, + // and then receive socket updates when it changes on the server? + if ( + slug?.split("~")[0] === currentText?.split("~")[0] && + sourceCode == currentCode && + !devReload + ) { + const blob = new Blob([currentCode], { type: "application/javascript" }); + blobUrl = URL.createObjectURL(blob); + sourceCode = currentCode; + originalCode = sourceCode; + } else { + let sourceToRun; + let fetchStartTime = performance.now(); // Initialize timing at the start + + // 💾 Check if this is a cached kidlisp code (starts with $ followed by a cache ID) + if (slug && slug.startsWith("$") && slug.length > 1) { + const cacheId = slug.slice(1).split(":")[0]; // Remove $ prefix and any :fps colon params + + // Clear author/hits immediately to prevent stale data showing during load + currentHUDAuthor = null; + currentHUDHits = null; + + // First check if we have this in objktKidlispCodes (offline bundle) + const globalScope = (function () { + if (typeof window !== 'undefined') return window; + if (typeof globalThis !== 'undefined') return globalThis; + if (typeof global !== 'undefined') return global; + if (typeof self !== 'undefined') return self; + return {}; + })(); + + if (globalScope.objktKidlispCodes && globalScope.objktKidlispCodes[cacheId]) { + sourceToRun = globalScope.objktKidlispCodes[cacheId]; + currentOriginalCodeId = slug; + currentHUDAuthor = null; // No author info in offline bundles + currentHUDHits = null; + } else { + console.log("💾 Loading cached kidlisp code:", cacheId); + try { + console.log("🔍 Fetching cached code from API..."); + sourceToRun = await getCachedCodeMultiLevel(cacheId); + if (!sourceToRun) { + throw new Error(`Cached code not found: ${cacheId}`); + } + // Track the original $code identifier for sharing + currentOriginalCodeId = slug; // Keep the full $code format + console.log("✅ Successfully loaded cached code:", cacheId, `(${sourceToRun.length} chars)`); + + // 👤 Fetch author metadata in background (for logging only) + fetchKidlispMetadata(cacheId).then(meta => { + if (meta) { + // Note: author/hits are fetched but not displayed for KidLisp pieces + console.log(`👤 Author: ${meta.handle || 'anonymous'}, Hits: ${meta.hits}`); + } + }).catch(err => { + console.warn("⚠️ Failed to fetch KidLisp metadata:", err); + }); + } catch (error) { + console.error("❌ Failed to load cached code:", cacheId, error); + throw new Error(`Failed to load cached code: ${cacheId}`); + } + } + } else if (fullUrl) { + // 🎄 Check if piece code is cached (for merry preloading) + const pieceSlug = slug?.split("~")[0]; + const cachedPiece = pieceSlug ? pieceCodeCache.get(pieceSlug) : null; + + if (cachedPiece) { + console.log(`🎄 Using cached code for: ${pieceSlug}`); + sourceToRun = cachedPiece.code; + // Skip fetch, use cached code directly + } else { + // In OBJKT mode, try direct import first for .mjs files to avoid CSP issues + // Extract the filename from URL, handling ./ prefix and hash fragments + const urlWithoutHash = fullUrl.split('#')[0]; + const filename = urlWithoutHash.split('/').pop(); + + if (getPackMode() && filename.endsWith('.mjs')) { + // In OBJKT mode, skip dynamic import and use fetch directly since files are bundled locally + // Will proceed to fetch() below + } + + let response; + if (logs.loading) console.log("📥 Loading from url:", fullUrl); + // if (debug) console.log("🔍 Debug: Constructed fullUrl:", fullUrl); + + // Notify boot progress + const fetchStartTime = performance.now(); + send({ + type: "boot-log", + content: `fetching: ${path}` + }); + + // Use cache: 'no-store' to force fresh fetch, especially important for LAN IP access + // Retry on network failure (bad connections drop fetches) + { + const maxAttempts = 3; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + response = await fetch(fullUrl, { cache: 'no-store' }); + break; + } catch (fetchErr) { + if (attempt >= maxAttempts) throw fetchErr; + console.warn(`🔄 Piece fetch attempt ${attempt}/${maxAttempts} failed, retrying in ${attempt}s...`); + await new Promise(r => setTimeout(r, 1000 * attempt)); + } + } + } + const fetchEndTime = performance.now(); + diskTimings.fetchComplete = Math.round(fetchEndTime - diskTimingStart); + // Silent: fetch completed + if (response.status === 404 || response.status === 403) { + const extension = path.endsWith('.lisp') + ? '.lisp' + : path.endsWith('.lua') + ? '.lua' + : '.mjs'; + // Handle sandboxed environments for anon URL construction + const { protocol } = getSafeUrlParts(); + const anonUrl = + protocol + + "//" + + "art.aesthetic.computer" + + "/" + + path.split("/").pop() + + extension + + "#" + + Date.now(); + if (logs.loading) + console.log("🧑‍🤝‍🧑 Attempting to load piece from anon url:", anonUrl); + response = await fetch(anonUrl, { cache: 'no-store' }); + if (response.status === 404 || response.status === 403) + throw new Error(response.status); + } + sourceToRun = await response.text(); + } + } else { + sourceToRun = source; + } + + // 🔥 Idea + // One should be able to drag a piece in, then be able to load the piece + // go back to the prompt, and return to it and it should still load + // the modded code! // Then refresh should be able to function as well? + // ⚠️ Detect if we are running `kidlisp` or JavaScript syntax. + // Note: This may not be the most reliable way to detect `kidlisp`? + // 🚗 Needs to know if the source was from a prompt with a lisp module. + // console.log("🔍 Checking if kidlisp source:", JSON.stringify(sourceToRun)); + if ( + sourceToRun.startsWith("(") || + sourceToRun.startsWith(";") || + forceKidlisp || + slug === "(...)" || + path === "(...)" || + (path && path.endsWith(".lisp")) || + (slug && slug.startsWith("$") && slug.length > 1) || // Cached codes are always kidlisp + // Pack mode KidLisp pieces - but NOT if acPACK_COLOPHON explicitly says isKidLisp: false + (typeof window !== 'undefined' && window.acPACK_PIECE && slug === window.acPACK_PIECE && + window.acPACK_COLOPHON?.piece?.isKidLisp !== false) + ) { + // Only use basic detection, not the broader isKidlispSource function + // which can incorrectly detect JavaScript as kidlisp, unless forceKidlisp is true + // or this came from parse function as kidlisp (slug/path === "(...)") + // Assume lisp. + // console.log( + // "🐍 Lisp piece detected (slug:", + // slug, + // "path:", + // path, + // "forceKidlisp:", + // forceKidlisp, + // ")", + // ); + sourceCode = sourceToRun; + originalCode = sourceCode; + + // 🔒 KidLisp pieces are always restricted (safe by design) + pieceMetadata = { + code: slug || "kidlisp", + trustLevel: "kidlisp", + anonymous: true + }; + + const isPackMode = + (typeof window !== "undefined" && window.acPACK_MODE) || + (typeof globalThis !== "undefined" && globalThis.acPACK_MODE); + if (!isPackMode) { + log.lisp.log("Initializing KidLisp piece..."); + } + + // Notify boot progress + const compileStartTime = performance.now(); + const fetchElapsed = Math.round(compileStartTime - fetchStartTime); + // console.log("📢 Disk sending boot-log: compiling kidlisp"); + // Ensure send is available before using it + if (typeof send === 'function') { + send({ + type: "boot-log", + content: `compiling kidlisp (fetch: ${fetchElapsed}ms)` + }); + } else { + log.lisp.warn("send function not available for boot-log"); + } + + // Initialize persistent cache for $codes (only needs to be done once) + initPersistentCache(store); + + // Initialize persistent image cache for paste/stamp (only needs to be done once) + imageCache.init(store); + + loadedModule = lisp.module(sourceToRun, path && path.endsWith(".lisp")); + + const compileEndTime = performance.now(); + const compileElapsed = Math.round(compileEndTime - compileStartTime); + diskTimings.compileComplete = Math.round(compileEndTime - diskTimingStart); + // Silent: KidLisp compiled + if (!isPackMode) { + log.lisp.success(`KidLisp module loaded (${compileElapsed}ms)`); + } + + // Notify boot progress + // console.log("📢 Disk sending boot-log: kidlisp compiled"); + send({ + type: "boot-log", + content: `kidlisp compiled (${compileElapsed}ms)` + }); + + // Send source file to boot canvas for display + send({ + type: "boot-file", + content: { filename: path, source: sourceCode.slice(0, 8000) } + }); + + if (devReload) { + store["publishable-piece"] = { + slug, + source: sourceToRun, + ext: "lisp", + }; + if (logs.loading) + console.log("💌 Publishable:", store["publishable-piece"]); + } + } else if ( + parsed?.ext === "lua" || + (path && path.endsWith(".lua")) || + (fullUrl && fullUrl.includes(".lua")) || + (sourceToRun.trim().startsWith("--") && + /(?:^|\n)\s*function\s+(setup|draw)\s*\(/.test(sourceToRun)) + ) { + sourceCode = sourceToRun; + originalCode = sourceCode; + pieceMetadata = { + code: slug || "l5", + trustLevel: "l5", + anonymous: true, + }; + + const compileStartTime = performance.now(); + const fetchElapsed = Math.round(compileStartTime - fetchStartTime); + send({ + type: "boot-log", + content: `compiling lua (fetch: ${fetchElapsed}ms)` + }); + + loadedModule = await l5.module(sourceToRun); + + const compileEndTime = performance.now(); + const compileElapsed = Math.round(compileEndTime - compileStartTime); + diskTimings.compileComplete = Math.round(compileEndTime - diskTimingStart); + + send({ + type: "boot-log", + content: `lua compiled (${compileElapsed}ms)` + }); + + send({ + type: "boot-file", + content: { filename: path, source: sourceCode.slice(0, 8000) } + }); + + if (devReload) { + store["publishable-piece"] = { + slug, + source: sourceToRun, + ext: "lua", + }; + if (logs.loading) + console.log("💌 Publishable:", store["publishable-piece"]); + } + } else { + if (devReload) { + store["publishable-piece"] = { slug, source: sourceToRun }; + if (logs.loading) + console.log("💌 Publishable:", store["publishable-piece"].slug); + } + + // Initialize persistent image cache for paste/stamp (also for .mjs pieces) + imageCache.init(store); + + // 🔒 Determine piece trust level + // Built-in disk pieces (aesthetic.computer/disks/*) are always trusted + const isBuiltInDisk = path && path.includes("aesthetic.computer/disks/"); + if (isBuiltInDisk) { + pieceMetadata = { code: slug || "system", trustLevel: "trusted", anonymous: false }; + } else if (slug && !devReload) { + try { + // Clear permissions from previous piece + if (pieceMetadata?.code) { + clearPermissions(pieceMetadata.code); + } + + pieceMetadata = await fetchPieceMetadata(slug); + if (logs.loading) { + console.log(`🔒 Piece metadata:`, { + code: pieceMetadata.code, + trustLevel: pieceMetadata.trustLevel, + anonymous: pieceMetadata.anonymous + }); + } + } catch (err) { + console.warn(`⚠️ Failed to fetch piece metadata, defaulting to untrusted:`, err); + pieceMetadata = { code: slug, trustLevel: "untrusted", anonymous: true }; + } + } else { + // Dev reload or no slug - default to untrusted + pieceMetadata = { code: slug || "unknown", trustLevel: "untrusted", anonymous: true }; + } + + originalCode = sourceToRun; + const updatedCode = updateCode(sourceToRun, host, debug); + + prefetches = updatedCode + .match(/"(@\w[\w.]*\/[^"]*)"/g) + ?.map((match) => match.slice(1, -1)); // for "@name/code". + + const blob = new Blob([updatedCode], { + type: "application/javascript", + }); + + blobUrl = URL.createObjectURL(blob); + + // Notify boot progress + const importStartTime = performance.now(); + const importFetchElapsed = Math.round(importStartTime - fetchStartTime); + // console.log("📢 Disk sending boot-log: importing module"); + send({ + type: "boot-log", + content: `importing module (fetch: ${importFetchElapsed}ms)` + }); + + sourceCode = updatedCode; + // Retry dynamic import on network failures (sub-dependency fetches can fail on bad connections) + { + const maxImportAttempts = 3; + for (let attempt = 1; attempt <= maxImportAttempts; attempt++) { + try { + loadedModule = await import(blobUrl); + break; + } catch (importErr) { + const isNetworkError = importErr.toString().includes("Failed to fetch"); + if (attempt < maxImportAttempts && isNetworkError) { + console.warn(`🔄 Module import attempt ${attempt}/${maxImportAttempts} failed, retrying in ${1.5 * attempt}s...`); + URL.revokeObjectURL(blobUrl); + blobUrl = URL.createObjectURL(new Blob([updatedCode], { type: "application/javascript" })); + await new Promise(r => setTimeout(r, 1500 * attempt)); + } else { + throw importErr; + } + } + } + } + + const importEndTime = performance.now(); + const importElapsed = Math.round(importEndTime - importStartTime); + diskTimings.compileComplete = Math.round(importEndTime - diskTimingStart); + // Silent: JS module imported + if (logs.loading) console.log(`✅ Module imported (${importElapsed}ms)`); + send({ + type: "boot-log", + content: `module imported (${importElapsed}ms)` + }); + + // Send source file to boot canvas for display + send({ + type: "boot-file", + content: { filename: path, source: sourceCode.slice(0, 8000) } // Limit size + }); + } + } + } catch (err) { + const moduleLoadErrorTime = performance.now(); + // console.log(`⏰ Module load error caught at ${moduleLoadErrorTime}ms`); + console.log("🟡 Error loading mjs module:", err); + + // Determine if this was a fetch failure (404/403) vs a module import/compile error. + // Only try language fallbacks if this was a missing .mjs path. + const isFetchError = err.message === "404" || err.message === "403"; + const isNetworkImportError = err.toString().includes("Failed to fetch dynamically imported module"); + const isModuleImportError = isNetworkImportError || + err.toString().includes("Unexpected token") || + err.toString().includes("SyntaxError") || + err.toString().includes("Cannot use import"); + + // If the .mjs file was fetched but failed to import (JS error), don't try fallbacks. + if (fullUrl && !fullUrl.includes('.lisp') && !fullUrl.includes('.lua') && isFetchError && !isModuleImportError) { + try { + const fallbackExts = [".lua", ".lisp"]; + let loadedFromFallback = false; + let fallbackError = null; + + for (const fallbackExt of fallbackExts) { + let fallbackUrl = fullUrl.replace(".mjs", fallbackExt); + if (logs.loading) { + console.log(`📥 Loading ${fallbackExt.slice(1)} from url:`, fallbackUrl); + } + + let response = await fetch(fallbackUrl, { cache: 'no-store' }); + + if (response.status === 404 || response.status === 403) { + // Handle sandboxed environments for anon URL construction + const { protocol } = getSafeUrlParts(); + const anonUrl = + protocol + + "//" + + "art.aesthetic.computer" + + "/" + + path.split("/").pop() + + fallbackExt + + "#" + + Date.now(); + if (logs.loading) { + console.log("🧑‍🤝‍🧑 Attempting to load piece from anon url:", anonUrl); + } + response = await fetch(anonUrl, { cache: 'no-store' }); + + if (response.status === 404 || response.status === 403) { + fallbackError = new Error(response.status); + continue; + } + } + + sourceCode = await response.text(); + originalCode = sourceCode; + + if (fallbackExt === ".lua") { + pieceMetadata = { + code: slug || "l5", + trustLevel: "l5", + anonymous: true, + }; + loadedModule = await l5.module(sourceCode); + if (devReload) { + store["publishable-piece"] = { slug, source: sourceCode, ext: "lua" }; + } + } else { + pieceMetadata = { + code: slug || "kidlisp", + trustLevel: "kidlisp", + anonymous: true + }; + loadedModule = lisp.module(sourceCode, true); + if (devReload) { + store["publishable-piece"] = { slug, source: sourceCode, ext: "lisp" }; + } + } + + loadedFromFallback = true; + break; + } + + if (!loadedFromFallback) { + throw fallbackError || new Error("404"); + } + + if (devReload && logs.loading) { + console.log("💌 Publishable:", store["publishable-piece"]); + } + } catch (err) { + // Continue with current module if one has already loaded. + console.error( + `😡 "${path}" load failure:`, + err, + "💾 First load:", + firstLoad, + ); + loadFailure = err; + $commonApi.net.loadFailureText = err.message + "\n" + sourceCode; + loading = false; + + // Only return a 404 if the error type is correct. + if (firstLoad && (err.message === "404" || err.message === "403")) { + // Check if slug is a user code (e.g. "ac25namuc") and redirect to their profile. + const handle = await resolveUserCode(slug); + if (handle) { + $commonApi.jump(`@${handle}`); + } else { + $commonApi.jump(`404~${slug}`); + } + } else { + $commonApi.notice(":(", ["red", "yellow"]); + } + return false; + } + } else { + // Module import/compile error - the .mjs file exists but has errors + // Don't try language fallbacks, just show the error + if (isNetworkImportError) { + console.error( + `😡 "${path}" module import failed (network error loading dependencies after ${3} retries):`, + err, + ); + } else if (isModuleImportError) { + console.error( + `😡 "${path}" module import failed (JS error in the piece):`, + err, + ); + } else { + console.error( + `😡 "${path}" load failure:`, + err, + "💾 First load:", + firstLoad, + ); + } + loadFailure = err; + $commonApi.net.loadFailureText = err.message + "\n" + (sourceCode || ""); + loading = false; + + // Only return a 404 if the error type is correct. + if (firstLoad && (err.message === "404" || err.message === "403")) { + // Check if slug is a user code (e.g. "ac25namuc") and redirect to their profile. + const handle = await resolveUserCode(slug); + if (handle) { + $commonApi.jump(`@${handle}`); + } else { + $commonApi.jump(`404~${slug}`); + } + } else { + $commonApi.notice(":(", ["red", "yellow"]); + } + return false; + } + } + + // console.log("Module load time:", performance.now() - moduleLoadTime, module); + // 🧨 Fail out if no module is found. + const moduleCheckTime = performance.now(); + // console.log(`⏰ Module check at ${moduleCheckTime}ms, module loaded: ${loadedModule !== undefined}`); + + if (loadedModule == null) { + loading = false; + leaving = false; + return false; + } + + // 🧩 Piece code has been loaded... + // Now we can instantiate the piece. + + pieceHistoryIndex += fromHistory === true ? 0 : 1; // Adjust the history. + + if (!debug && !firstLoad) { + // console.clear(); + headers($commonApi.dark); // Clear console and re-print headers if we are in production. + } + + // console.log("🧩", path, "🌐", host); + + $commonApi.net.devReload = devReload; // Expose to the piece if it was + // reloaded by the developer, for special logic per piece. + + // Add debug to the common api. + $commonApi.debug = debug; + + // Add reload to the common api. + $commonApi.reload = function reload({ + piece, + name, + source, + codeChannel, + createCode, + codeId, // The $code identifier (e.g., "inz") from kidlisp.com + authToken, // Auth token from kidlisp.com login + enableTrace, // Enable execution trace for kidlisp.com visualization + language, // Optional source language (e.g., "lua") + ext, // Optional source extension hint (e.g., "lua") + liveName, // Optional stable live piece name + } = {}) { + // console.log("⚠️ Reloading:", piece, name, source); + + // Check for global loading timeout first + if (checkLoadingTimeout()) { + console.log("🔴 Recovered from stuck loading state"); + } + + if (loading && source && !name && !piece) { + // If a piece is loading and we have new source code, queue the reload + // But only queue up to 10 times (1 second max wait), then force through + const queueCount = (reload._queueCount || 0) + 1; + reload._queueCount = queueCount; + + if (queueCount > 10) { + // Too many retries - force reset loading state and proceed + console.warn("🔴 Reload queue timeout - forcing loading reset"); + loading = false; + loadingStartTime = null; + reload._queueCount = 0; + } else { + console.log("🟡 Queueing reload until current load completes..."); + setTimeout( + () => + reload({ + source, + codeId, + createCode, + authToken, + enableTrace, + language, + ext, + liveName, + }), + 100, + ); + return; + } + } else { + // Reset queue counter when not queuing + reload._queueCount = 0; + } + + if (loading) { + console.log("🟡 A piece is already loading."); + return; + } + if (piece === "*refresh*") { + // padding: 8px; border-radius: 2px;console.log("💥️ Restarting system..."); + send({ type: "refresh" }); // Refresh the browser. + } else if (piece === "*piece-reload*") { + // console.log("🎨 Reloading current piece..."); + // Reload the current piece without refreshing the entire page + $commonApi.load( + { + path: currentPath, + host: currentHost, + search: currentSearch, + colon: currentColon, + params: currentParams, + hash: currentHash, + text: currentText, + }, + true, // fromHistory - don't add to history stack + alias, + true, // devReload + ); + } else if (source && !name && !piece) { + // Live reload with new source code (e.g., from kidlisp.com editor) + const isLuaReload = language === "lua" || ext === "lua"; + + // Just pass the source directly without path/text + currentText = isLuaReload ? (liveName || "l5-live") : source; + currentPath = isLuaReload ? (liveName || "l5-live") : source; + + if (isLuaReload) { + $commonApi.load( + { + source, + name: liveName || "l5-live", + ext: "lua", + search: currentSearch, + colon: currentColon, + params: currentParams, + hash: currentHash, + }, + true, // fromHistory - don't add to history stack + alias, + true, // devReload + ); + return; + } + + // Store the $code identifier for screenshots/sharing + if (codeId) { + currentOriginalCodeId = `$${codeId}`; + } + + // Store createCode flag globally so kidlisp can access it + if (createCode) { + $commonApi.kidlispCreateCode = true; + } else { + // console.log('⚠️ createCode flag is false or undefined:', createCode); + $commonApi.kidlispCreateCode = false; + } + + // Store auth token globally so kidlisp can use it for saving + if (authToken) { + $commonApi.kidlispAuthToken = authToken; + } + + // Store trace flag globally for kidlisp visualization + $commonApi.kidlispEnableTrace = enableTrace || false; + + // Reset frameCount so trace is posted on first frame of new code + if (globalKidLispInstance) { + globalKidLispInstance.frameCount = 0; + } + + $commonApi.load( + { + source: source, // Pass as source so it's used directly + name: "kidlisp-live", // Give it a name so slug won't be undefined + search: currentSearch, + colon: currentColon, + params: currentParams, + hash: currentHash, + enableTrace: enableTrace, // Pass trace flag + }, + true, // fromHistory - don't add to history stack + alias, + true, // devReload + undefined, // loadedCallback + true, // forceKidlisp - always treat as KidLisp code + ); + } else if (name && source) { + // TODO: Check for existence of `name` and `source` is hacky. 23.06.24.19.27 + // TODO: 🔥 This should somehow keep current commands or params, etc. + + // console.log( + // "🪷 Current: params:", + // currentParams, + // "text:", + // currentText, + // "path:", + // currentPath, + // ); + + // Note: This is used for live development via the socket server. + $commonApi.load({ source, name, codeChannel }, false, false, true); // Load source code. + } /*if (piece === "*" || piece === undefined /*|| currentText === piece*/ /*) {*/ else { + // console.log("💾️ Reloading:", piece, "Params:", currentParams); + // $commonApi.pieceCount = -1; // Reset pieceCount on developer reload. + // (This can be disabled while testing pieces + // that rely on pieceCount increments) + // ❤️‍🔥 TODO: Reloading the same piece should not effect piece count. + + $commonApi.load( + { + path: currentPath, + host: currentHost, + search: currentSearch, + colon: currentColon, + params: currentParams, + hash: currentHash, + text: currentText, + }, + // Use the existing contextual values when live-reloading in debug mode. + true, // (fromHistory) ... never add any reload to the history stack + alias, + true, // devReload + ); + } //else if (piece !== undefined) { + //console.log("Reloading pieceeeeeeee:", piece, parse(piece)); + //$commonApi.load(parse(piece), false, false, true); + //} + }; + + // Start the socket server + // TODO: Before we load the disk, in case of needing to reload remotely on failure? 23.01.27.12.48 + let receiver; // Handles incoming messages from the socket. + const forceProd = false; // For testing prod socket servers in development. + // TOOD: Hoist this to an environment variable? + + // Requests a session-backend and connects via websockets. + function startSocket() { + // Skip socket connections in OBJKT mode or SPIDER mode + if (getPackMode() || (typeof window !== 'undefined' && window.acSPIDER)) { + return; + } + + // 📡 Set up remote logging for LAN Dev mode + setupRemoteLogging(); + + if ( + //parsed.search?.startsWith("preview") || + //parsed.search?.startsWith("icon") + previewOrIconMode + ) { + console.log("🧦 Sockets disabled, just grabbing screenshots. 😃"); + return; + } + // Never open socket server in icon / preview mode. + // if (debug && logs.session) console.log("🫂 Finding session server..."); + socket = new Socket(debug, send); // Then redefine and make a new socket. + + const monolith = "monolith"; // "monolith"; // or `undefined` for horizontal scaling via + // jamstack + + session(slug, forceProd, monolith) + .then(async (sesh) => { + if (typeof sesh === "string") throw new Error(sesh); // Cancel if error. + const url = new URL(sesh.url); // Parse the url. + const udpUrl = new URL(sesh.udp); // Parse the udp url. + + // console.log("Session URL:", url); + + // 🩰 Fetch dev-info to get TURN host (for local dev with Docker) + // Only attempt this in dev mode (localhost) - not in production + let turnHost = null; + const isLocalDev = url.hostname === "localhost" || url.hostname.endsWith(".local"); + if (isLocalDev) { + try { + const devInfoRes = await fetch(`${sesh.url}/dev-info`); + if (devInfoRes.ok) { + const devInfo = await devInfoRes.json(); + if (devInfo.ip) { + turnHost = devInfo.ip; + log.socket.debug("Got TURN host from dev-info:", turnHost); + } + } + } catch (e) { + log.socket.debug("Could not fetch dev-info for TURN host:", e.message); + } + } + + // 🩰 UDP... (via `bios`) + // Use the protocol from the session response (http for localhost, https for prod) + log.socket.debug("Sending udp:connect to BIOS:", { + url: `${udpUrl.protocol}//${udpUrl.hostname}`, + port: udpUrl.port || (udpUrl.protocol === 'https:' ? '443' : '80'), + turnHost, + }); + send({ + type: "udp:connect", + content: { + url: `${udpUrl.protocol}//${udpUrl.hostname}`, + port: udpUrl.port || (udpUrl.protocol === 'https:' ? '443' : '80'), + turnHost, // Pass TURN host separately for ICE servers + }, + }); + + let slugBroadcastInterval; + + // 🕸️ Web Sockets + socket?.connect( + url.host + url.pathname, + (id, type, content) => { + // Globally receivable messages... + // (There are also some messages handled in `Socket`) + // 😱 Scream at everyone who is connected! + if (type === "scream" && socket?.id !== id) { + console.log("😱 Scream:", content, "❗"); + scream = content; + } + // 🎯 Jump to a specific piece! + if (type === "jump") { + $commonApi.jump(content.piece); + } + // 📱 Handle dev identity from session server (LAN Dev mode) + if (type === "dev:identity") { + devIdentity = content; + remoteLogSocket = socket; // Store socket reference for remote logging + flushRemoteLogQueue(); // Send any queued logs + $commonApi?.needsPaint?.(); + return; + } + // 🧩 Pieces get all other messages not caught in `Socket`. + receiver?.(id, type, content); // Run the piece receiver. + }, + $commonApi.reload, + "wss", + () => { + // 🔩 Connected! (Post-connection logic.) + if (USER) socket?.send("login", { user: USER }); + + // Broadcast current location. + if (HANDLE) { + // console.log("🗼 Broadcasting slug:", currentText, "for:", HANDLE); + socket?.send("location:broadcast", { + handle: HANDLE, + slug: currentText, + user: USER, // Include user for identity resolution + }); + slugBroadcastInterval = setInterval(() => { + socket?.send("location:broadcast", { + handle: HANDLE, + slug: "*keep-alive*", + user: USER, // Include user for identity resolution + }); + }, 2500); + } + + // Subscribe to code-channel as needed. + if (codeChannel) socket?.send("code-channel:sub", codeChannel); + + updateHUDStatus(); + $commonApi.needsPaint(); + codeChannelAutoLoader?.(); + // setTimeout(function () { + // currentHUDStatusColor = undefined; + // }, 250); + }, + () => { + // 💔 Disconnected! (Post-disconnection logic.) + updateHUDStatus(); + clearInterval(slugBroadcastInterval); + }, + ); + }) + .catch((err) => { + console.error("Session connection error:", err); + }); + } + + // End the socket connection before switching pieces if one exists. + // socket?.kill(); + // udp?.kill(); + // socket = undefined; + + // Delay session server by .75 seconds in order to prevent redundant + // connections being opened as pieces are quickly re-routing and jumping. + clearTimeout(socketStartDelay); + socket?.kill(); // Kill any already open socket from a previous disk. + udp?.kill(); + socket = undefined; + socketStartDelay = setTimeout(() => startSocket(), 250); + + $commonApi.net.socket = function (receive) { + receiver = receive || (() => {}); + if (!socket) { + // Just in case we init. in a `boot` before the timeout fires above. + clearTimeout(socketStartDelay); + startSocket(); + } else { + // Return the server then send an already connected message. + setTimeout(() => { + if (socket?.id) receiver(socket.id, "connected:already"); + }, 10); + } + return socket; + }; + + // ***Client Metadata Fields*** + // Set default metadata fields for SEO and sharing, + // (requires serverside prerendering, also via `index.js`). + let meta; + + if (alias === false) { + // Parse any special piece metadata. + + // Check if we're in OBJKT mode and have colophon data + let objktContext = null; + if (checkPackMode() && typeof window !== 'undefined' && window.acOBJKT_COLOPHON) { + objktContext = { author: window.acOBJKT_COLOPHON.build.author }; + } + + // Use parsed.piece for the title (just the piece name, not full slug with params) + const titleSlug = parsed.piece || slug; + + const { title, desc, ogImage, twitterImage, icon } = metadata( + location.host, // "aesthetic.computer", + titleSlug, + loadedModule.meta?.({ + ...parsed, + num: $commonApi.num, + store: $commonApi.store, + }) || inferTitleDesc(originalCode), + location.protocol, // Pass the current protocol + objktContext // Pass OBJKT context if available + ); + + meta = { + title, + desc, // Note: This doesn't auto-update externally hosted module descriptions, and may never need to? 22.07.19.06.00 + img: { + og: ogImage, + twitter: twitterImage, + icon, + }, + url: "https://aesthetic.computer/" + slug, + }; + } + + // Add meta to the common api so the data can be overridden as needed. + $commonApi.meta = (data) => send({ type: "meta", content: data }); + + // Rewrite a new URL / parameter path without affecting the history. + $commonApi.net.rewrite = (path, historical = false) => { + if (historical) $commonApi.history.push(path); + send({ type: "rewrite-url-path", content: { path, historical } }); // Jump the browser to a new url. + }; + + // Add host to the networking api. + $commonApi.net.host = host; + + // Jump the browser to a new url. + $commonApi.net.web = (url, jumpOut) => { + send({ type: "web", content: { url, blank: jumpOut } }); + }; + + $commonApi.net.refresh = () => { + send({ type: "refresh" }); + }; + + // Tell the system to wait until preloading is finished before painting. + $commonApi.net.waitForPreload = () => { + send({ type: "wait-for-preload", content: true }); + }; + + // Tell the system that all preloading is done. + $commonApi.net.preloaded = () => { + send({ type: "preload-ready", content: true }); + }; + + $commonApi.content = new Content(); + + $commonApi.dom = {}; + + $commonApi.dom.clear = () => { + $commonApi.content.remove(); + }; + + $commonApi.dom.html = (strings, ...vars) => { + const processed = defaultTemplateStringProcessor(strings, ...vars); + $commonApi.content.add(processed); + }; + + $commonApi.dom.css = (strings, ...vars) => { + const processed = defaultTemplateStringProcessor(strings, ...vars); + $commonApi.content.add(``); + }; + + $commonApi.dom.javascript = (strings, ...vars) => { + const processed = defaultTemplateStringProcessor(strings, ...vars); + $commonApi.content.add(``); + }; + // 💾 Uploading + Downloading + // Add download event to trigger a file download from the main thread. + $commonApi.download = (filename, data, modifiers) => { + send({ type: "download", content: { filename, data, modifiers } }); + }; + + // * Preload * + // Add preload to the boot api. + // Accepts paths local to the original disk server, full urls, and demos. + // Usage: preload("demo:drawings/2021.12.12.17.28.16.json") // pre-included + // preload("https://myserver.com/test.json") // remote + // preload("drawings/default.json") // hosted with disk + // Results: preload().then((r) => ...).catch((e) => ...) // via promise + + $commonApi.net.preload = async function ( + path, + parseJSON = true, + progressReport, + options = {}, + ) { + let extension; + + const rejection = (reject) => { + reject(new DOMException("Aborted", "AbortError")); + }; + + // 🎒 PACK mode: Check VFS for embedded files first (used for offline bundles) + const packModeActive = + (typeof window !== "undefined" && window.acPACK_MODE) || + (typeof globalThis !== "undefined" && globalThis.acPACK_MODE); + const packVfs = + (typeof window !== "undefined" && window.VFS) || + (typeof globalThis !== "undefined" && globalThis.VFS); + + if (packModeActive && packVfs) { + let vfsPath = typeof path === "object" ? path.path : path; + // Normalize path: strip leading slash and "aesthetic.computer/" prefix + if (vfsPath.startsWith('/')) vfsPath = vfsPath.slice(1); + if (vfsPath.startsWith('aesthetic.computer/')) vfsPath = vfsPath.slice('aesthetic.computer/'.length); + + const vfsEntry = packVfs[vfsPath]; + if (vfsEntry) { + const ext = typeof path === "object" ? path.extension : vfsPath.split(".").pop().split("?")[0]; + progressReport?.(1); // Report completion immediately for VFS files + + if (ext === "json") { + // JSON files in VFS: { content: "...", type: 'json' } or { content: "...", type: 'base64' } + let jsonStr; + if (typeof vfsEntry === 'object' && vfsEntry.content !== undefined) { + if (vfsEntry.type === 'base64') { + jsonStr = atob(vfsEntry.content); + } else { + jsonStr = vfsEntry.content; // type: 'json' stores content as string + } + } else if (typeof vfsEntry === 'string') { + jsonStr = vfsEntry; + } + if (jsonStr !== undefined) { + return parseJSON ? JSON.parse(jsonStr) : jsonStr; + } + } else if (ext === "xml") { + let xmlStr; + if (typeof vfsEntry === 'object' && vfsEntry.content !== undefined) { + xmlStr = vfsEntry.type === 'base64' ? atob(vfsEntry.content) : vfsEntry.content; + } else { + xmlStr = vfsEntry; + } + const parser = new DOMParser(); + return parser.parseFromString(xmlStr, "text/xml"); + } + // For other types, fall through to normal handling if not JSON/XML + } + } + + if (soundWhitelist.includes(path)) { + extension = "ogg"; + } else { + if (typeof path === "object") { + extension = path.extension; + path = path.path; + } else { + extension = path.split(".").pop().split("?")[0]; + } + + if (path.indexOf("/") === 0) path = path.slice(1); + + try { + const url = new URL(path); + if (url.protocol === "demo:") { + path = `/demo/${url.pathname}`; + } else if (url.protocol !== "https:") { + } + } catch { + // Handle sandboxed environments for path construction + const { protocol, hostname } = getSafeUrlParts(); + const originalPath = path; + + // Apply the same origin-aware logic as in module loading + let baseUrl; + if (path.startsWith('aesthetic.computer/')) { + // Check if we're in a development environment (localhost with port) + baseUrl = getBuiltInPieceBaseUrl(); + + // Only strip "aesthetic.computer/" if we're using the main production domain + if (baseUrl === 'https://aesthetic.computer') { + path = path.substring('aesthetic.computer/'.length); + } + } else { + baseUrl = `${protocol}//${hostname}`; + } + + path = `${baseUrl}/${path}`; + + // Removed debug log for font glyph preload + } + } + + if (extension === "xml" || extension === undefined) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + options.signal?.addEventListener("abort", () => { + xhr.abort(); + rejection(reject); + }); + + xhr.open("GET", path, true); + xhr.onprogress = function (event) { + const progress = min(event.loaded / event.total, 1); + if (debug && logs.download) { + console.log(`💈 XML Download: ${progress * 100}%`); + } + progressReport?.(progress); + }; + xhr.onload = function () { + if (xhr.status === 200 || xhr.status === 304) { + resolve(xhr.responseXML || xhr.responseText); + } else { + reject(xhr.status); + } + }; + xhr.onerror = reject; + xhr.send(); + }); + } else if (extension === "json") { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + options.signal?.addEventListener("abort", () => { + xhr.abort(); + rejection(reject); + }); + + xhr.open("GET", path, true); + xhr.onprogress = function (event) { + const progress = min(event.loaded / event.total, 1); + if (debug && logs.download) { + console.log(`💈 JSON Download: ${progress * 100}%`); + } + progressReport?.(progress); + }; + xhr.onload = function () { + if (xhr.status === 200 || xhr.status === 304) { + resolve(parseJSON ? JSON.parse(xhr.response) : xhr.response); + } else { + reject(xhr.status); + } + }; + xhr.onerror = reject; + xhr.send(); + }); + } else if ( + extension === "webp" || + extension === "jpg" || + extension === "gif" || + extension === "jpeg" || + extension === "png" + ) { + return new Promise((resolve, reject) => { + if (options.signal?.aborted) { + rejection(reject); + return; + } + + send({ type: "load-bitmap", content: path }); + preloadPromises[path] = { resolve, reject }; + + options.signal?.addEventListener("abort", () => { + send({ type: "load:abort", content: path }); + rejection(reject); + }); + }); + } else if ( + extension === "m4a" || + extension === "ogg" || + extension === "webm" || + extension === "wav" || + extension === "mp3" + ) { + return new Promise((resolve, reject) => { + if (options.signal?.aborted) { + rejection(reject); + return; + } + + send({ type: "sfx:load", content: path }); + preloadPromises[path] = { resolve, reject }; + + options.signal?.addEventListener("abort", () => { + rejection(reject); + }); + }); + } else if (extension === "zip") { + return new Promise((resolve, reject) => { + if (options.signal?.aborted) { + rejection(reject); + return; + } + + send({ type: "zip:load", content: path }); + preloadPromises[path] = { resolve, reject }; + + options.signal?.addEventListener("abort", () => { + rejection(reject); + }); + }); + } + }; + + // 🎬 Load animated WebP and decode all frames + $commonApi.net.preloadAnimatedWebp = async function (url) { + return new Promise((resolve, reject) => { + send({ type: "load-animated-webp", content: url }); + preloadPromises[url] = { resolve, reject }; + }); + }; + + $commonApi.slug = slug; + $commonApi.piece = slug?.split("~")[0].split(":")[0]; + $commonApi.query = Object.fromEntries(new URLSearchParams(search)); + $commonApi.params = params || []; + $commonApi.colon = colon || []; + $commonApi.hash = hash; // Add hash support for painting codes like #2ug + + // Initialize duration tracking from query parameters + if ($commonApi.query.duration) { + const duration = parseFloat($commonApi.query.duration); + if (!isNaN(duration) && duration > 0) { + durationTotal = duration; + durationStartTime = pageLoadTime; // Start from page load time immediately + durationProgress = 0; + durationCompleted = false; + durationBlinkState = false; + console.log("⏱️ Duration parameter detected:", duration, "seconds - starting from page load time:", pageLoadTime); + } + } + + $commonApi.load = async function () { + // Load a piece, wrapping it in a leave function so a final frame + // plays back. + leaving = true; + graph.unmask(); // Clear any active mask when leaving a piece + + return new Promise((resolve) => { + leaveLoad = async () => { + const loaded = await load(...arguments); + resolve(loaded); // Resolve with `true` or `false`. + }; + }); + }; + + // 💡 Eventually this could merge with net.web so there is one command + // to either go to a piece within the system if one loads... or an entirely + // different url somehow! 23.02.07.21.21 + + $commonApi.alias = function alias(name, colon, params) { + $commonApi.jump( + name + + colon.map((c) => `:` + c).join("") + + params.map((p) => `~` + p).join(""), + true, + true, + ); + }; + + // Go back to the previous piece, or to the prompt if there is no history. + $commonApi.back = () => { + if (pieceHistoryIndex > 0) { + send({ type: "back-to-piece" }); + } else { + $commonApi.jump("prompt"); + } + }; + + if (!alias) $commonApi.pieceCount += 1; // Don't bump pieceCount if aliased. + + // Load typeface if it hasn't been yet. + // (This only has to happen when the first piece loads.) + // Skip expensive typeface loading - use on-demand loading for all glyphs + const skipTypefacePreload = true; // Always skip preload, load on-demand instead + // console.log(`⏰ Typeface skip check: skipTypefacePreload=${skipTypefacePreload}, tf exists=${!!tf}`); + + const typefaceLoadStartTime = performance.now(); + if (!tf && !skipTypefacePreload) { + tf = await new Typeface(/*"unifont"*/).load($commonApi.net.preload); + const typefaceLoadEndTime = performance.now(); + // console.log(`⏰ Typeface load: ${Math.round(typefaceLoadEndTime - typefaceLoadStartTime)}ms`); + } else if (skipTypefacePreload) { + // Create a Typeface instance and call load() to set up Proxy + tf = new Typeface(/*"unifont"*/); + // In TV/headless mode (oven captures), await font load to ensure glyphs + // are available before first paint. Otherwise load on-demand (fire-and-forget). + const isHeadless = typeof window !== "undefined" && + new URLSearchParams(window.location?.search).get("tv") === "true"; + if (isHeadless) { + try { + await tf.load($commonApi.net.preload); + if (typeof window !== "undefined") { + window.acFontsReady = true; + window.__acTypeface = tf; + } + console.log("🔤 font_1 awaited load complete (tv mode)"); + } catch (err) { + console.error("🔤 tf.load() FAILED:", err?.message || err); + if (typeof window !== "undefined") window.acFontsReady = "error"; + } + } else { + tf.load($commonApi.net.preload).then(() => { + if (typeof window !== "undefined") { + window.acFontsReady = true; + window.__acTypeface = tf; + } + }).catch(() => {}); + } + const typefaceLoadEndTime = performance.now(); + // console.log(`⏰ Typeface load skipped (on-demand mode), Proxy initialized in ${Math.round(typefaceLoadEndTime - typefaceLoadStartTime)}ms`); + } else { + // console.log(`⏰ Typeface already loaded`); + } + + $commonApi.typeface = tf; // Expose a preloaded typeface globally. + if (typeof window !== "undefined") window.__acTypeface = tf; // Expose for oven diagnostics + ui.setTypeface(tf); // Set the default `ui` typeface. + + // Initialize HUD metrics now that the primary typeface is available + currentHUDLabelBlockWidth = tf.blockWidth; + currentHUDLabelBlockHeight = tf.blockHeight; + currentHUDShareWidth = tf.blockWidth * "share ".length; + currentHUDEditWidth = tf.blockWidth; + + // Initialize MatrixChunky8 font for QR code text rendering and keep it warm across pieces + // Always register the font so kidlisp.com previews still render MatrixChunky8 labels + const matrixFontStartTime = performance.now(); + let matrixFont = typefaceCache.get("MatrixChunky8"); + + if (!matrixFont) { + matrixFont = new Typeface("MatrixChunky8"); + typefaceCache.set("MatrixChunky8", matrixFont); + } + + if (!matrixFont.__loadPromise) { + matrixFont.__loadPromise = matrixFont.load($commonApi.net.preload); + } + + // Only run the heavier glyph preload pass when auth UI is enabled; kidlisp.com preview just needs the font registered + const skipMatrixGlyphPreload = typeof window !== "undefined" && window.acNOAUTH; + + if (!skipMatrixGlyphPreload) { + await matrixFont.__loadPromise; + + if (matrixFont && !matrixFont.__preloadedCommonGlyphs) { + const glyphPreloadStartTime = performance.now(); + // Pre-load common QR code characters to avoid fallback during rendering + const commonQRChars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz$"; + + // Access each character to trigger the Proxy loading mechanism and collect promises + const glyphPromises = []; + + for (const char of commonQRChars) { + // Accessing the glyph triggers the Proxy to start loading it + const glyph = matrixFont.glyphs[char]; + + // If it's a Promise, collect it to wait for loading + if (glyph && typeof glyph.then === "function") { + glyphPromises.push(glyph); + } + } + + // Wait for all common glyphs to finish loading + if (glyphPromises.length > 0) { + try { + await Promise.all(glyphPromises); + } catch (e) { + console.warn("🔤 Some MatrixChunky8 glyphs failed to preload:", e); + } + } + + const glyphPreloadEndTime = performance.now(); + // console.log(`⏰ MatrixChunky8 glyph preload: ${Math.round(glyphPreloadEndTime - glyphPreloadStartTime)}ms`); + matrixFont.__preloadedCommonGlyphs = true; + } + + const matrixFontEndTime = performance.now(); + // console.log(`⏰ MatrixChunky8 total: ${Math.round(matrixFontEndTime - matrixFontStartTime)}ms`); + } else { + // console.log("⏰ MatrixChunky8 preload skipped (noauth mode)"); + } + + /** + * @function video + * @descrption Make a live video feed. Returns an object that links to current frame. + * @param {string} type - "camera" or "camera-update" or see below. 💡 + * @param {object} options - *unimplemented* { src, width, height } + */ + + let videoTimeout; + + $commonApi.video = function (type, options) { + // TODO: ❤️‍🔥 Prevent fast multiple taps while camera is updating... + + // TODO: Options could eventually be { src, width, height } + // const vid = video("youtube-link"); + // const vid = video("tiktok:@whistlegraph"); + // https://codepen.io/oceangermanique/pen/LqaPgO + + if (videoSwitching === false) { + if (type === "camera:update") { + lastActiveVideo = activeVideo || lastActiveVideo; + activeVideo = null; + } + + clearTimeout(videoTimeout); + videoTimeout = setTimeout(() => { + send({ type: "video", content: { type, options } }); + }, 50); + + if (type === "camera:update") videoSwitching = true; + } + + // Return an object that can grab whatever the most recent frame of + // video was. + return videoFrame; + }; + + function videoFrame(shader) { + if (activeVideo) { + const { width, pixels } = activeVideo; + + if (shader) { + for (let i = 0; i < pixels.length; i += 4) { + const c = pixels.subarray(i, i + 4); + const p = { x: (i / 4) % width, y: floor(i / 4 / width) }; + shader(p, c); + } + } + } else if (lastActiveVideo) { + // Make it all red... + const { pixels } = lastActiveVideo; + for (let i = 0; i < pixels.length; i += 4) { + pixels[i] = 255; + pixels[i + 1] = 0; + pixels[i + 2] = 0; + pixels[i + 3] = 255; + } + } + return activeVideo || lastActiveVideo; + } + + // This function actually hotSwaps out the piece via a callback from `bios` once fully loaded via the `loading-complete` message. + hotSwap = () => { + loadedCallback?.(); // Run the optional load callback. (See also: `jump`) + + $commonApi.rec.loadCallback?.(); // Start any queued tape. + $commonApi.rec.loadCallback = null; + + // 📤 Send ready signal to parent window (e.g., kidlisp.com editor) + // Note: disk.mjs runs in a Web Worker, so window may not exist + const hasWindow = typeof window !== 'undefined'; + + try { + if (hasWindow && window.parent && window.parent !== window) { + window.parent.postMessage({ type: "kidlisp-ready" }, "*"); + } + // If no window (worker context), silently skip + } catch (e) { + // Silently fail if in worker context or cross-origin + } + + // Process any queued piece-reload that arrived before piece was ready + if (pendingPieceReload && $commonApi.reload) { + log.piece.log("Processing queued piece-reload from hotSwap"); + const pending = pendingPieceReload; + pendingPieceReload = null; + // Use setTimeout to ensure all initialization is complete + setTimeout(() => { + $commonApi.reload({ + source: pending.source, + codeId: pending.codeId, + createCode: pending.createCode, + authToken: pending.authToken, + enableTrace: pending.enableTrace, + language: pending.language, + ext: pending.ext, + liveName: pending.liveName, + }); + }, 50); + } + + module = loadedModule; + + // 🧹 Reset KidLisp baked layers when returning to the prompt to avoid stale flashes + if (module?.system?.startsWith("prompt") && + globalKidLispInstance?.clearBakedLayers) { + globalKidLispInstance.clearBakedLayers(); + graph.unmask(); // Clear mask when returning to prompt from KidLisp + } + + if (!module.system?.startsWith("world")) + $commonApi.system.world.teleported = false; + + // 📚 nopaint system + if ( + !Array.isArray(module) && + (module.system?.startsWith("nopaint") || + typeof module?.brush === "function" || + typeof module?.lift === "function" || + typeof module?.filter === "function") + ) { + // If there is no painting is in ram, then grab it from the local store, + // or generate one. + const modsys = module.system || "nopaint"; + + // TODO: 🖌️💬 Integrate nopaint system with chat. + // console.log(chat); + + const chatEnabled = false; + + $commonApi.system.nopaint.bakeOnLeave = + modsys.split(":")[1] === "bake-on-leave"; // The default is to `bake` at the end of each gesture aka `bake-on-lift`. + + boot = async ($) => { + // Reset scroll state when a new piece boots + graph.resetScrollState(); + // Reset mask state to prevent masks from persisting across pieces + graph.unmask(); + + const booter = module.boot || nopaint_boot; + booter($); + if (chatEnabled) { + try { + const chatModule = await import("../disks/chat.mjs"); + chatModule.boot($); + } catch (err) { + console.log("💬 Chat disabled in OBJKT mode"); + } + } + }; + + sim = module.sim || defaults.sim; + paint = async ($) => { + let painted = false; + + if (module.paint) { + painted = module.paint($); + if ($.system?.nopaint) { + $.system.nopaint.needsPresent = true; + } + } + + // 🎨 OVERLAY SUPPORT: Call overlay function for preview rendering with painting coordinates + if (module.overlay && $.system.nopaint?.brush?.dragBox) { + // Transform painting coordinates to screen coordinates for direct screen rendering + const originalDragBox = $.system.nopaint.brush.dragBox; + const zoom = $.system.nopaint.zoomLevel; + const tx = $.system.nopaint.translation.x; + const ty = $.system.nopaint.translation.y; + + // Create screen-space dragBox + const screenDragBox = { + x: originalDragBox.x * zoom + tx, + y: originalDragBox.y * zoom + ty, + w: originalDragBox.w * zoom, + h: originalDragBox.h * zoom + }; + + // Temporarily replace the dragBox with screen coordinates + const originalBrush = $.system.nopaint.brush; + $.system.nopaint.brush = { ...originalBrush, dragBox: screenDragBox }; + + // Add color word for overlay function + $.color = $.system.nopaint.color; + + // Add mark word for overlay function (preview dragBox) + $.mark = $.system.nopaint.brush?.dragBox; + + // Render overlay directly to screen buffer + module.overlay($); + + // Restore original dragBox + $.system.nopaint.brush = originalBrush; + } + + // 📊 Always render nopaint performance HUD if enabled (regardless of module.paint result) + if (system === "nopaint" && nopaintPerf) { + nopaint_renderPerfHUD($); + // Force continuous painting for nopaint system when perf HUD is enabled + painted = true; + } + + if (painted) { + return painted; + } + + // TODO: Pass in extra arguments here that flag the wipe. + if (chatEnabled) { + try { + const chatModule = await import("../disks/chat.mjs"); + chatModule.paint($, { embedded: true }); // Render any chat interface necessary. + } catch (err) { + console.log("💬 Chat disabled in OBJKT mode"); + } + } + }; + beat = module.beat || defaults.beat; + brush = module.brush; + lift = module.lift; + filter = module.filter; + act = ($) => { + nopaint_act($); // Inherit base functionality. + + // 🎯 IMMEDIATE BAKING: Fix race condition by triggering bake immediately after lift + const np = $.system?.nopaint; + if (np?.needsBake === true && bake) { + // 📊 Trigger bake flash effect and beep sound + nopaint_triggerBakeFlash(); + + // 🔊 Microwave-style beep for bake completion (only for robo) + if (currentPath?.includes('robo')) { + $commonApi.sound.synth({ + tone: 800, // Higher pitched beep like a microwave + duration: 0.1, + attack: 0.01, + decay: 0.5, + volume: 0.3, + }); + } + + // 🎨 ELEGANT BRUSH PATTERN: Call brush or lift function for final painting + if (brush || lift) { + const finalBrushApi = { ...$ }; + // Add top-level 'color' word that maps to system.nopaint.color + finalBrushApi.color = $.system.nopaint.color; + + // Add top-level 'mark' word that maps to finalDragBox for brush + finalBrushApi.mark = $.system.nopaint.finalDragBox; + + // 🎯 Use preserved coordinates since brush is null at bake time + const preservedDragBox = $.system.nopaint.finalDragBox; + const preservedStartDrag = $.system.nopaint.finalStartDrag; + + if (preservedDragBox) { + // Create a pen object with the preserved coordinates + finalBrushApi.pen = { + dragBox: preservedDragBox, + x: preservedStartDrag?.x || preservedDragBox.x, + y: preservedStartDrag?.y || preservedDragBox.y + }; + } else { + finalBrushApi.pen = $.system.nopaint.brush; // Fallback to original (likely null) + } + + finalBrushApi.lift = true; // 🎯 Single clean flag for final brush call + + $.page($.system.painting); // Set context to painting surface + + // Call lift function if it exists, otherwise fall back to brush + if (lift) { + lift(finalBrushApi); // Call lift for final painting + } else if (brush) { + brush(finalBrushApi); // Call brush for final painting (legacy) + } + + $.page($.screen); // Reset context + } + + $.page($.system.painting); + bake($); + + // 🧹 CLEAR BUFFER: Prevent flickering by clearing buffer BEFORE presentation + $.page($.system.nopaint.buffer).wipe(255, 255, 255, 0); + + $.page($.screen); + np.present($); + np.needsBake = false; + + // 📸 Snapshot for undo after every stroke bake. + addUndoPainting($.system.painting, "stroke"); + + // 🚀 Broadcast bake completion + $commonApi.broadcastPaintingUpdateImmediate("baked", { + source: "immediate_bake", + piece: loadedModule?.meta?.()?.title || "unknown" + }); + } + + if (module.act) { + return module.act($); + } else { + return defaults.act($); + } + }; + leave = ($) => { + module.leave?.($); // Run the custom leave. + nopaint.leave($); // And the inherited default leave from nopaint. + }; + bake = module.bake || nopaint.bake; + system = "nopaint"; + } else if (module.system?.startsWith("prompt")) { + // 📖 prompt system + // Default wrap to "word" if using `prompt:character`. + const wrap = + module.wrap || + (module.system.indexOf("character") > -1 ? "word" : undefined); + + if (module.scheme) { + if ( + module.scheme.dark === undefined && + module.scheme.light === undefined + ) { + const defaultScheme = { ...module.scheme }; + module.scheme.dark = module.scheme.light = defaultScheme; + keys(module.scheme).forEach((key) => { + if (key !== "dark" && key !== "light") delete module.scheme[key]; + }); + } + } + + boot = async ($) => { + await prompt.prompt_boot( + $, + { + prompt: module.prompt, + program: { + before: module.before, + after: module.after, + }, + hint: module.system.split(":").slice(1).join(":"), // See `ask.ts`. + forgetful: module.forgetful || false, + memory: module.memory || Infinity, + gutterMax: module.gutterMax, + lineSpacing: module.lineSpacing, + }, + module.reply, + module.halt, + module.scheme, + wrap, + module.copied, + module.activated, + ); + await module.boot?.($); + }; + + sim = ($) => { + module.sim?.($); + prompt.prompt_sim($); + }; + + paint = ($) => { + let noPaint = module.paint?.($); // Carry the return. + + // Paint an illustration if it exists and `paint` is not defined. + if (!module.paint && module.illustration) { + $.wipe(...module.scheme.dark.background.slice(0, 3)); + $.stamp(module.illustration, { center: "x", bottom: 0 }); + noPaint = true; + } + + const promptPainted = prompt.prompt_paint($); + return noPaint || promptPainted; + }; + + beat = module.beat || defaults.beat; + + act = ($) => { + module.act?.($); + prompt.prompt_act($); + }; + + leave = ($) => { + module.leave?.($); + prompt.prompt_leave($); + }; + + // console.log("📨 Module exports check:", { + // hasReceive: !!module.receive, + // receiveType: typeof module.receive, + // receiveIsDefault: module.receive === defaults.receive, + // }); + + receive = module.receive || defaults.receive; + + system = "prompt"; + } else if (module.system?.startsWith("world")) { + // 🗺️ world system + boot = async ($) => { + await world.world_boot($, module.world); + await module.boot?.($); + }; + + sim = ($) => { + world.world_sim($); + module.sim?.($); + }; + + paint = ($) => { + if (!world.coversScreen($.screen)) module.background?.($); + world.world_paint($, module.paint, module.curtain); + }; + + beat = module.beat || defaults.beat; + + act = ($) => { + world.world_act($); + module.act?.($); + }; + + leave = ($) => { + world.world_leave($); + module.leave?.($); + }; + + system = "world"; + } else if (module.system?.startsWith("fps")) { + // 🧊 fps system + let doll; + + boot = ($) => { + doll = new CamDoll($.Camera, $.Dolly, { + fov: 80, + z: 0, + y: 0, + sensitivity: 0.002, + }); + $commonApi.system.fps = { doll, renderStats: graph.renderStats }; + module?.boot?.($); + }; + + sim = ($) => { + doll?.sim(); + module?.sim?.($); + }; + + act = ($) => { + doll?.act($.event); + module?.act?.($); + }; + + paint = module.paint || defaults.paint; + leave = module.leave || defaults.leave; + + system = "fps"; + } else if (module.system?.startsWith("game")) { + // 🎲 🎮 game system + console.log("game!"); + // TODO: ⚠️ Make game template. 25.06.05.09.19 + } else { + const moduleAssignStartTime = performance.now(); + // console.log(`⏰ Starting module function assignment at ${moduleAssignStartTime}ms`); + + // 🧩 piece + // Reset scroll state when a piece loads + graph.resetScrollState(); + // Reset mask state to prevent masks from persisting across pieces + graph.unmask(); + + boot = module.boot || defaults.boot; + sim = module.sim || defaults.sim; + paint = module.paint || defaults.paint; + beat = module.beat || defaults.beat; + act = module.act || defaults.act; + leave = module.leave || defaults.leave; + + // console.log("📨 Module exports check:", { + // hasReceive: !!module.receive, + // receiveType: typeof module.receive, + // receiveIsDefault: module.receive === defaults.receive + // }); + + receive = module.receive || defaults.receive; // Handle messages from BIOS + + // ⏱️ Background mode: pieces can export `background = true` to keep sim() + // running when the page is hidden (e.g. clock.mjs for audio scheduling) + pieceBackground = !!module.background; + + // 🎨 AUTO-DETECT BRUSH FUNCTIONS: If a piece exports a brush or lift function, automatically use nopaint system + system = module.system || (module.brush || module.lift ? "nopaint" : null); + + // 🎨 AUTO-GENERATE BAKE: If using brush/lift but no explicit bake function, create a default one + if ((module.brush || module.lift) && !module.bake) { + bake = ({ paste, system, page }) => { + paste(system.nopaint.buffer); + page(system.nopaint.buffer).wipe(255, 255, 255, 0); + }; + } + + const moduleAssignEndTime = performance.now(); + // console.log(`⏰ Module function assignment complete (${Math.round(moduleAssignEndTime - moduleAssignStartTime)}ms)`); + + // delete $commonApi.system.name; // No system in use. + } + + preview = module.preview || defaults.preview; // Set preview method. + icon = module.icon || defaults.icon; // Set preview method. + + // ♻️ Reset global state for this piece. + paintCount = 0n; + paintingAPIid = 0n; + simCount = 0n; + booted = false; + // initialSim = true; + activeVideo = null; + videoSwitching = false; + lastActiveVideo = null; + keys(preloadPromises).forEach((key) => preloadPromises[key].reject(key)); + preloadPromises = {}; + noPaint = false; + + // 🎞️ Reset piece-level FPS control + pieceFPS = null; + lastPaintTime = 0; + lastPaintOut = undefined; + shouldSkipPaint = false; + pieceFrameCount = 0; + // Reset acPieceReady so the signal fires again for the next piece + if (typeof window !== "undefined") { + window.acPieceReady = false; + } + + // 🎬 Piece Transition: Capture CURRENT screen pixels BEFORE clearing + // This enables piece-to-piece morphing transitions (not just noise16→piece) + // Skip entirely if TRANSITION_TYPE is "none" + if (TRANSITION_TYPE !== "none") { + // Check if buffer is valid (not detached from transfer to main thread) + try { + if (screen?.pixels && screen.width > 0 && screen.height > 0 && + screen.pixels.buffer && !screen.pixels.buffer.detached && screen.pixels.byteLength > 0) { + golTransition.overlayPixels = new Uint8ClampedArray(screen.pixels); + golTransition.width = screen.width; + golTransition.height = screen.height; + + // Initialize and START transition immediately (loading phase) + initGOLCells(golTransition.width, golTransition.height); + golTransition.generation = 0; + golTransition.active = true; + console.log(`🎬 Transition: Started ${TRANSITION_TYPE} (loading phase)`, screen.width, "x", screen.height); + } + } catch (e) { + // Buffer may be detached if pixels were transferred - skip transition + console.log("🎬 Transition: Could not capture pixels (buffer detached), skipping"); + golTransition.overlayPixels = null; + } + } + + // Note: transition state is now set above when starting + + // 🧹 Clear screen buffer when loading a new piece to prevent stale pixels + // This fixes the blank screen issue when backspacing from kidlisp to prompt + graph.clear(); + + formsSent = {}; // Clear 3D list for GPU. + currentPath = path; + currentHost = host; + currentSearch = search; + // console.log("Set currentSearch to:", search); + firstPreviewOrIcon = true; + + // Clear clock piece cached code when leaving (will be set by clock piece if it caches) + cachedClockCode = null; + + // Parse search parameters properly to check for nolabel + // Also hide label in pack mode (standalone bundles have no URL params) + hideLabel = (typeof window !== "undefined" && window.acPACK_MODE && !window.acKEEP_LABEL) || false; + if (parsed.search) { + const searchParams = new URLSearchParams(parsed.search); + if (searchParams.has("nolabel")) hideLabel = true; + } + + currentColon = colon; + currentParams = params; + currentHash = hash; + // sound = null; + glazeEnabled = null; + // soundClear = null; + hourGlasses.length = 0; + // labelBack = false; // Now resets after a jump label push. 25.03.22.21.36 + + // Parse search parameters properly to check for preview and icon + previewMode = false; + iconMode = false; + if (parsed.search) { + const searchParams = new URLSearchParams(parsed.search); + previewMode = searchParams.has("preview"); + iconMode = searchParams.has("icon"); + } + + // console.log("🔴 PREVIEW OR ICON:", PREVIEW_OR_ICON, "Preview mode:", previewMode, "Icon mode:", iconMode); + // console.log("📑 Search:", parsed.search); + // console.log("🖼️ ICON MODE:", iconMode); + + previewOrIconMode = previewMode || iconMode; + paintings = {}; // Reset painting cache. + // Reset KidLisp persistent paintings cache to avoid stale embedded content + if (globalKidLispInstance?.persistentPaintings) { + globalKidLispInstance.persistentPaintings.clear(); + } + prefetches?.forEach((p) => prefetchPicture(p)); // Prefetch parsed media. + graph.color2(null); // Remove any secondary color that was added from another piece. + // 🐢 Reset turtle state. + turtleAngle = 270; + turtleDown = false; + turtlePosition = { x: screen.width / 2, y: screen.height / 2 }; + + //$api.fps = function (newFps) { + // No longer reset global FPS when loading new pieces + // send({ type: "fps-change", content: undefined }); + //}; + + // 🪧 See if notice needs to be shown. + if ($commonApi.query.notice === "success") { + notice = "PRINTED!"; + noticeColor = ["white", "green"]; + noticeBell(cachedAPI); + } else if ($commonApi.query.notice === "cancel") { + notice = "CANCELLED"; + noticeColor = ["yellow", "red"]; + noticeBell(cachedAPI, { tone: 300 }); + } else if ($commonApi.query.notice === "email-verified") { + notice = "Email verified!"; + noticeColor = ["white", "blue"]; + noticeBell(cachedAPI, { tone: 300 }); + } else if ($commonApi.query.notice?.length > 0) { + notice = $commonApi.query.notice; + noticeColor = ["white", "green"]; + noticeBell(cachedAPI, { tone: 300 }); + } + + if (!alias) { + currentHUDTxt = slug; // Update hud if this is not an alias. + + // Special HUD label formatting for specific pieces + if (slug === "r8dio") { + // Display as "r8Dio" with "8D" in magenta + currentHUDTxt = "r\\255,0,255\\8D\\255,200,220\\io"; + currentHUDPlainTxt = "r8Dio"; + } else { + currentHUDPlainTxt = stripColorCodes(slug); + } + + // Hide HUD label for RGB-only kidlisp pieces (e.g., "255 0 0") + // since they're just background colors and don't need to be displayed + if (sourceCode && lisp?.isValidRGBString && lisp.isValidRGBString(sourceCode.trim())) { + currentHUDTxt = undefined; + currentHUDPlainTxt = undefined; + } + } + if (module.nohud || system === "prompt") { + currentHUDTxt = undefined; + currentHUDPlainTxt = undefined; + } + // Clear original code ID for non-$code pieces (unless this was a $code load) + if (!slug?.startsWith("$")) { + currentOriginalCodeId = undefined; + currentHUDAuthor = null; // Reset author for non-KidLisp pieces + currentHUDHits = null; + } + currentHUDOffset = undefined; // Always reset these to the defaults. + currentHUDTextColor = undefined; + currentHUDStatusColor = "red"; //undefined; + currentHUDButton = undefined; + currentHUDScrub = 0; + currentHUDLeftPad = 0; + currentHUDQR = null; // Reset QR code when loading new piece + currentHUDQRCells = null; + currentHUDSuperscript = null; // Reset custom superscript + forceTinyHudLabel = false; // Reset tiny label flag + // currentPromptButton = undefined; + + // Push last piece to a history list, skipping prompt and repeats. + if ( + !fromHistory && + currentText && + currentText !== "prompt" && + currentText !== $commonApi.history[$commonApi.history.length - 1] + ) { + $commonApi.history.push(currentText); + } + + currentText = slug; + currentCode = sourceCode; + + // Broadcast location change to session server + if (HANDLE && socket?.connected && slug !== "*keep-alive*") { + socket.send("location:broadcast", { + handle: HANDLE, + slug: slug, + user: USER, + }); + if (logs.socket) console.log("📍 Broadcasting location:", slug); + } + + if (screen) screen.created = true; // Reset screen to created if it exists. + + cursorCode = "precise"; // Set default cursor. + + if (firstLoad === true) { + firstLoad = false; + // firstPiece = path; + // firstParams = params; + // firstSearch = search; + } + }; + + send({ + type: "disk-loaded", + content: { + path, + host, + search, + params, + hash, + text: slug, + pieceCount: $commonApi.pieceCount, + pieceHasSound: true, // TODO: Make this an export flag for pieces that don't want to enable the sound engine. 23.07.01.16.40 + background: pieceBackground, // ⏱️ Piece wants sim() to keep running when hidden + // 📓 Could also disable the sound engine if the flag is false on a subsequent piece, but that would never really make practical sense? + fromHistory, + alias, + meta, + taping: $commonApi.rec.loadCallback !== null || $commonApi.rec.recording, // 🎏 Hacky flag. 23.09.17.05.09 + // noBeat: beat === defaults.beat, + clockShortcode, // ⏰ Original *code for URL display (null if not a clock shortcode) + }, + }); + + // Notify parent window of progress via bios relay + const loadCompleteTime = performance.now(); + // console.log("⏰ load() completed at", loadCompleteTime); + // console.log("📢 Disk sending boot-log: loaded"); + send({ + type: "boot-log", + content: `loaded: ${slug || path}` + }); + + return true; // Loaded successfully. +} + +const isWorker = typeof importScripts === "function"; +const _workerReadyTime = performance.now(); +// Only log worker ready if there was significant import time +if (_workerReadyTime - _importStart > 50) { + console.log(`📦 [DISK] Ready: ${(_workerReadyTime - _importStart).toFixed(0)}ms`); +} + +// ***Bootstrap*** +// Start by responding to a load message, then change +// the message response to makeFrame. +if (isWorker) { + // Signal to bios.mjs that disk.mjs has loaded successfully + postMessage({ type: "worker-ready" }); + + onmessage = (e) => { + // DEBUG: Log DAW messages only when debugging + if (e.data?.type?.startsWith?.("daw:") && logs.daw) { + console.log("🎹 WORKER:", e.data?.type, e.data); + } + makeFrame(e); + }; +} else { + noWorker.onMessage = (d) => makeFrame({ data: d }); +} + +// The main messaging function to comumunicate back with the main thread. +function send(data, shared = []) { + if (isWorker) { + if (shared[0] === undefined) shared = []; + postMessage(data, shared); + } else { + if (noWorker.postMessage) { + noWorker.postMessage({ data }); + } else { + console.warn("⚠️ noWorker.postMessage is not defined yet, cannot send:", data); + } + } +} + +// Used to subscribe to live coding / development reloads. +let codeChannel, codeChannelAutoLoader; + +// Queue for export events that arrive when actEvents isn't available +let pendingExportEvents = []; + +// 4. ✔ Respond to incoming messages, and probably produce a frame. +// Boot procedure: +// First `paint` happens after `boot`, then any `act` and `sim`s each frame +// before `paint`ing occurs. One `sim` always happens after `boot` and before +// any `act`. `paint` can return false to stop drawing every display frame, +// then, it must be manually restarted via `needsPaint();`). 2022.01.19.01.08 +// 🔥 +// TODO: makeFrame is no longer a great name for this function, which actually +// receives every message from the main thread, one of which renders a +// frame. +// TODO: Make simple needsPaint example. +// TODO: Try to remove as many API calls from here as possible. + +async function makeFrame({ data: { type, content } }) { + // DEBUG: Log ALL messages starting with "pedal:" or "daw:" + if (type?.startsWith?.("pedal:") || type?.startsWith?.("daw:") || type?.startsWith?.("spreadnob:")) { + console.log("🎹🎹🎹 makeFrame received:", type, JSON.stringify(content)); + } + + // Handle permission responses from bios.mjs + if (type === "permission-response") { + resolvePermissionRequest(content.requestId, content.granted); + return; + } + + // Navigate to a new piece (device.html slideshow uses this for instant transitions) + if (type === "navigate") { + const target = content?.to; + console.log("🧭 Navigate received:", target, "jump available:", !!$commonApi?.jump, "loading:", loading, "leaving:", leaving); + if (target && $commonApi?.jump) { + $commonApi.jump(target); + } else { + console.warn("🧭 Navigate failed: target=", target, "$commonApi=", !!$commonApi, "jump=", !!$commonApi?.jump); + } + return; + } + + // Warm the kidlisp code cache with pre-fetched sources from parent + if (type === "warm-kidlisp-cache") { + const codes = content?.codes; + if (codes) { + let count = 0; + for (const [codeId, source] of Object.entries(codes)) { + saveCodeToAllCaches(codeId, source); + count++; + } + console.log(`🔥 Warmed kidlisp cache with ${count} codes`); + } + return; + } + + // Runs once on boot. + if (type === "init-from-bios") { + debug = content.debug; + setDebug(content.debug); + ROOT_PIECE = content.rootPiece; + + if (content.debugHud) { + globalThis.debugHudHitbox = true; + console.log("🔍 HUD Hitbox Debug: ON (init-from-bios)"); + $commonApi.needsPaint(); + } + + // Store noauth flag for iframe embedding (kidlisp.com) + // In noauth mode, mark session as started immediately so piece loading + // doesn't wait for the session:started message (resilience when session server is down) + if (content.noauth) { + globalThis.NOAUTH_MODE = true; + sessionStarted = true; + } + + USER = content.user; + // if (USER) socket.send("login", { user: USER }); + + LAN_HOST = content.lanHost; + SHARE_SUPPORTED = content.shareSupported; + PREVIEW_OR_ICON = content.previewOrIcon; + VSCODE = content.vscode; + setPackMode( + Boolean( + content.objktMode || + content.packMode || + (typeof globalThis !== "undefined" && globalThis.acPACK_MODE), + ), + ); // Store PACK/OBJKT mode flag in worker global scope + if (content.objktKidlispCodes) { + const globalScope = (function() { + if (typeof globalThis !== 'undefined') return globalThis; + if (typeof self !== 'undefined') return self; + if (typeof global !== 'undefined') return global; + return {}; + })(); + globalScope.objktKidlispCodes = content.objktKidlispCodes; + } + + TV_MODE = content.resolution?.tv === true; + DEVICE_MODE = content.resolution?.device === true; + SOLO_MODE = content.resolution?.solo === true; + + // Parse highlight parameter + const highlightParam = content.resolution?.highlight; + if (highlightParam) { + HIGHLIGHT_MODE = true; + if (highlightParam !== true && typeof highlightParam === 'string') { + // If it's a color string, parse it + if (highlightParam.startsWith('#')) { + // Convert hex to RGB + const hex = highlightParam.slice(1); + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + HIGHLIGHT_COLOR = `${r},${g},${b}`; + } else if (highlightParam.includes(',')) { + // Already RGB format + HIGHLIGHT_COLOR = highlightParam; + } else { + // Named color or other format - use CSS color parser or default + HIGHLIGHT_COLOR = highlightParam; + } + } + } else { + HIGHLIGHT_MODE = false; + } + + // Parse perf parameter for KidLisp performance HUD + PERF_MODE = content.resolution?.perf === true; + + // Parse auto-scale parameter for automatic density scaling + AUTO_SCALE_MODE = content.resolution?.autoScale === true; + SPOOF_AUDIO_MODE = content.resolution?.spoofaudio === true; + NOGAP_MODE = content.resolution?.gap === 0; + + microphone.permission = content.microphonePermission; + + $commonApi.canShare = SHARE_SUPPORTED; + $commonApi.vscode = VSCODE; // Add vscode flag to the common api. + $commonApi.net.lan = LAN_HOST; + $commonApi.user = USER; + $commonApi.net.iframe = content.iframe; + $commonApi.net.sandboxed = content.sandboxed; + $commonApi.net.tvMode = TV_MODE; // Add TV mode flag to common API + + codeChannelAutoLoader = null; + codeChannel = await store.retrieve("code-channel"); + if (!codeChannel || codeChannel?.length === 0) { + codeChannel = nanoid(); + store["code-channel"] = codeChannel; + store.persist("code-channel"); + } + + // console.log("💻 Code channel:", codeChannel); + + // Always send the codeChannel up to any parent window. + codeChannelAutoLoader = () => { + send({ + type: "post-to-parent", + content: { type: "setCode", value: codeChannel }, + }); + codeChannelAutoLoader = null; + }; + + // 🚀 Eagerly initialize GPU effects module for faster spin/blur/zoom + // Currently GPU effects are disabled due to Y-flip issues, so this is non-blocking + // TODO: Re-enable await when GPU effects Y-coordinate handling is fixed + if (typeof graph.initGpuEffects === 'function') { + graph.initGpuEffects().catch(e => { + console.warn('🎮 GPU Effects initialization failed:', e); + }); + } + + // console.log("Init:", content); + // await handle(); // Get the user's handle. + // console.log("🟢 Loading after preamble:", content.parsed); + + originalHost = content.parsed.host; + loadAfterPreamble = () => { + const loadCallStartTime = performance.now(); + // console.log("⏰ loadAfterPreamble called at", loadCallStartTime); + loadAfterPreamble = null; + load(content.parsed); // Load after some of the default frames run. + }; + + if (PREVIEW_OR_ICON) { + console.log("💬 Chat disabled, just grabbing screenshots. 😃"); + } else if (getPackMode()) { + // Skip chat connection in PACK mode - offline bundle + } else { + chatClient.connect("system"); // Connect to `system` chat. + } + + send({ type: "disk-defaults-loaded" }); + return; + } + + // Enable KidLisp-only console channel (forwarded from boot.mjs). + if (type === "kidlisp-console-enable") { + // Call the kidlisp.mjs enableKidlispConsole() to set the module-level flag + // This is needed because bios.mjs imports isKidlispConsoleEnabled() from kidlisp.mjs + enableKidlispConsole(); + return; + } + + // � Toggle HUD hitbox debug visualization from main thread + if (type === "debug:hud-hitbox:toggle") { + globalThis.debugHudHitbox = !globalThis.debugHudHitbox; + console.log("🔍 HUD Hitbox Debug:", globalThis.debugHudHitbox ? "ON" : "OFF"); + $commonApi.needsPaint(); + return; + } + + // �🛑 Watchdog ping - respond immediately with pong + if (type === "watchdog:ping") { + send({ type: "watchdog:pong", content: { timestamp: content?.timestamp } }); + return; + } + + if (type === "logout:broadcast:subscribe") { + console.log("🏃‍♂️ Broadcasting logout:", content); + socket?.send("logout:broadcast:subscribe", content); + return; + } + + // Get visualViewport update, for keyboard overlays, etc. + if (type === "viewport-height:changed" && booted) { + const $api = cachedAPI; + const data = { ...content }; + Object.assign(data, { + is: (e) => e === type, + }); + $api.event = data; + try { + act($api); + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + return; + } + + // Receive a midi input message. + if (type === "midi:keyboard" && booted) { + // console.log("🎹 Keyboard:", content.data); + const $api = cachedAPI; + const data = { ...content }; + Object.assign(data, { + device: "midi:keyboard", + is: (e) => e === type, + }); + $api.event = data; + try { + act($api); + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + return; + } + + if (type === "audio:sample-rate") { + AUDIO_SAMPLE_RATE = content; + return; + } + + // 🎹 DAW Sync messages (from Max for Live via bios.mjs) + // Use persistentDawState since $commonApi.sound gets recreated every frame + if (type === "daw:tempo") { + // Send acknowledgement back to main thread for debugging + send({ type: "daw:tempo:ack", content: { bpm: content?.bpm } }); + sound.bpm = content.bpm; + // Store in persistent state (survives frame updates) + persistentDawState.bpm = content.bpm; + return; + } + + if (type === "daw:transport") { + // Send acknowledgement back to main thread for debugging + send({ type: "daw:transport:ack", content: { playing: content?.playing } }); + // Store in persistent state (survives frame updates) + persistentDawState.playing = content.playing; + return; + } + + if (type === "daw:phase") { + // Store in persistent state (survives frame updates) + persistentDawState.time = content.time; + return; + } + + if (type === "daw:samplerate") { + // Store in persistent state (survives frame updates) + persistentDawState.sampleRate = content.rate; + return; + } + + // 🎛️ Spreadnob messages (from M4L via bios) — stored on persistentDawState + if (type === "spreadnob:note") { + persistentDawState.snNote = content.note; + return; + } + if (type === "spreadnob:target") { + persistentDawState.snTarget = content.name; + return; + } + if (type === "spreadnob:value") { + persistentDawState.snValue = content.value; + return; + } + if (type === "spreadnob:active") { + persistentDawState.snActive = content.active; + return; + } + if (type === "spreadnob:min") { + persistentDawState.snMin = content.min; + return; + } + if (type === "spreadnob:max") { + persistentDawState.snMax = content.max; + return; + } + if (type === "spreadnob:state") { + persistentDawState.snRawNote = content.raw; + persistentDawState.snNormalizedNote = content.note; + persistentDawState.snShift = content.shift; + persistentDawState.snLocked = content.locked; + persistentDawState.snAmbiguous = content.ambiguous; + return; + } + if (type === "spreadnob:qwerty-range") { + persistentDawState.snQwertyLow = content.low; + persistentDawState.snQwertyHigh = content.high; + return; + } + + // 🎸 Pedal messages (for audio effect visualization) + if (type === "pedal:peak") { + // Forward to the piece's receive function if it exists + if (typeof receive === "function") { + receive({ type: "pedal:peak", peak: content.peak }); + } + return; + } + + if (type === "pedal:envelope") { + // Forward to the piece's receive function if it exists + if (typeof receive === "function") { + receive({ type: "pedal:envelope", ...content }); + } + return; + } + + // Update the logged in user after initialization. + if (type === "session:started") { + // console.log("🟢 Session starting..."); + USER = content.user; + $commonApi.user = USER; // User will be set to "null" here + // it it doesn't exist. + + if (USER) { + // console.log("Getting handle..."); + await handle(); // Get the user's handle. + // console.log("Handle recived:", HANDLE); + const displayName = HANDLE || USER.email; + console.log(`%c🔐 auth%c Welcome back %c${displayName}`, + 'background: #ec4899; color: white; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: bold;', + 'color: inherit;', + 'color: #ec4899; font-weight: bold;' + ); + // Log tab ID for debugging + if (!$commonApi._tabId) { + $commonApi._tabId = Math.random().toString(36).substr(2, 9); + } + // Broadcast to other tabs... + $commonApi.broadcast("login:success"); + } else { + // console.log("🔐 You are not logged in."); + // Generate tab ID for debugging (anonymous user) + if (!$commonApi._tabId) { + $commonApi._tabId = Math.random().toString(36).substr(2, 9); + } + } + sessionStarted = true; + diskTimings.sessionStarted = Math.round(performance.now() - diskTimingStart); + // Silent: sessionStarted + return; + } + + // Handle live reload from kidlisp.com editor + if (type === "piece-reload") { + log.piece.log("Reloading with new code:", content.source?.substring(0, 30) + "..."); + if ($commonApi.reload) { + $commonApi.reload({ + source: content.source, + codeId: content.codeId, // The $code identifier (e.g., "inz") + createCode: content.createCode, + authToken: content.authToken, // Pass token from kidlisp.com login + enableTrace: content.enableTrace, // Enable execution trace for visualization + language: content.language, + ext: content.ext, + liveName: content.liveName, + }); + } else { + // Queue the reload request to be processed once the piece is ready + // Always update the queue with the latest code (overwrite previous) + log.piece.warn("Reload function not ready yet, queuing:", content.source?.substring(0, 20)); + pendingPieceReload = { + source: content.source, + codeId: content.codeId, + createCode: content.createCode, + authToken: content.authToken, + enableTrace: content.enableTrace, + language: content.language, + ext: content.ext, + liveName: content.liveName, + }; + } + return; + } + + // 🎚️ Slide mode update - re-evaluate code but preserve buffers/state + if (type === "piece-slide") { + if (content.source) { + // Use the exported slideUpdate from kidlisp.mjs to update the correct instance + // (The one used by lisp.module() for loaded pieces) + lisp.slideUpdate(content.source); + } + return; + } + + // 🎵 Audio data from jukebox - update KidLisp audio state + if (type === "kidlisp-audio") { + lisp.updateKidLispAudio?.(content); + return; + } + + // Confirming if the pen has been locked or unlocked by the Pointer Lock API. + if (type === "pen:locked" || type === "pen:unlocked") { + actAlerts.push(type); + return; + } + + // Capture a link from the docs system. + if (type === "docs:link") { + console.log("📚 Doc link captured:", content); + $commonApi.jump("prompt~" + content); + return; + } + + // Capture the browser scroll wheel / scroll effect. + if (type === "scroll") { + if (!cachedAPI) return; // Guard against undefined cachedAPI + const $api = cachedAPI; + const data = { ...content }; + Object.assign(data, { + device: "wheel", + is: (e) => e === type, + }); + $api.event = data; + try { + act($api); + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + return; + } + + // Jump to any piece slug from the bios. + if (type === "jump") { + let ahistorical, alias; + if (content.ahistorical === undefined) { + ahistorical = true; + } else ahistorical = content.ahistorical; + if (content.alias === undefined) { + alias = true; + } else alias = content.alias; + $commonApi.jump(content.piece, ahistorical, alias); + return; + } + + // Create a notice. + if (type === "notice") { + $commonApi.notice(content, ["white", "maroon"]); + return; + } + + if (type === "loading-complete") { + leaving = false; + hotSwap?.(); // Actually swap out the piece functions and reset the state. + loading = false; + loadingStartTime = null; + noPaint = false; // 🎨 Ensure first frame paints after piece swap + return; + } + + if (type === "udp:receive") { + udp.receive(content); + return; + } + + if (type === "udp:connected") { + console.log("🩰 DISK received udp:connected! Setting udp.connected = true"); + udp.connected = true; + updateHUDStatus(); + $commonApi.needsPaint(); + return; + } + + if (type === "udp:disconnected") { + console.log("🩰 DISK received udp:disconnected! Setting udp.connected = false"); + udp.connected = false; + updateHUDStatus(); + $commonApi.needsPaint(); + return; + } + + if (type === "microphone-disconnect") { + microphone.connected = false; + actAlerts.push("microphone-disconnect"); + return; + } + + // if (type === "hand-tracking-data") { + // $commonApi.hand = { mediapipe: content }; + // return; + // } + + // Load the source code for a dropped `.mjs` or KidLisp file. + if (type === "dropped:piece") { + // Pass forceKidlisp=true if this was detected as KidLisp (e.g., from .lisp.html bundle) + const forceKidlisp = content.isKidLisp === true; + load(content, false, false, true, undefined, forceKidlisp); + return; + } + + // Run dropped text as a prompt command (navigation / piece name). + if (type === "dropped:text") { + $commonApi.jump(content.text); + return; + } + + if (type === "dropped:bitmap") { + if (currentPath === "aesthetic.computer/disks/prompt") { + $commonApi.system.nopaint.replace( + { system: $commonApi.system, store, needsPaint: $commonApi.needsPaint }, + content.source, + ); + } else { + const $api = cachedAPI; + const data = { name: content.name, painting: content.source }; + Object.assign(data, { + device: "none", + is: (e) => e === type, + }); + $api.event = data; + try { + act($api); + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + } + return; + } + + // Handle dropped ALS files - route to piece receive function if available + if (type === "dropped:als") { + console.log("🎵 DISK.MJS: Received dropped:als message:", content); + + // First try to call the piece's receive function if it exists + if (typeof receive === "function") { + console.log("🎵 DISK.MJS: Calling piece receive function"); + try { + receive({ type, content }); + } catch (e) { + console.warn("🎵 DISK.MJS: Piece receive function error:", e); + } + } else { + console.log("🎵 DISK.MJS: No piece receive function found, triggering act event"); + // Fall back to act event pattern + const $api = cachedAPI; + if ($api) { + const data = { + name: content.name, + xmlData: content.xmlData, + type: "dropped:als" + }; + Object.assign(data, { + device: "none", + is: (e) => e === type, + }); + $api.event = data; + try { + act($api); + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + } + } + return; + } + + // Handle dropped WAV files - route to piece receive function if available + if (type === "dropped:wav") { + console.log("🎵 DISK.MJS: Received dropped:wav message:", content); + + // First try to call the piece's receive function if it exists + if (typeof receive === "function") { + console.log("🎵 DISK.MJS: Calling piece receive function for WAV"); + try { + receive({ type, content }); + } catch (e) { + console.warn("🎵 DISK.MJS: Piece receive function error for WAV:", e); + } + } else { + console.log("🎵 DISK.MJS: No piece receive function found for WAV, triggering act event"); + // Fall back to act event pattern + const $api = cachedAPI; + if ($api) { + const data = { + name: content.name || content.originalName, + id: content.id, + size: content.size, + type: "dropped:wav" + }; + Object.assign(data, { + device: "none", + is: (e) => e === type, + }); + $api.event = data; + try { + act($api); + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + } + } + return; + } + + // 🗣️ An act that fires when an utterance has ended in the Web Speech API. + if (type === "speech:completed") { + actAlerts.push("speech:completed"); + return; + } + + // 🎵 Clock piece sent its cached code for QR display + if (type === "clock:cached") { + cachedClockCode = content?.code || null; + console.log(`🎵 Clock cached code received: *${cachedClockCode}`); + return; + } + + // When inputting text into the prompt. + if ( + type === "prompt:text:replace" || + type === "prompt:text:select" || + type === "prompt:text:cursor" + ) { + const $api = cachedAPI; + const data = { ...content }; + Object.assign(data, { + device: "none", + is: (e) => e === type, + }); + $api.event = data; + try { + act($api); + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + return; + } + + // Handles: clipboard:paste:pasted, clipboard:paste:pasted:empty + if (type.startsWith("paste:pasted")) { + actAlerts.push("clipboard:" + type); + return; + } + + if (type === "paste:failed") { + actAlerts.push("clipboard:paste:failed"); + return; + } + + if (type === "copy:copied") { + actAlerts.push("clipboard:copy:copied"); + return; + } + + if (type === "aesthetic-parent:focused") { + actAlerts.push("aesthetic-parent:focused"); + return; + } + + if (type === "copy:failed") { + actAlerts.push("clipboard:copy:failed"); + return; + } + + if (type === "upload:progress") { + serverUploadProgressReporter?.(content); // Report file upload progress if needed. + + // Also forward to current piece (especially video.mjs for tape uploads) + if (cachedAPI?.piece?.receive) { + console.log(`📤 Forwarding upload:progress to piece: ${content}`); + try { + cachedAPI.piece.receive({ + type: "upload:progress", + content, + is: (name) => name === "upload:progress" + }); + } catch (error) { + console.warn("📤 Error forwarding upload:progress to piece:", error); + } + } + + return; + } + + if (type === "upload:status") { + // Forward backend upload status stages to the active piece. + if (cachedAPI?.piece?.receive) { + try { + cachedAPI.piece.receive({ + type: "upload:status", + content, + is: (name) => name === "upload:status", + }); + } catch (error) { + console.warn("📤 Error forwarding upload:status to piece:", error); + } + } + return; + } + + if (type === "focus-change") { + if (!cachedAPI) return; // Hacky... 23.04.21.14.59 + const $api = cachedAPI; + if (content !== inFocus) { + inFocus = content; + const data = {}; + Object.assign(data, { + device: "none", + is: (e) => e === (inFocus === true ? "focus" : "defocus"), + }); + $api.event = data; + try { + act($api); + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + } + } + + if (type === "visibility-change") { + // 🧨 Just in case of a regression... 23.06.02.21.12 + // Because the `bios` focus event changed from visibility behavior. + // if (!lastActAPI) return; // Hacky... 23.04.21.14.59 + // const $api = lastActAPI; // Focus change events have an empty API. + // if (content !== inFocus) { + // inFocus = content; + // const data = {}; + // Object.assign(data, { + // device: "none", + // is: (e) => e === (inFocus === true ? "focus" : "defocus"), + // }); + // $api.event = data; + // try { + // act($api); + // } catch (e) { + // console.warn("️ ✒ Act failure...", e); + // } + // } + + // Reset KidLisp timing state when becoming visible again + // This prevents animation "catch up" after tab was hidden + // Skip reset for pieces with background mode — they kept running while hidden. + if (content === true && visible === false && globalKidLispInstance && !pieceBackground) { + // Becoming visible - reset timing expressions + globalKidLispInstance.lastSecondExecutions = {}; + globalKidLispInstance.instantTriggersExecuted = {}; + if (globalKidLispInstance.sequenceCounters) { + globalKidLispInstance.sequenceCounters.clear(); + } + } + + visible = content; + } + + if (type === "before-unload") { + // This has to be synchronous (no workers) to work, and is also often unreliable. + // I should not design around using this event, other than perhaps + // sending a beacon at the end. 22.11.03.14.53 + // See also: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon + + /* + try { + leave({ store, screen, system: $commonApi.system }); // Trigger leave. + } catch (e) { + console.warn("👋 Leave failure...", e); + } + */ + return; + } + + // Get the updated device motion. + if (type === "motion:update") { + $commonApi.motion.on = true; + $commonApi.motion.current = content; + return; + } + + if (type === "motion:enabled") { + $commonApi.motion.on = true; + return; + } + + // 🔷 Tezos wallet state updates from bios.mjs + if (type === "wallet:state") { + // Update the wallet state from bios (full state broadcast) + Object.assign($commonApi.wallet._state, content); + // Clear any error after successful update + if (content.connected) delete $commonApi.wallet._state.error; + log.wallet.debug("Wallet state updated:", $commonApi.wallet._state); + return; + } + + // 🔷 Wallet pairing URI response (for mobile QR code) + if (type === "wallet:pairing-uri-response") { + if ($commonApi.wallet._pairingCallback) { + $commonApi.wallet._pairingCallback(content); + $commonApi.wallet._pairingCallback = null; + } + return; + } + + // 🔷 Wallet sign response + if (type === "wallet:sign-response") { + if ($commonApi.wallet._signCallback) { + $commonApi.wallet._signCallback(content); + $commonApi.wallet._signCallback = null; + } + return; + } + + if (type === "gpu-rendered-once") { + $commonApi.gpuReady = true; + return; + } + + if (type === "gpu-forms-removed") { + // Delete forms from the sent list that have been removed from the GPU scene. + content.forEach((id) => { + formsToClear.push(id); + }); + return; + } + + if (type === "dark-mode") { + darkMode(content.enabled); + return; + } + + if (type === "gpu-config") { + graph.setGpuConfig(content); + return; + } + + if (type === "forms:baked") { + //console.log("🍞 Forms baked:", content); + //noPaint = false; + + // if (content.pixels) { + // graph.paste(content, 0, 0, 1, true); + // } + + // paintFormsResolution?.(); + return; + } + + // Media Recorder Events + + if (type === "recorder:transcode-progress") { + console.log("📼 🚨 DISK.MJS caught transcode-progress EARLY - updating rec.printProgress:", content); + $commonApi.rec.printProgress = content; + if (content === 1) { + send({ type: "signal", content: "recorder:transcoding-done" }); + // TODO: Is this the best place for this signal to be sent? + // Maybe it should go back in the BIOS? 22.08.19.13.44 + } + // DON'T return here - let it continue to the piece forwarding logic below! + // return; // ❌ REMOVED - this was preventing messages from reaching the piece + } + + // Forward recorder export events to the current piece, but only when not in export mode + // During export (when actEvents is undefined), let events pass through directly to main thread + if ( + type === "recorder:export-status" || + type === "recorder:export-progress" || + type === "recorder:transcode-progress" || + type === "recorder:export-complete" || + type === "tape:posted" || + type === "tape:post-error" || + type === "tape:download-progress" || + type === "tape:load-progress" || + type === "tape:preloaded" || + type === "tape:preload-error" || + type === "tape:frames" || + type === "tape:audio-context-state" || + type === "tape:playback-progress" + ) { + // Check if actEvents exists without causing ReferenceError + try { + const hasActEvents = typeof actEvents !== 'undefined' && actEvents && Array.isArray(actEvents); + + // If actEvents is available, route to the piece as normal + if (hasActEvents) { + const event = { + is: (eventType) => eventType === type, + type: type, + content: content, + progress: type === "recorder:transcode-progress" ? content : undefined + }; + + actEvents.push(event); + if (debug) console.log("📼 ✅ Used actEvents for export event:", type); + return; // Don't let it pass through to main thread + } + } catch (e) { + // actEvents doesn't exist - that's fine, we'll call receive directly + } + + // If actEvents isn't available, try calling the piece's receive function directly + // But only if the piece is booted AND has a custom receive (not the default) + if (typeof receive === "function" && booted && receive !== defaults.receive) { + const event = { + is: (eventType) => eventType === type, + type: type, + content: content, + progress: type === "recorder:transcode-progress" ? content : undefined + }; + try { + if (!getPackMode()) console.log("📼 ✅ Calling receive directly for export event:", type); + receive(event); + return; // Successfully delivered to piece + } catch (e) { + console.warn("📼 ❌ Error calling receive:", e); + } + } else { + // Log why we're NOT calling receive + if (!getPackMode()) { + const reasons = []; + if (typeof receive !== "function") reasons.push("receive not a function"); + if (!booted) reasons.push("not booted"); + if (receive === defaults.receive) reasons.push("using default receive"); + console.log(`📼 ⏸️ Cannot call receive for ${type}: ${reasons.join(", ")}`); + } + } + + // If we can't deliver it (piece not booted or using default receive), queue it for later + const event = { + is: (eventType) => eventType === type, + type: type, + content: content, + progress: type === "recorder:transcode-progress" ? content : undefined + }; + pendingExportEvents.push(event); + if (!getPackMode()) { + console.log("📼 📥 Queued export event for later delivery:", type, "queue size:", pendingExportEvents.length); + } + return; + } + + if ( + type === "recorder:rolling:started" || + type === "recorder:rolling:resumed" + ) { + $commonApi.rec.recording = true; + $commonApi.rec.cleanMode = content.cleanMode || false; + $commonApi.rec.rollingCallback?.(content.time); + return; + } + + if (type === "recorder:rolling:ended") { + $commonApi.rec.recording = false; + $commonApi.rec.recorded = true; // Also cleared when a recording "slates". + // Don't reset cleanMode here - it's needed during GIF export/playback + + if ($commonApi.rec.cutCallback) { + try { + $commonApi.rec.cutCallback(); + // Clear the callback after execution to prevent duplicate calls + $commonApi.rec.cutCallback = null; + } catch (error) { + console.error(`🎬 ❌ Error in cutCallback:`, error); + console.error(`🎬 ❌ Stack trace:`, error.stack); + } + } else { + console.warn(`🎬 ⚠️ No cutCallback available when rolling ended`); + // Fallback: try to jump to video directly if no callback is set + $commonApi.jump("video"); + } + return; + } + + if (type === "recorder:printing:started") { + return; + } + + if (type === "recorder:printed") { + $commonApi.rec.printed = true; + $commonApi.rec.printCallback?.(content); + return; + } + + if (type === "recorder:presented") { + $commonApi.rec.presenting = true; + $commonApi.rec.presentProgress = 0; + $commonApi.rec.tapeProgress = 0; + return; + } + + if (type === "recorder:presented:failure") { + $commonApi.rec.presenting = false; + $commonApi.rec.presentProgress = 0; + $commonApi.rec.tapeProgress = 0; + return; + } + + if (type === "recorder:frames-response") { + // First chunk - initialize accumulation + if (content.totalChunks > 1) { + console.log(`📦 Receiving chunked frames: chunk 1/${content.totalChunks}`); + $commonApi.rec.frameChunks = [content.frames]; + $commonApi.rec.totalChunks = content.totalChunks; + $commonApi.rec.receivedChunks = 1; + $commonApi.rec.rawAudio = content.rawAudio; + } else { + // Single chunk, call callback immediately + $commonApi.rec.framesCallback?.(content); + } + return; + } + + if (type === "recorder:frames-chunk") { + // Subsequent chunks - accumulate + if ($commonApi.rec.frameChunks) { + $commonApi.rec.frameChunks.push(content.frames); + $commonApi.rec.receivedChunks++; + + console.log(`📦 Received chunk ${$commonApi.rec.receivedChunks}/${$commonApi.rec.totalChunks}`); + + // All chunks received - combine and call callback + if ($commonApi.rec.receivedChunks === $commonApi.rec.totalChunks) { + const allFrames = $commonApi.rec.frameChunks.flat(); + console.log(`✅ All ${$commonApi.rec.totalChunks} chunks received, total frames: ${allFrames.length}`); + + $commonApi.rec.framesCallback?.({ + frames: allFrames, + rawAudio: $commonApi.rec.rawAudio + }); + + // Clean up + delete $commonApi.rec.frameChunks; + delete $commonApi.rec.totalChunks; + delete $commonApi.rec.receivedChunks; + delete $commonApi.rec.rawAudio; + } + } + return; + } + + if (type === "recorder:present-progress") { + $commonApi.rec.presentProgress = content; + return; + } + + if (type === "recorder:present-playing") { + $commonApi.rec.playing = true; + return; + } + + if (type === "recorder:present-paused") { + $commonApi.rec.playing = false; + return; + } + + if (type === "recorder:unpresented") { + $commonApi.rec.presenting = false; + $commonApi.rec.presentProgress = 0; + $commonApi.rec.tapeProgress = 0; + return; + } + + if (type === "signal") { + signals.push(content); + return; + } + + if (type === "piece:act-alert") { + if (content !== undefined && content !== null) { + if (typeof content === "string") { + actAlerts.push(content); + } else if (typeof content.name === "string" && content.name.length > 0) { + const { name, ...extra } = content; + if (keys(extra).length > 0) { + actAlerts.push({ name, ...extra }); + } else { + actAlerts.push(name); + } + } + } + return; + } + + if (type === "store:retrieved") { + storeRetrievalResolutions[content.key]?.(content.data); + delete storeRetrievalResolutions[content.key]; + return; + } + + if (type === "store:deleted") { + storeDeletionResolutions[content.key]?.(content.data); + delete storeDeletionResolutions[content.key]; + return; + } + + // USB flash support (Electron only) + if (type === "usb:devices-listed") { + if (usbDeviceListResolution) { + usbDeviceListResolution(content); + usbDeviceListResolution = null; + } + return; + } + + if (type === "usb:flash-progress") { + actAlerts.push({ name: "usb:flash-progress", ...content }); + return; + } + + if (type === "usb:flash-complete") { + if (usbFlashResolution) { + usbFlashResolution({ success: true, ...content }); + usbFlashResolution = null; + } + return; + } + + if (type === "usb:flash-error") { + if (usbFlashResolution) { + usbFlashResolution({ success: false, error: content.error }); + usbFlashResolution = null; + } + return; + } + + if (type === "content-created") { + $commonApi.content.receive(content); + return; + } + + if (type === "leave") { + //const $api = {}; + console.log("🏃‍♂️ Leave:", content); + return; + } + + if (type === "sfx:killed") { + sfxKillReceivers[content.id]?.(); + return; + } + + if (type === "sfx:got-sample-data") { + sfxSampleReceivers[content.id]?.(content.data); + return; + } + + if (type === "sfx:progress:report") { + sfxProgressReceivers[content.id]?.(content); // Resolve the progress report. + return; + } + + if (type === "sfx:got-duration") { + sfxDurationReceivers[content.id]?.(content.duration); + return; + } + + // 🎵 Streaming Audio messages - forward to piece receive function + if (type === "stream:playing" || + type === "stream:paused" || + type === "stream:stopped" || + type === "stream:error" || + type === "stream:frequencies-data") { + if (typeof receive === "function") { + try { + receive({ type, content }); + } catch (e) { + console.warn("🎵 Stream receive error:", e); + } + } + return; + } + + if (type === "microphone-amplitude") { + microphone.amplitude = content; + return; + } + + if (type === "microphone-waveform") { + microphone.waveform = content; + return; + } + + if (type === "microphone-recording-buffer") { + microphone.recordingBuffer = content; + return; + } + + if (type === "waveforms") { + speaker.waveforms = content; + return; + } + + if (type === "amplitudes") { + speaker.amplitudes = content; + return; + } + + if (type === "frequencies") { + speaker.frequencies = content; + // Extract beat data if it exists in the content + if (content.beat) { + speaker.beat = content.beat; + } + return; + } + + // Handle Game Boy frame data + if (type === "gameboy:frame-data") { + // Store frame data and metadata in gameboy API object + gameboy.frame = content.pixels; + gameboy.romName = content.romName; + gameboy.title = content.title; + gameboy.isGameBoyColor = content.isGameBoyColor; + gameboy.isPlaying = true; + + return; + } + + if (type === "microphone-pitch") { + microphone.pitch = content; + return; + } + + if (type === "microphone-recording:complete") { + microphone.recordingPromise?.resolve(content); + return; + } + + if (type === "microphone-connect:success") { + microphone.connected = true; + actAlerts.push("microphone-connect:success"); + return; + } + + if (type === "microphone-connect:failure") { + microphone.connected = false; + actAlerts.push({ name: "microphone-connect:failure", ...content }); + return; + } + + // 1a. Import // One send (returns afterwards) + // Here we are receiving file data from main thread that was requested + // by $api.upload 😱. We check to see if the upload promise exists and then + // use it and/or throw it away. + if (type === "import" && fileImport) { + if (content.result === "success") { + fileImport?.resolve(content.data); + } else if (content.result === "error") { + console.error("File failed to load:", content.data); + fileImport?.reject(content.data); + } + fileImport = undefined; + return; + } + + // Resolve a web3 connection message. + if (type === "web3-connect-response" && web3Response) { + if (content.result === "success") { + web3Response?.resolve(content.id); + } else if (content.result === "error") { + web3Response?.reject("error"); + } + web3Response = undefined; + return; + } + + // Resolve Tezos wallet connection + if (type === "tezos-connect-response" && tezosConnectResponse) { + if (content.result === "success") { + tezosConnectResponse?.resolve(content.address); + } else { + tezosConnectResponse?.reject(content.error || "Connection failed"); + } + tezosConnectResponse = undefined; + return; + } + + // Resolve Tezos wallet disconnection + if (type === "tezos-disconnect-response" && tezosDisconnectResponse) { + if (content.result === "success") { + tezosDisconnectResponse?.resolve(); + } else { + tezosDisconnectResponse?.reject(content.error || "Disconnect failed"); + } + tezosDisconnectResponse = undefined; + return; + } + + // Resolve Tezos address request + if (type === "tezos-address-response" && tezosAddressResponse) { + if (content.result === "success") { + tezosAddressResponse?.resolve(content.address); + } else { + tezosAddressResponse?.reject(content.error || "Failed to get address"); + } + tezosAddressResponse = undefined; + return; + } + + // Resolve Tezos message signing + if (type === "tezos-sign-response" && tezosSignResponse) { + if (content.result === "success") { + tezosSignResponse?.resolve(content.signature); + } else { + tezosSignResponse?.reject(content.error || "Signing cancelled"); + } + tezosSignResponse = undefined; + return; + } + + // Resolve Tezos contract call + if (type === "tezos-call-response" && tezosCallResponse) { + if (content.result === "success") { + tezosCallResponse?.resolve(content.opHash); + } else { + tezosCallResponse?.reject(content.error || "Contract call failed"); + } + tezosCallResponse = undefined; + return; + } + + // Resolve a gpu message + if (type === "gpu-response" && gpuResponse) { + if (content.result === "success") { + gpuResponse?.resolve(content.data); + } else if (content.result === "error") { + gpuResponse?.reject(content.data); + } + gpuResponse = undefined; + return; + } + + // Resolve a server uploaded file. + if (type === "upload" && serverUpload) { + if (content.result === "success") { + // 🗄️ After S3 upload succeeds, create MongoDB record with short code + // Extract extension from filename to determine media type + const filename = content.data.filename || ""; + const ext = filename.split(".").pop(); + + // Only track paintings and pieces in database + if (ext === "png" || ext === "mjs" || ext === "lisp" || ext === "lua") { + try { + // Call track-media POST to create database record with short code + const trackResponse = await $commonApi.net.userRequest("POST", "/api/track-media", { + slug: content.data.slug, + ext: ext, + }); + + if (trackResponse.status === 401) { + // Session expired - trigger logout + const errorData = await trackResponse.json(); + if (errorData.code === "SESSION_EXPIRED") { + console.warn("🔒 Session expired, logging out..."); + await $commonApi.net.logout(); + console.log("✅ Logged out due to expired session"); + } + return; // Stop processing this upload + } + + if (trackResponse.status === 200) { + const trackData = await trackResponse.json(); + // Add the short code to the response data + content.data.code = trackData.code; + console.log(`✅ ${ext === "png" ? "Painting" : "Piece"} tracked: ${content.data.slug} → #${trackData.code}`); + + // Fire off ATProto sync (don't await - let it happen in background) + if (ext === "png") { + $commonApi.net.userRequest("POST", "/api/sync-painting-atproto", { + slug: content.data.slug, + code: trackData.code, + paintingId: trackData.paintingId, + bucket: content.data.bucket || "user-aesthetic-computer", + }).then(response => { + if (response.status === 200) { + console.log("✅ ATProto sync initiated for", content.data.slug); + } + }).catch(err => { + console.warn("⚠️ ATProto sync failed (non-critical):", err); + }); + } + } else { + console.warn(`⚠️ Failed to track media in database:`, trackResponse.status); + } + } catch (err) { + console.error("❌ Error tracking media in database:", err); + // Don't fail the upload if database tracking fails + } + } + + serverUpload?.resolve(content.data); + } else if (content.result === "error") { + console.error("File failed to load:", content); + serverUpload?.reject(content.data); + } + serverUpload = undefined; + return; + } + + if (type === "zipped" && zipCreation) { + if (content.result === "success") { + zipCreation.resolve(content.data); + } else if (content.result === "error") { + console.error("Zip failed to be created:", content); + zipCreation?.reject(content.data); + } + zipCreation = undefined; + return; + } + + // Run when a painting record ZIP is succesfully parsed after being + // dragged into the A.C window. + if (type === "painting:record:dropped") { + // Replace the active nopaint record with the loaded one. + // $commonApi.system.nopaint.recording = true; + $commonApi.system.nopaint.record = content; + if ($commonApi.slug !== "painting") $commonApi.jump("painting"); + return; + } + + // Resolve a locally requested file. + if (type === "file-open:response" && fileOpenRequest) { + if (content.result === "success") { + fileOpenRequest?.resolve(content.data); + } else if (content.result === "error") { + console.error("Failed to open file.", content); + fileOpenRequest?.reject(content.data); + } + fileOpenRequest = undefined; + return; + } + + // Resolve a file encoding request. + if (type === "file-encode:response" && fileEncodeRequest) { + if (content.result === "success") { + fileEncodeRequest?.resolve(content.data); + } else if (content.result === "error") { + console.error("Failed to encode file.", content); + fileEncodeRequest?.reject(content.data); + } + fileEncodeRequest = undefined; + return; + } + + // Resolve an authorization request. + if (type === "authorization:response" && authorizationRequest) { + if (content.result === "success") { + authorizationRequest?.resolve(content.data); + } else if (content.result === "error") { + authorizationRequest?.reject(content.data); + } + authorizationRequest = undefined; + return; + } + + // 1b. Video frames. + if (type === "video-frame") { + if (!videoSwitching) activeVideo = content; + return; + } + + if (type === "video-devices") { + videoDeviceCount = content; + $commonApi.cameras = videoDeviceCount; + return; + } + + if (type === "camera:updated") { + videoSwitching = false; + actAlerts.push("camera:mode:" + content); + return; + } + + if (type === "camera:denied") { + actAlerts.push("camera:denied"); + return; + } + + // 1c. Loading from History + if (type === "history-load") { + if (debug && logs.history) console.log("⏳ History:", content); + $commonApi.load(content, true); + return; + } + + // 1d. Loading Bitmaps + if (type === "loaded-bitmap-success") { + if (debug) console.log("🖼️ Bitmap loaded:", content); + preloadPromises[content.url]?.resolve(content); + delete preloadPromises[content]; + return; + } + + if (type === "loaded-bitmap-rejection") { + if (debug) console.error("🖼️ Bitmap load failure:", content); + preloadPromises[content.url]?.reject(content.url); + delete preloadPromises[content.url]; + return; + } + + // 1d2. Loading Animated WebP + if (type === "loaded-animated-webp-success") { + console.log("🎬 Animated WebP loaded:", content.frameCount, "frames"); + preloadPromises[content.url]?.resolve(content); + delete preloadPromises[content.url]; + return; + } + + if (type === "loaded-animated-webp-rejection") { + console.error("🎬 Animated WebP load failure:", content); + preloadPromises[content.url]?.reject(content.error); + delete preloadPromises[content.url]; + return; + } + + // 1e. Loading Sound Effects + if (type === "loaded-sfx-success") { + if (debug && logs.audio) console.log("Sound load success:", content); + preloadPromises[content.sfx]?.resolve(content.sfx); + delete preloadPromises[content]; + return; + } + + if (type === "loaded-sfx-rejection") { + if (debug && logs.audio) console.error("Sound load failure:", content); + preloadPromises[content.sfx]?.reject(content.sfx); + delete preloadPromises[content.sfx]; + return; + } + + // 1f. Loading ZIP files. + if (type === "loaded-zip-success") { + if (debug) console.log("🤐 Zip load success:", content.url); + preloadPromises[content.url]?.resolve(content.data); + delete preloadPromises[content.url]; + return; + } + + if (type === "loaded-zip-rejection") { + if (debug) console.warn("🤐 Zip load failure:", content.url); + preloadPromises[content.url]?.reject(content.url); + delete preloadPromises[content.url]; + return; + } + + // Request a repaint (runs when the window is resized.) + if (type === "needs-paint") { + noPaint = false; + return; + } + + if (type === "reframed") { + // Always update the currentDisplay settings for synchronous + // screen buffer updates. + currentDisplay = { + width: content.innerWidth, + height: content.innerHeight, + subdivisions: content.subdivisions, + }; + $commonApi.display = currentDisplay; + + // IMPORTANT: Update screen dimensions immediately so the next paint uses correct size + if (content.width !== undefined && content.height !== undefined && screen) { + const oldWidth = screen.width; + const oldHeight = screen.height; + const oldBufferSize = screen.pixels.length; + + screen.width = content.width; + screen.height = content.height; + // Create fresh buffer - bios.mjs freeze frame provides visual continuity + // The piece's paint() will fill correct content on next frame + screen.pixels = new Uint8ClampedArray(content.width * content.height * 4); + + if ($commonApi.rec.presenting && (oldWidth !== content.width || oldHeight !== content.height)) { + console.log('📐 REFRAME: Worker dimensions updated', oldWidth, 'x', oldHeight, '→', content.width, 'x', content.height); + console.log(' Buffer:', oldBufferSize, '→', screen.pixels.length, '| Expected:', content.width * content.height * 4); + } + } + + // Only trigger a reframe event if we have already passed `boot` (painted + // at least once) + if (booted) { + reframed = true; + formReframing = true; + } + return; + } + + // Handle QR code fullscreen toggle from hitbox taps + if (type === "qr:toggle-fullscreen") { + toggleQRFullscreen(); + return; + } + + // 1. Beat + if (type === "beat") { + if (!sound) return; // Just in case no `frame` has been sent yet. + try { + beat($activePaintApi); + } catch (e) { + console.warn(" 💗 Beat failure...", e); + } + + send({ type: "beat", content: sound }); + // soundClear?.(); + sound.sounds.length = 0; // Empty the sound command buffer. + sound.bubbles.length = 0; + sound.kills.length = 0; + return; + } + + // 2. Frame + // Where each piece action (boot, sim, paint, etc...) is run. + if (type === "frame") { + // Update loop paused state from bios + loopPaused = content.paused || false; + + // Take hold of a previously worker transferrable screen buffer + // and re-assign it. + let pixels; + if (content.pixels && screen) { + pixels = new Uint8ClampedArray(content.pixels); + // Only use the transferred buffer if it matches current screen dimensions + // After a reframe, the buffer from bios may have old dimensions + const expectedLength = screen.width * screen.height * 4; + if (pixels.length === expectedLength) { + screen.pixels = pixels; + } else { + if ($commonApi.rec.presenting) { + console.log('⚠️ FRAME: Ignoring mismatched buffer from bios. Got:', pixels.length, '| Expected:', expectedLength, '| Keeping reframed buffer'); + } + // Keep the current screen.pixels buffer (from reframe) + } + } + + function getHudEditScrubThreshold() { + const promptLength = Math.max( + 1, + (currentHUDPlainTxt || currentHUDTxt || currentText || "").length, + ); + const blockWidth = + currentHUDLabelBlockWidth || tf?.blockWidth || DEFAULT_TYPEFACE_BLOCK_WIDTH; + const baseCaretWidth = blockWidth + 2; + const maxCaretWidth = blockWidth * 3; + if (promptLength <= 4) return baseCaretWidth; + if (promptLength <= 8) return Math.floor(baseCaretWidth * 1.5); + return Math.min(maxCaretWidth, Math.floor(promptLength * blockWidth * 0.25)); + } + + function triggerBackspaceAction(overrideContent) { + $commonApi.sound.synth({ + tone: 800, + beats: 0.1, + attack: 0.01, + decay: 0.5, + volume: 0.15, + }); + send({ type: "keyboard:unlock" }); + + const content = + overrideContent || currentHUDPlainTxt || currentHUDTxt || currentText; + let promptSlug = "prompt"; + if (content) { + if (lisp.isKidlispSource(content)) { + promptSlug += "~" + lisp.encodeKidlispForUrl(content); + } else { + promptSlug += "~" + content.replace(/~/g, " "); + } + } + + $commonApi.jump(promptSlug); + send({ type: "keyboard:open" }); + currentHUDScrub = 0; + currentHUDScrubReadyState = null; + $commonApi.needsPaint?.(); + } + + function triggerShareAction() { + $commonApi.sound.synth({ + tone: 1800, + beats: 0.15, + attack: 0.01, + decay: 0.5, + volume: 0.15, + }); + $commonApi.sound.synth({ + tone: 1800 / 2, + beats: 0.15 * 2, + attack: 0.01, + decay: 0.5, + volume: 0.1, + }); + + // Prefer existing cached short codes so we don't re-embed long inline source + const cachedShortCode = getCachedCode(currentCode); + if (cachedShortCode) { + $commonApi.jump("share~$" + cachedShortCode); + } else if (currentOriginalCodeId && currentOriginalCodeId.startsWith("$")) { + $commonApi.jump("share~" + currentOriginalCodeId); + } else { + const textToShare = currentHUDPlainTxt || currentHUDTxt || currentText || ""; + $commonApi.jump("share~" + lisp.encodeKidlispForUrl(textToShare)); + } + currentHUDScrub = 0; + currentHUDScrubReadyState = null; + $commonApi.needsPaint?.(); + } + + // 🌟 Global Keyboard Shortcuts (these could also be seen via `act`) + content.keyboard.forEach((data) => { + if (currentText && currentText.indexOf("botce") > -1) return; // No global keys on `botce`. 23.11.12.23.38 + + // 📼 [Ctrl+R+R] Quick tape - start recording without leaving the piece + // Handle this BEFORE the keyboard:down check since it's a special event + if (data.name === "keyboard:quick-tape" && !getPackMode()) { + console.log("📼 Quick tape: Handler triggered!"); + console.log("📼 Quick tape: currentText =", currentText); + console.log("📼 Quick tape: currentParams =", currentParams); + console.log("📼 Quick tape: $commonApi.rec =", $commonApi.rec); + + // Play a sound to indicate recording started + $commonApi.sound.synth({ + tone: 2000, + duration: 0.05, + attack: 0.01, + decay: 0.5, + volume: 0.25, + }); + + // Set up the recording with default 7 second duration + const defaultDuration = 7; + const kidlispFps = (typeof window !== 'undefined' && window.currentKidlispFps) || null; + + console.log("📼 Quick tape: About to call rec.rolling()..."); + + // Set up recording options and callback + $commonApi.rec.rolling( + { + type: "video", + pieceName: currentText || "quick-tape", + pieceParams: currentParams ? currentParams.join("~") : "", + originalCommand: currentText, + intendedDuration: defaultDuration, + frameMode: false, + frameCount: null, + kidlispFps: kidlispFps, + cleanMode: false, + showTezosStamp: false, + mystery: false + }, + (time) => { + // This callback is invoked after recording starts + console.log(`📼 Quick tape: Timer callback invoked! time=${time}`); + $commonApi.rec.tapeTimerSet(defaultDuration, time, false); + console.log(`📼 Quick tape: Timer set complete`); + }, + ); + + console.log("📼 Quick tape: rec.rolling() called"); + + // Mark as recording + $commonApi.rec.recording = true; + + console.log(`📼 Quick tape: Recording initiated! Duration=${defaultDuration}s`); + return; // Don't process this event further + } + + if (data.name.indexOf("keyboard:down") === 0) { + // [Escape] (Deprecated on 23.05.22.19.33) + // If not on prompt, then move backwards through the history of + // previously loaded pieces in a session. + // if ( + // data.key === "Escape" && + // currentPath !== "aesthetic.computer/disks/prompt" + // ) { + // if (pieceHistoryIndex > 0) { + // send({ type: "back-to-piece" }); + // } else { + // // Load the prompt automatically. + // // $api.load("prompt"); Disabled on 2022.05.07.03.45 + // } + // } + + if ((data.key === "$" || data.key === "Home") && !getPackMode()) { + if (data.ctrl || data.alt) { + const sys = $commonApi.system; + // Make it a painting. + sys.nopaint.replace( + cachedAPI, + graph.cloneBuffer(screen), + "$creenshot", + ); + $commonApi.jump("prompt"); + } else { + downloadScreenshot(); // 🖼️ Take a screenshot. + } + $commonApi.sound.synth({ + tone: 1600, + duration: 0.02, + attack: 0.01, + decay: 0.5, + volume: 0.25, + }); + } + + // ⛈️ Jump back to the `prompt` from anywhere.. + if ( + !getPackMode() && // Disable navigation keys in OBJKT mode + !SOLO_MODE && // Disable navigation keys in solo mode + (data.key === "`" || + data.key === "Enter" || + data.key === "Backspace" || + data.key === "Escape") && + system !== "prompt" && + system !== "world" && + currentText !== "chat" && + currentText !== "laer-klokken" && + currentText !== "sign" && + currentText !== "jas" && + currentPath !== "aesthetic.computer/disks/prompt" + ) { + // If recording, Escape should stop the tape instead of jumping to prompt + if (data.key === "Escape" && $commonApi.rec.recording) { + console.log("🎬 Escape pressed during recording - stopping tape"); + + // Cut the recording first + $commonApi.rec.cut(); + + // Play the sound AFTER a brief delay so it doesn't get recorded + setTimeout(() => { + $commonApi.sound.synth({ + tone: 600, + duration: 0.15, + attack: 0.01, + decay: 0.8, + volume: 0.2, + }); + }, 100); // 100ms delay to ensure recording has stopped + + return; // Don't proceed with navigation + } + // 🎬 Stop demoplay on Escape + if (data.key === "Escape" && typeof $commonApi.system.stopDemoplay === "function") { + leaving = false; + $commonApi.system.stopDemoplay(); + return; + } + // Stop merry pipeline for both Escape and Backspace + let merryOriginalCommand = null; + if (data.key === "Escape" || data.key === "Backspace") { + const stopMerry = $commonApi.system.stopMerryPipeline; + if (typeof stopMerry === "function") { + // Capture original command before stopping (for backspace editing) + if (data.key === "Backspace" && $commonApi.system.merry?.originalCommand) { + merryOriginalCommand = $commonApi.system.merry.originalCommand; + } + // Reset leaving flag to allow merry stop to jump + leaving = false; + stopMerry({ + reason: data.key === "Escape" ? "escape" : "backspace", + jumpAfter: false, + jumpTarget: null, + cutTape: true, + }); + } else if ($commonApi.system.merry) { + $commonApi.system.merry.running = false; + delete $commonApi.system.merry; + } + } + + $commonApi.sound.synth({ + tone: data.key === "Backspace" ? 800 : 1200, + beats: 0.1, + attack: 0.01, + decay: 0.5, + volume: 0.15, + }); + + send({ type: "keyboard:unlock" }); + + // 🌐 Branded domain: Escape/backtick/Enter navigates to aesthetic.computer + if (data.key !== "Backspace" && redirectIfBrandedDomain()) return; + + if (!labelBack || data.key === "Backspace" || data.key === "Escape") { + let promptSlug = "prompt"; + if (data.key === "Backspace") { + triggerBackspaceAction(merryOriginalCommand); + } else { + $commonApi.jump(promptSlug); + send({ type: "keyboard:open" }); + } + } else { + if ($commonApi.history.length > 0) { + send({ type: "back-to-piece" }); + // $commonApi.jump( + // $commonApi.history[$commonApi.history.length - 1], + // ); + } else { + $commonApi.jump(promptSlug); + send({ type: "keyboard:open" }); + } + } + } + + // [Tab] Toggle HUD label and QR overlay visibility with smooth animation + // Disabled in pack mode (OBJKT bundles) + // Only works in KidLisp pieces (to avoid conflict with autocomplete) + if (data.key === "Tab" && !getPackMode()) { + const isKidLispPiece = detectKidLispPiece({ currentPath, currentHUDTxt, currentText, cleanText: currentHUDTxt }); + if (isKidLispPiece) { + const currentTime = performance.now(); + const timeSinceLastTab = currentTime - (hudAnimationState.lastTabTime || 0); + const isDoubleTap = timeSinceLastTab < 300; // 300ms double-tap window + + hudAnimationState.lastTabTime = currentTime; + // Skip sound only for embedded/inline KidLisp (kidlisp.com editor, PJ KidLisp) + const isEmbedded = detectEmbeddedKidLisp({ currentPath, currentHUDTxt, currentText }); + toggleHUDVisibility(isDoubleTap, isEmbedded); + } + } + + // [Shift] Toggle QR code fullscreen mode for KidLisp pieces + // if (data.key === "Shift") { + // console.log("⌨️ [Shift Key] Received! getPackMode():", getPackMode()); + // } + if (data.key === "Shift" && !getPackMode()) { + toggleQRFullscreen(); + } + + // [Ctrl + X] + // Enter and exit fullscreen mode. + if (data.key === "x" && data.ctrl && currentText !== "notepat" && !getPackMode()) { + send({ type: "fullscreen-enable" }); + } + + // [Ctrl + D] + // Toggle auto-density override — force full density even when FPS is low. + if (data.key === "d" && data.ctrl && globalKidLispInstance) { + if (globalKidLispInstance.autoDensityOverride) { + // Re-enable auto-density + globalKidLispInstance.autoDensityOverride = false; + if (typeof window !== 'undefined') window.acAutoDensityOverride = false; + globalKidLispInstance.enableAutoDensity(); + } else { + // Force full density — disable auto-scaling + globalKidLispInstance.autoDensityOverride = true; + if (typeof window !== 'undefined') window.acAutoDensityOverride = true; + globalKidLispInstance.disableAutoDensity(); + } + } + } + }); + + // Add 'loading' status to $commonApi. + $commonApi.loading = loading; // Let the piece know if we are already + // loading another piece. + + // Globalize any background music data, retrievable via bgm.data + $commonApi.bgm.data = { + amplitude: content.audioMusicAmplitude, + sample: content.audioMusicSampleData, + }; + + // Synthetic audio for headless captures — feed KidLisp audio globals + if (SPOOF_AUDIO_MODE && content.audioMusicAmplitude > 0) { + const norm = content.audioMusicAmplitude / 255; // 0–1 + updateKidLispAudio({ + amp: norm * 10, + leftAmp: norm * 10, + rightAmp: norm * 10, + __source: "spoof", + }); + } + + // Hand-tracking + if (content.hand) $commonApi.hand = { mediapipe: content.hand }; + + // Pens + if (content.pen) { + const primaryPointer = help.findKeyAndValue( + content.pen.pointers, + "isPrimary", + true, + ); + + // Returns all [pens] if n is undefined, or can return a specific pen by 1 based index. + // [pens] are sorted by `pointerIndex` + + // TODO: Including "help.findKeyAndValue" seems to bring a lot of + // allocation here because it keeps the whole API around? + // Re-test this when pointers is not empty! 22.11.12.20.02 + const pointers = content.pen.pointers; + const pointersValues = Object.values(pointers); + + // Make all available dragBoxes into `Box` instances. + pointersValues.forEach((p) => { + if (p.dragBox) p.dragBox = geo.Box.from(p.dragBox); + }); + + const pens = pointersValues.reduce((arr, value) => { + arr[value.pointerNumber] = value; + return arr; + }, []); + + // if (pens.length > 0 && debug) + // console.log("Pens:", pens, content.pen.events); + + $commonApi.pens = function (n) { + if (n === undefined) return pens; + return help.findKeyAndValue(pointers, "pointerNumber", n - 1) || {}; + }; + + if (pointersValues.length > 1 && primaryPointer) + primaryPointer.multipen = true; // Set a flag for multipen activity on main pen API object. + + $commonApi.pen = primaryPointer; + + if ( + screen && + primaryPointer && + (primaryPointer.delta?.x !== 0 || primaryPointer.delta?.y !== 0) + ) { + // Skip sending fairy points in KidLisp pieces. + const isKidlisp = detectKidLispPiece({ currentPath, currentHUDTxt, currentText }) || + (currentPath && currentPath.endsWith('.lisp')); + if (!isKidlisp) { + udp?.send("fairy:point", { + x: primaryPointer.x / screen.width, + y: primaryPointer.y / screen.height, + }); + } + } + } + + // 🕶️ VR Pen + $commonApi.pen3d = content.pen3d?.pen; + + // Add upload event to allow the main thread to open a file chooser. + // type: Accepts N mimetypes or file extensions as comma separated string. + // Usage: upload(".jpg").then((data) => ( ... )).catch((err) => ( ... )); + $commonApi.sideload = (type) => { + const prom = new Promise((resolve, reject) => { + fileImport = { resolve, reject }; + }); + send({ type: "import", content: type }); + return prom; + }; + + // 🤖 Sim // no send + $commonApi.seconds = function (s) { + return s * 120; // TODO: Get 120 dynamically from the Loop setting. 2022.01.13.23.28 + }; + + // 🔈 Sound + // TODO: Most of the $sound api doesn't need to be generated per + // frame. 24.01.14.15.19 + + // For reference in `freq` below. + const noteFrequencies = { + c: 16.35, + "c#": 17.32, + db: 17.32, + d: 18.35, + "d#": 19.45, + eb: 19.45, + e: 20.6, + f: 21.83, + "f#": 23.12, + gb: 23.12, + g: 24.5, + "g#": 25.96, + ab: 25.96, + a: 27.5, + "a#": 29.14, + bb: 29.14, + b: 30.87, + }; + + const $sound = { + get time() { + const currentTime = content.audioTime; + // Add comprehensive logging for audio timing (throttled to avoid spam) + // if (debug && Math.floor(currentTime * 100) % 100 === 0) { // Log every 10ms when rounded + // console.log(`🎵 AUDIO_TIME: ${currentTime.toFixed(6)}s (getter called from ${(new Error().stack.split('\n')[2] || 'unknown').trim()})`); + // } + return currentTime; + }, + // 🎹 DAW state (populated via daw:tempo, daw:transport, daw:phase, daw:samplerate messages from M4L) + // Uses persistentDawState which survives frame updates (unlike $commonApi.sound which is recreated) + get daw() { + return persistentDawState; + }, + // 🎛️ Spreadnob state (populated via spreadnob:* messages from M4L) + get spreadnob() { + return persistentSpreadnobState; + }, + // Get the bpm with bpm() or set the bpm with bpm(newBPM). + bpm: function (newBPM) { + if (newBPM) sound.bpm = newBPM; + return sound.bpm; + }, + enabled: () => { + return AUDIO_SAMPLE_RATE > 0; + }, + // Compute the frequency of a musical note. + // 🗒️ Can take a number or formatted octave string such as 5C# or even C#5 for C sharp in fifth octave. + freq: function (input) { + // console.log("🎵 Note to check:", input); + // Return if it's just a number or parses as one. + if (typeof input === "number") return input; + if (input === null || input === undefined) return null; + + const trimmed = String(input).trim(); + if (trimmed === "") return null; + + if (!isNaN(parseFloat(trimmed)) && isFinite(trimmed)) return Number(trimmed); + + let octave = 4; + let note = trimmed.toLowerCase(); // Downcase everything. + + // Handle special tokens that shouldn't resolve to a frequency + if (note === "rest" || note === "pause" || note === "_" || note === "speech") return null; + + const prefixMatch = note.match(/^(-?\d+)([a-z#/+\-]+)$/); + const suffixMatch = note.match(/^([a-z#/+\-]+)(-?\d+)$/); + + if (prefixMatch) { + octave = parseInt(prefixMatch[1], 10); + note = prefixMatch[2]; + } else if (suffixMatch) { + note = suffixMatch[1]; + octave = parseInt(suffixMatch[2], 10); + } + + // Remove trailing + or - markers and other notation artifacts + note = note.replace(/[+\-]+$/g, ""); + + // Replace notepat "s"/"f" suffixes with standard accidentals when appropriate + note = note.replace(/^([a-g])s$/, "$1#").replace(/^([a-g])f$/, "$1b"); + + const frequency = noteFrequencies[note]; // Look up freq for the note. + if (!frequency) throw new Error("Note not found in the list"); + + // Calculate the frequency for the given octave + const finalFreq = frequency * Math.pow(2, octave); + return finalFreq; + }, + // Calculate a musical note from a frequency. + note: function (frequency) { + let closestNote = "", + minDiff = Infinity; + for (let octave = 0; octave <= 8; octave++) { + for (let note in noteFrequencies) { + const noteFrequency = noteFrequencies[note] * Math.pow(2, octave); + const diff = Math.abs(frequency - noteFrequency); + if (diff < minDiff) { + minDiff = diff; + closestNote = note + octave; + } + } + } + + return closestNote.toUpperCase(); + }, + // MIDI + midi: { + connect: () => send({ type: "midi:connect" }), + // Convert MIDI note number to note string + note: function (midiNumber) { + const noteNames = [ + "C", + "C#", + "D", + "D#", + "E", + "F", + "F#", + "G", + "G#", + "A", + "A#", + "B", + ]; + const octave = floor(midiNumber / 12) - 1; + const noteIndex = midiNumber % 12; + return noteNames[noteIndex] + octave; + }, + }, + // Rendering + paint: { + bars: function paintSound( + { ink, box, screen, num }, + amplitude, + waveform, + x, + y, + width, + height, + color, + options = { noamp: false, nobounds: false }, + ) { + const yMid = round(y + (height - 2) / 2), + yMax = round((height - 2) / 2); + let lw = options.noamp ? 0 : 4; // levelWidth; + const xStep = (width - lw) / waveform.length; + + // Vertical bounds. + if (!options.nobounds) { + ink("yellow") + .line(x + lw, y, x + width - 1, y) + .line(x + lw, y + height, x + width - 1, y + height); + } + + // Level meter. + if (!options.noamp) { + ink("black").box(x, y, lw, height + 1); + ink("green").box(x, y + height, lw, -amplitude * height); + } + + // Filled waveform + const waves = waveform.map((v, i) => { + if (v < -1) v = -1; + if (v > 1) v = 1; + return [x + lw + i * xStep, yMid + v * yMax]; + }); + + ink(options.secondaryColor || "black").box( + x + lw, + y + 1, + width - lw, + height - 1, + ); + + ink(options.primaryColor || "white"); + + let remainder = 0; + let totalWidthCovered = 0; + + waves.forEach((point, index) => { + let bx = x + lw + totalWidthCovered; + if (bx >= x + width) return; + // Compute the pixel-aligned width for the current bar. + let barWidth = Math.floor(xStep + remainder); + remainder = (xStep + remainder) % 1; // Collect the fractional remainder. + // Ensure we don't exceed the full width for the last bar. + if (index === waves.length - 1 || bx + barWidth >= x + width) + barWidth = x + width - bx; + box( + bx, + y + point[1] + 1 - y, + barWidth, + y + (height - 1) - point[1], + ); + totalWidthCovered += barWidth; + }); + + // Waveform + // ink("lime", 255).poly( + // waveform.map((v, i) => [x + lw + i * xStep, yMid + v * yMax]), + // ); + + // TODO: Fill a point above this line and below. + // ink("blue").flood(x + 7, y + 1); + // ink("teal").flood(x + 7, y + height - 2); + + // const my = screen.height - mic.amplitude * screen.height; + // ink("yellow", 128).line(0, my, screen.width, my); // Horiz. line for amplitude. + }, + + // Paints a waveform with a bounding box based on amplitude. + waveform: function paintWaveform( + { ink }, + amplitude, + waveform, + x, + y, + width, + height, + color = "yellow", + options, + ) { + if (waveform?.length < 1) return; + const direction = options?.direction || "left-to-right"; + if (direction === "left-to-right") { + const xStep = width / (waveform.length - 1); + + const yMid = y + height / 2, + yMax = height / 2; + + ink(color, 128).poly( + waveform.map((v, i) => { + const p = [x + i * xStep, yMid + (v || 0) * yMax]; + return p; + }), + ); + } else if (direction === "bottom-to-top") { + const yStep = height / (waveform.length - 1); + const xMid = x + width / 2, + xMax = width; + + ink("blue", 128).poly( + waveform.map((v, i) => { + const p = [xMid + (v || 0) * xMax, y + height - i * yStep]; + return p; + }), + ); + } else { + console.warn("🌊 Unsupported direction."); + } + }, + + // Renders an "AUDIO ENGINE OFF" badge when the audio context is + // suspended. Returns { visible, x, y, width, height } so callers + // can react to its layout. + audioEngineBadge: function paintAudioEngineBadge( + { ink, screen }, + speaker, + x, + y, + options = {}, + ) { + const waveforms = speaker?.waveforms?.left; + const amp = speaker?.amplitudes?.left; + const ready = + waveforms && + typeof waveforms.length === "number" && + waveforms.length > 0 && + typeof amp === "number" && + Number.isFinite(amp); + + if (ready) return { visible: false, x: 0, y: 0, width: 0, height: 0 }; + + const maxWidth = + options.maxWidth != null ? options.maxWidth : screen.width - 20; + const padding = 4; + const badgeHeight = options.height || 12; + const align = options.align || "center"; // "center" | "left" | "right" + + // Responsive text choices + const texts = ["AUDIO ENGINE OFF", "ENGINE OFF", "OFF"]; + let chosen = texts[0]; + for (const t of texts) { + // Approximate glyph width for MatrixChunky8 is 6px per char + 1px gap + const tw = t.length * 7; + if (tw + padding * 2 <= maxWidth) { + chosen = t; + break; + } + } + const textWidth = chosen.length * 7; + const totalWidth = textWidth + padding * 2; + + let bx = x; + if (align === "center") { + bx = x != null ? x : Math.floor((screen.width - totalWidth) / 2); + } else if (align === "right") { + bx = (x != null ? x : screen.width) - totalWidth; + } + + ink(180, 0, 0, 240).box(bx, y, totalWidth, badgeHeight); + ink(255, 255, 0).write( + chosen, + { x: bx + padding, y: y + 3 }, + undefined, + undefined, + false, + "MatrixChunky8", + ); + + return { visible: true, x: bx, y, width: totalWidth, height: badgeHeight }; + }, + }, + }; + + $sound.microphone = microphone; + $sound.speaker = speaker; + $sound.gameboy = gameboy; + $sound.sampleRate = AUDIO_SAMPLE_RATE; + + // TODO: Generalize square and bubble calls. + // TODO: Move this stuff to a "sound" module. + sound.bpm = content.audioBpm; + + // Clear synchronized audio triggers. + // soundClear = () => { + // sound.sounds.length = 0; + // sound.bubbles.length = 0; + // sound.kills.length = 0; + // }; + + // Trigger a named audio sample to playback in the `bios`. + // options: { volume: 0-n, pan: 0-2?, loop: Bool, ...(there is more) } + + $sound.getSampleData = async function getSampleData(id) { + const prom = new Promise((resolve, reject) => { + sfxSampleReceivers[id] = resolve; + return { resolve, reject }; + }); + send({ type: "sfx:get-sample-data", content: { id } }); + return prom; + }; + + $sound.registerSample = function registerSample(id, data, sampleRate) { + if (!id || !data) return; + send({ type: "sfx:register", content: { id, data, sampleRate } }); + }; + + // 🔄 Live buffer update - updates sample data for currently playing sounds + $sound.updateSample = function updateSample(id, data, sampleRate) { + if (!id || !data) return; + send({ type: "sfx:update-sample", content: { id, data, sampleRate } }); + }; + + soundTime = content.audioTime; + + $sound.play = function play(sfx, options, callbacks) { + const id = sfx + "_" + $sampleCount; // A *unique id for this sample. + $sampleCount += 1n; + + send({ type: "sfx:play", content: { sfx, id, options } }); + + const playingSound = { + options, // Allow the options passed to BIOS to be inspected. + startedAt: soundTime, // performance.now(), + killed: false, + kill: (fade) => { + send({ type: "sfx:kill", content: { id, fade } }); + }, + progress: async () => { + if (playingSound.killed) return { progress: 0 }; + const prom = new Promise((resolve, reject) => { + sfxProgressReceivers[id] = (progressData) => { + // Enhanced logging for audio progress tracking + if (debug && progressData?.progress !== undefined) { + const timeElapsed = soundTime - playingSound.startedAt; + // console.log(`🎵 AUDIO_PROGRESS: id=${id.substring(id.lastIndexOf('_') + 1)}, progress=${progressData.progress.toFixed(6)}, elapsed=${timeElapsed.toFixed(3)}s, started=${playingSound.startedAt.toFixed(3)}s, current=${soundTime.toFixed(3)}s`); + + // Detect potential timing issues - compare in consistent units + if (progressData.progress > 0 && timeElapsed > 0 && progressData.duration) { + // Convert audio progress to actual time for comparison + const actualAudioTime = progressData.progress * progressData.duration; + const timeDrift = Math.abs(actualAudioTime - timeElapsed); + const driftPercent = (timeDrift / progressData.duration) * 100; + + if (driftPercent > 5) { // 5% drift threshold + console.warn(`🎵 SYNC_DRIFT: actual=${actualAudioTime.toFixed(3)}s, expected=${timeElapsed.toFixed(3)}s, drift=${timeDrift.toFixed(3)}s (${driftPercent.toFixed(1)}%)`); + } + } + } + resolve(progressData); + }; + return { resolve, reject }; + }); + send({ type: "sfx:progress", content: { id } }); + return prom; + }, + update: function (properties) { + if (properties.shift || properties.sampleSpeed !== undefined) { + send({ + type: "sfx:update", + content: { id, properties }, + }); + } + }, + }; + + sfxKillReceivers[id] = () => { + callbacks?.kill?.(); + playingSound.killed = true; + }; + + return playingSound; + }; + + $sound.skip = function () { + send({ type: "beat:skip" }); + }; + + $sound.at = function (timeToRun, callback) { + // TODO: Finish this implementation. + // timeToRun; + // content.audioTime; + }; + + $sound.getDuration = async function getDuration(id) { + const prom = new Promise((resolve, reject) => { + sfxDurationReceivers[id] = resolve; + return { resolve, reject }; + }); + send({ type: "sfx:get-duration", content: { id } }); + return prom; + }; + + $sound.synth = function synth({ + tone = 440, // hz, or musical note + type = "square", // "sine", "triangle", "square", "sawtooth", "custom" + // "noise-white" <-ignores tone + duration = 0.1, // In seconds... (where beats is a shortcut) + beats = undefined, // 🧧 Should this be deprecated? + attack = 0.01, // How quickly the sound starts. + decay = 0.9, // A multiplier for when the sound fades. + volume, + pan = 0, + generator = null, // Custom waveform generator function for type "custom" + } = {}) { + const id = soundId; + if (volume === undefined) volume = 1; + if (duration === "🔁") duration = Infinity; // First emoji in the API. 24.07.03.02.26 + if (beats === undefined && duration !== undefined) + beats = (duration * sound.bpm) / 60; + + tone = $sound.freq(tone); + // console.log("⛈️ Tone:", tone); + // Add generator to sound data for custom type + const soundData = { id, type, tone, beats, attack, decay, volume, pan }; + if (type === "custom" && generator) { + soundData.generator = generator.toString(); // Convert function to string for postMessage + } + + sound.sounds.push(soundData); + soundId += 1n; + let seconds; + if (beats === undefined && duration !== undefined) seconds = duration; + else seconds = (60 / sound.bpm) * beats; + // console.log("Beats:", beats, "Duration:", duration, "Seconds:", seconds, "BPM:", sound.bpm); + + const end = soundTime + seconds; + + return { + startedAt: soundTime, // performance.now(), + id, + kill: function (fade) { + sound.kills.push({ id, fade }); + }, + progress: function (time) { + return 1 - max(0, end - time) / seconds; + }, + update: function (properties) { + if (properties.tone) properties.tone = $sound.freq(properties.tone); + send({ + type: "synth:update", + content: { id, properties }, + }); + }, + updateGenerator: function (newGenerator) { + if (type === "custom") { + send({ + type: "update-generator", + content: { id, generator: newGenerator.toString() }, // Convert function to string + }); + } + }, + }; + }; + $sound.bubble = function ({ radius, rise, volume = 1, pan = 0 } = {}) { + const id = soundId; + sound.bubbles.push({ id, radius: radius, rise, volume, pan }); + soundId += 1n; + + return { + startedAt: soundTime, + id, + kill: function (fade) { + sound.kills.push({ id, fade }); + }, + update: function (properties) { + send({ + type: "bubble:update", + content: { id, properties }, + }); + }, + enableSustain: function () { + send({ + type: "bubble:update", + content: { id, properties: { sustain: true } }, + }); + }, + disableSustain: function () { + send({ + type: "bubble:update", + content: { id, properties: { sustain: false } }, + }); + }, + }; + }; + + $sound.kill = function (id, fade) { + sound.kills.push({ id, fade }); + }; + + // 🏠 Room/Reverb API - global reverb effect + $sound.room = { + // Toggle room reverb on/off + toggle: function () { + send({ type: "room:toggle" }); + }, + // Enable room reverb + on: function () { + send({ type: "room:set", content: { enabled: true } }); + }, + // Disable room reverb + off: function () { + send({ type: "room:set", content: { enabled: false } }); + }, + // Set room parameters: { enabled, mix (0-1), feedback (0-0.95) } + set: function (options = {}) { + send({ type: "room:set", content: options }); + }, + // Get current room state (async via callback) + get: function () { + send({ type: "room:get" }); + }, + }; + + // 🧩 Glitch API - global raw synth effect + $sound.glitch = { + // Toggle glitch on/off + toggle: function () { + send({ type: "glitch:toggle" }); + }, + // Enable glitch + on: function () { + send({ type: "glitch:set", content: { enabled: true } }); + }, + // Disable glitch + off: function () { + send({ type: "glitch:set", content: { enabled: false } }); + }, + // Set glitch parameters: { enabled, mix (0-1), crush (2-12), rate (Hz), jitter (0-1) } + set: function (options = {}) { + send({ type: "glitch:set", content: options }); + }, + // Get current glitch state (async via callback) + get: function () { + send({ type: "glitch:get" }); + }, + }; + + $commonApi.sound = $sound; + + // System beep. + $commonApi.beep = (tone = 1200) => { + $sound.synth({ + tone, + beats: 0.1, + attack: 0.01, + decay: 0.5, + volume: 0.25, + }); + }; + + // Act & Sim (Occurs after first boot and paint, `boot` occurs below.) + if (booted && paintCount > 0n /*&& !leaving*/) { + let $api = {}; + keys($commonApi).forEach((key) => ($api[key] = $commonApi[key])); + keys($updateApi).forEach((key) => ($api[key] = $updateApi[key])); + keys(painting.api).forEach((key) => ($api[key] = painting.api[key])); + $api.api = $api; // Add a reference to the whole API. + + // 🎮 Add gamepad helper API (separate event streams per gamepad) + $api.gamepads = createGamepadAPI(content.gamepads); + + // Set the robo API context so it can send events + $api.robo.setAPI($api); + + $api.inFocus = inFocus; + + // 🔒 Apply security restrictions for untrusted pieces + if (pieceMetadata && shouldRestrictPiece(pieceMetadata)) { + $api = createRestrictedApi($api, pieceMetadata); + } + + cachedAPI = $api; // Remember this API for any other acts outside + // of this loop, like a focus change or custom act broadcast. + + // Use screen.width/height instead of content.width/height to get the most up-to-date dimensions + // content.width/height can be stale if a reframe just happened + $api.screen = { + width: screen.width, + height: screen.height, + pixels: screen.pixels, + }; + + $api.cursor = (code) => (cursorCode = code); + + // 📻 Signaling + $api.signal = (content) => { + send({ type: "signal", content }); + }; + + // Deprecated on 23.07.01.15.31 (Remove later if no regressions.) + // if (initialSim) { + // console.log("initial", initialSim, content.updateCount, 'paintcount', paintCount); + // simCount += 1n; + // $api.simCount = simCount; + // try { + // sim($api); + // } catch (e) { + // console.warn("🧮 Sim failure...", e); + // } + // initialSim = false; + // } else + + if (content.updateCount > 0 && paintCount > 0n) { + // Run `sim` the number of times as requested from `bios`. + for (let i = content.updateCount; i--; ) { + simCount += 1n; + $api.simCount = simCount; + try { + sim($api); + noticeTimer?.step(); // Globally tick the noticeTimer if it exists. + // ⌛ Run through all the global hourglass timers. + for (let i = hourGlasses.length - 1; i >= 0; i--) { + hourGlasses[i].step(); + if (hourGlasses[i].complete && !hourGlasses[i].autoFlip) + hourGlasses.splice(i, 1); + } + $api.rec.tapeTimerStep($api); + } catch (e) { + console.warn("🧮 Sim failure...", e); + } + } + } + + // 🌟 Act + // *Device Event Handling* + + // TODO: Shouldn't all these events come in as part of one array to + // keep their order of execution across devices? + // TODO: Could "device" be removed in favor of "device:event" strings and + // if needed, a device method? + + // Window Events + + // Reframing the piece... (resizing the window). + if (reframed === true) { + + $api.event = { + device: "none", + is: (e) => e === "reframed", + }; + try { + act($api); + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + reframed = false; + + // Global reframings. + // currentPromptButton?.reposition({ + // left: 6, + // bottom: 6, + // screen: $api.screen, + // }); + } + + // If a disk failed to load, then notify the disk that loaded it + // by checking to see if loadFailure has anything set. + if (loadFailure) { + $api.event = { + error: loadFailure, + is: (e) => e === "load-error", + }; + try { + act($api); + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + send({ type: "load-failure" }); + loadFailure = undefined; + } + + // Signaling + if (signals.length) { + const data = { signal: signals }; + Object.assign(data, { + device: "none", + is: (e) => e === "signal", + }); + $api.event = data; + try { + act($api); + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + signals.length = 0; + } + + // Keyboard Paste Event + // if (content.clipboardText) { + // const data = { text: content.clipboardText }; + // Object.assign(data, { + // device: "none", + // is: (e) => e === "pasted:text", + // }); + // $api.event = data; + // try { + // act($api); + // } catch (e) { + // console.warn("️ ✒ Act failure...", e); + // } + // } + + // *** Pen Events *** + // Ingest all pen input events by running act for each event. + // TODO: I could also be transforming pen coordinates here... + // TODO: Keep track of lastPen to see if it changed. + const startTime = performance.now(); + let penEventCount = 0; + + content.pen?.events.forEach((data) => { + penEventCount++; + Object.assign(data, { + device: data.device, + hudButtonActive: currentHUDButtonActive, // Add global HUD button flag + is: (e) => { + let [name, pointer] = e.split(":"); + if (pointer) { + if (pointer === "any") { + return name === data.name; + } else { + return name === data.name && data.index === parseInt(pointer); + } + } else { + return name === data.name && data.isPrimary === true; + } + }, + }); + + //console.log(data) + $api.event = data; + // 🌐🖋️️ Global pen events. + try { + // Always check to see if there was a tap on the corner. + const { event: e, jump, send, sound, system, piece } = $api; + let originalColor; + let masked = false; + + if ( + e.is("touch:5") && + piece !== "notepat" && + piece !== "stample" && + piece !== "toss" + ) { + sound.synth({ + tone: 1600, + duration: 0.02, + attack: 0.01, + decay: 0.5, + volume: 0.25, + }); + system.nopaint.replace( + cachedAPI, + graph.cloneBuffer(screen), + "$creenshot", + ); + jump("prompt"); + } + + // Corner prompt button. + if (!TV_MODE) { + currentHUDButton?.act(e, { + down: () => { + originalColor = currentHUDTextColor; + currentHUDScrub = 0; + currentHUDScrubReadyState = null; + currentHUDTextColor = [0, 255, 0]; + send({ type: "keyboard:enabled" }); // Enable keyboard flag. + send({ type: "keyboard:unlock" }); + $api.needsPaint(); + + // Set global flag to block other button interactions + currentHUDButtonActive = true; + // Track that this was a direct touch on the HUD button + currentHUDButtonDirectTouch = currentHUDButton.box.contains(e); + + // Mask unless we are in the camera. + if ($api.slug !== "camera") masked = true; + + $api.sound.synth({ + tone: 600, + beats: 0.1, + attack: 0.01, + decay: 0.5, + volume: 0.25, + }); + }, + push: (btn) => { + // Block HUD label navigation in solo mode + if (SOLO_MODE) return; + + const fallbackShareWidth = tf.blockWidth * "share ".length; + const shareWidth = Math.max(currentHUDShareWidth || 0, fallbackShareWidth); + const editWidth = Math.max(currentHUDEditWidth || 0, getHudEditScrubThreshold()); + + // 📦 PACK mode: no scrub-to-share, tap refreshes page + if (getPackMode()) { + currentHUDButtonActive = false; + currentHUDButtonDirectTouch = false; + currentHUDScrub = 0; + currentHUDScrubReadyState = null; + $api.sound.synth({ + tone: 1200, + beats: 0.1, + attack: 0.01, + decay: 0.5, + volume: 0.15, + }); + if (typeof window !== 'undefined') window.location.reload(); + masked = true; + $api.needsPaint(); + return; + } + + // If scrubbed to reveal "share", jump to share piece + if (currentHUDScrub >= shareWidth) { + currentHUDButtonActive = false; + currentHUDButtonDirectTouch = false; + triggerShareAction(); + return; + } + + // If scrubbed left to edit threshold, trigger backspace-to-edit + if (currentHUDScrub <= -editWidth) { + currentHUDButtonActive = false; + currentHUDButtonDirectTouch = false; + triggerBackspaceAction(); + return; + } + + // If scrubbed but not to max, cancel the push + if ( + (currentHUDScrub > 0 && currentHUDScrub < shareWidth) || + (currentHUDScrub < 0 && currentHUDScrub > -editWidth) + ) { + btn.actions.cancel?.(); + return; + } + + // Clear global HUD button flags + currentHUDButtonActive = false; + currentHUDButtonDirectTouch = false; + + $api.sound.synth({ + tone: 1200, + beats: 0.1, + attack: 0.01, + decay: 0.5, + volume: 0.15, + }); + + // 🌐 Branded domain: tapping HUD label navigates to aesthetic.computer + if (redirectIfBrandedDomain()) return; + + if (!labelBack) { + jump("prompt"); + } else { + labelBack = false; // Reset `labelBack` after jumping. + if ($commonApi.history.length > 0) { + send({ type: "back-to-piece" }); + } else { + jump("prompt"); + } + } + $api.needsPaint(); + masked = true; + currentHUDScrub = 0; + currentHUDScrubReadyState = null; + }, + scrub: (btn) => { + if (getPackMode()) return; // No scrub-to-share in bundled mode. + if (piece === "share") return; // No need to share scrub while in share. + if (currentHUDQR || currentHUDQRCells?.length || hudAnimationState.qrFullscreen) return; // Disable swipe-to-share when QR is shown. + + // Only allow scrubbing if this was a direct touch on the HUD button + if (!currentHUDButtonDirectTouch) return; + + if (btn.over || currentHUDScrub !== 0) { + const deltaX = Number(e?.delta?.x) || 0; + currentHUDScrub += deltaX; + } + + const fallbackShareWidth = (currentHUDLabelBlockWidth || DEFAULT_TYPEFACE_BLOCK_WIDTH) * "share ".length; + const shareWidth = Math.max(currentHUDShareWidth || 0, fallbackShareWidth); + const editWidth = Math.max(currentHUDEditWidth || 0, getHudEditScrubThreshold()); + + if (currentHUDScrub >= 0) { + const fallbackWidth = hudAnimationState.labelWidth + ? Math.max(hudAnimationState.labelWidth - currentHUDScrub, 0) + : currentHUDLabelBlockWidth * (currentHUDPlainTxt?.length || currentHUDTxt.length); + const baseWidth = currentHUDLabelMeasuredWidth ?? fallbackWidth; + btn.box.w = baseWidth + currentHUDScrub; + // console.log(btn.b); + } + + if (currentHUDScrub > shareWidth) currentHUDScrub = shareWidth; + if (currentHUDScrub < -editWidth) currentHUDScrub = -editWidth; + + let nextReadyState = null; + if (currentHUDScrub >= shareWidth) nextReadyState = "share"; + if (currentHUDScrub <= -editWidth) nextReadyState = "edit"; + + if (nextReadyState !== currentHUDScrubReadyState && nextReadyState !== null) { + $api.sound.synth({ + tone: nextReadyState === "share" ? 1600 : 900, + beats: 0.07, + attack: 0.01, + decay: 0.2, + volume: 0.12, + }); + } + currentHUDScrubReadyState = nextReadyState; + + if (currentHUDScrub >= shareWidth) { + currentHUDScrub = shareWidth; + currentHUDTextColor = [0, 255, 255]; + } else if (currentHUDScrub <= -editWidth) { + currentHUDScrub = -editWidth; + currentHUDTextColor = [255, 255, 0]; + } else if (currentHUDScrub > 0) { + currentHUDTextColor = [0, 200, 200]; + } else if (currentHUDScrub < 0) { + currentHUDTextColor = [255, 120, 180]; + } else if (currentHUDScrub === 0) { + if (btn.over) { + currentHUDTextColor = [0, 255, 0]; + } else { + currentHUDTextColor = [255, 0, 0]; + } + } + + $api.needsPaint(); + }, + cancel: () => { + currentHUDTextColor = originalColor; + + // Clear global HUD button flags + currentHUDButtonActive = false; + currentHUDButtonDirectTouch = false; + + const fallbackShareWidth = tf.blockWidth * "share ".length; + const shareWidth = Math.max(currentHUDShareWidth || 0, fallbackShareWidth); + const editWidth = Math.max(currentHUDEditWidth || 0, getHudEditScrubThreshold()); + if (currentHUDScrub === shareWidth) { + triggerShareAction(); + return; + } + + if (currentHUDScrub === -editWidth) { + triggerBackspaceAction(); + return; + } + + if (currentHUDScrub !== 0) { + $api.sound.synth({ + tone: 260, + beats: 0.09, + attack: 0.01, + decay: 0.2, + volume: 0.12, + }); + } + + currentHUDScrub = 0; + currentHUDScrubReadyState = null; + // TODO: This might break on pieces where the keyboard is already + // open. + send({ type: "keyboard:disabled" }); // Disable keyboard flag. + send({ type: "keyboard:lock" }); + $api.needsPaint(); + $api.sound.synth({ + tone: 200, + beats: 0.1, + attack: 0.01, + decay: 0.5, + volume: 0.15, + }); + }, + rollover: (btn) => { + if (btn) { + send({ type: "keyboard:unlock" }); + if (btn.down) { + currentHUDTextColor = [0, 255, 0]; + } + } + }, + rollout: () => { + // console.log("rolled out..."); + currentHUDTextColor = [200, 80, 80]; + send({ type: "keyboard:lock" }); + }, + }); + } + + if (!masked && !TV_MODE) act($api); // Run the act function for all pen events (skip in TV mode). + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + }); + + // *** 3D Pen Events *** + content.pen3d?.events?.forEach((data) => { + Object.assign(data, { + is: (e) => { + let [prefix, event, pointer] = e.split(":"); + if ( + prefix === "3d" && + event === data.name && + (pointer === undefined || parseInt(pointer) === data.pointer) + ) + return true; + }, + }); + $api.event = data; + try { + if (!TV_MODE) act($api); // Skip 3D pen events in TV mode + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + }); + + // if (penEventCount > 0) { + // const processingTime = performance.now() - startTime; + //if (processingTime > 5) { // Only log if processing took more than 5ms + //console.log("🖋️ Pen event processing:", { avgPerEvent: (processingTime / penEventCount).toFixed(2) + "ms" + //}); + //} + // } + + // 🎬 Inject demoplay synthetic keyboard events + if ($commonApi.system.demoplay?.syntheticKeyboard?.length) { + const synth = $commonApi.system.demoplay.syntheticKeyboard.splice(0); + content.keyboard = [...(content.keyboard || []), ...synth]; + } + + // Ingest all keyboard input events by running act for each event. + content.keyboard?.forEach((data) => { + Object.assign(data, { + device: "keyboard", + is: (e) => { + const parts = e.split(":"); + if (parts.length > 2) { + // Check for an exact match if `keyboard:action:?` + return data.name === e; + } else { + // Or a subtring match if `keyboard:action` + return data.name.indexOf(e) === 0; + } + }, + }); + $api.event = data; + try { + if (!TV_MODE) act($api); // Execute piece shortcut (skip in TV mode). + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + }); + + // Ingest all gamepad input events by running act for each event. + content.gamepad?.forEach((data) => { + Object.assign(data, { + device: "gamepad", + is: (e) => data.name.indexOf(e) === 0, + }); + $api.event = data; + try { + if (!TV_MODE) act($api); // Execute piece shortcut (skip in TV mode). + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + }); + + // *** UI Cancel Interactions *** (For edge detection) + if (content.type === "ui:cancel-interactions" && content.content) { + const cancelData = { + name: "ui:cancel-interactions", + x: content.content.x, + y: content.content.y, + pointer: content.content.pointer, + reason: content.content.reason, + is: (e) => e === "ui:cancel-interactions", + }; + + $api.event = cancelData; + try { + if (!TV_MODE) act($api); // Execute cancel for all buttons (skip in TV mode) + } catch (e) { + console.warn("️ ✒ Act failure (UI cancel)...", e); + } + } + + // *** Act Alerts *** (Custom events defined in here.) + // These do not run in the initial loader / preview piece. + actAlerts.forEach((action) => { + // Check if `name`'s not a string, and if not, attach arbitrary data. + let name, + extra = {}; + if (typeof action !== "string") { + // Make extra be an object with every key on action other than 'name'. + ({ name, ...extra } = action); + } else { + name = action; + } + + const data = { + name, + is: (e) => e === name, + of: (e) => name.startsWith(e), + ...extra, + }; + + // TODO: All all fields from 'extra' into 'data'. + + $api.event = data; + try { + // In TV mode, only allow certain system events, not user input events + const allowInTvMode = name.startsWith('speech:') || + name.startsWith('dark-mode') || + name.startsWith('light-mode') || + name.startsWith('microphone-') || + name.startsWith('aesthetic-parent:'); + + if (!TV_MODE || allowInTvMode) { + act($api); + } + } catch (e) { + console.warn("️ ✒ Act failure...", e); + } + }); + // 🎬 Forward actAlerts to demoplay conductor for wait-for actions + if ($commonApi.system.demoplay?.eventCallback) { + actAlerts.forEach((action) => { + const name = typeof action === "string" ? action : action.name; + if (name) $commonApi.system.demoplay.eventCallback(name); + }); + } + //if (actAlerts.length > 0) console.log(actAlerts, booted); + actAlerts.length = 0; // Clear act alerts. + } + + // 🖼 Paint + if (content.needsRender) { + let $api = {}; + keys($commonApi).forEach((key) => ($api[key] = $commonApi[key])); + keys(painting.api).forEach((key) => ($api[key] = painting.api[key])); + $api.api = $api; // Add a reference to the whole API. + + // 🎮 Add gamepad helper API (separate event streams per gamepad) + $api.gamepads = createGamepadAPI(content.gamepads); + + // Set the robo API context so it can send events + $api.robo.setAPI($api); + + // 🔒 Apply security restrictions for untrusted pieces + if (pieceMetadata && shouldRestrictPiece(pieceMetadata)) { + $api = createRestrictedApi($api, pieceMetadata); + } + + cachedAPI = $api; // Remember this API for any other acts outside + // of this loop, like a focus change or custom act broadcast. + + // Object.assign($api, $commonApi); + // Object.assign($api, painting.api); + + // Use piece-level frame counter that only increments when piece actually paints + // This ensures frame-based calculations (like in kidlisp) work correctly with FPS limiting + $api.paintCount = pieceFrameCount; + + $api.inFocus = content.inFocus; + + // Make a screen buffer or resize it automatically if it doesn't exist. + + if ( + !screen || + screen.width !== content.width || + screen.height !== content.height + ) { + const hasScreen = screen !== undefined; + + screen = { + pixels: + pixels || new Uint8ClampedArray(content.width * content.height * 4), + width: content.width, + height: content.height, + load: function load(name) { + if (store[name]?.pixels) { + this.pixels = new Uint8ClampedArray(store[name].pixels); + this.width = store[name].width; + this.height = store[name].height; + $commonApi.resize(this.width, this.height); + + // 🎨 Broadcast screen resize from load operation + if ($commonApi.broadcastPaintingUpdate) { + $commonApi.broadcastPaintingUpdate("resized", { + width: this.width, + height: this.height, + source: "screen_load" + }); + } + + return true; + } else { + return false; + } + }, + save: function save(name) { + store[name] = { + pixels: new Uint8ClampedArray(this.pixels), + width: this.width, + height: this.height, + }; + }, + }; + + screen[hasScreen ? "resized" : "created"] = true; // Screen change type. + + // 🎨 Broadcast screen resize to other tabs + if (hasScreen && $commonApi.broadcastPaintingUpdate) { + $commonApi.broadcastPaintingUpdate("resized", { + width: screen.width, + height: screen.height, + source: "screen_resize" + }); + } + + // TODO: Add the depth buffer back here. + // Reset the depth buffer. + // TODO: I feel like this is causing a memory leak... + graph.depthBuffer.length = screen.width * screen.height; + graph.depthBuffer.fill(Number.MAX_VALUE); + + graph.writeBuffer.length = 0; //screen.width * screen.height; + // graph.writeBuffer.fill(0); + } + + // TODO: Disable the depth buffer for now... it doesn't need to be + // regenerated on every frame. + // TODO: This only needs to run if 'form' is running in a piece. 25.03.20.19.27 + graph.depthBuffer.fill(Number.MAX_VALUE); // Clear depthbuffer. + graph.writeBuffer.length = 0; //fill(0); // Clear writebuffer. + + $api.screen = screen; + $api.screen.center = { x: screen.width / 2, y: screen.height / 2 }; + + // Snapshot true screen state each frame (after content.pixels transfer). + // page(sub-buffer) mutates screen in place; _mainScreenObject lets page() + // detect by identity when we're returning to the main screen and restore. + _trueScreenPixels = screen.pixels; + _trueScreenWidth = screen.width; + _trueScreenHeight = screen.height; + _mainScreenObject = screen; + + $api.fps = function (newFps) { + // Use piece-level timing instead of changing global render loop + if (newFps === undefined || newFps === null) { + if (pieceFPS !== null) { + pieceFPS = null; // Remove FPS limit only if it was previously set + } + } else { + // Only initialize timing if FPS wasn't set before, or if it's a different value + if (pieceFPS === null) { + // First time setting FPS - initialize timing + pieceFPS = newFps; + lastPaintTime = 0; // This will trigger "first frame" logic on next paint + } else if (pieceFPS !== newFps) { + // FPS changed to a different value - reset timing + pieceFPS = newFps; + lastPaintTime = 0; // This will trigger "first frame" logic on next paint + } + // If pieceFPS === newFps, do nothing (cache logic) + } + // Don't send fps-change to global render loop anymore + // send({ type: "fps-change", content: newFps }); + }; + + $api.cursor = (code) => (cursorCode = code); + + graph.setBuffer(screen); + + // API Stops being modified here... + /*if (!$activePaintApi)*/ $activePaintApi = $api; + + // Add paintings cache to API for KidLisp ready? function + $activePaintApi.paintings = paintings; + + // 🎯 Initialize global KidLisp instance with the main API + initializeGlobalKidLisp($api); + + // TODO: Set bpm from boot. + /* + $api.sound = { + time: content.time, + bpm: function (newBPM) { + if (newBPM) { + content.bpm[0] = newBPM; + } + return content.bpm[0]; + }, + }; + */ + + // TODO: Boot's painting is currently bound by whatever dirtyBox gets + // set to at the end of `paint`. + + // Run boot only once before painting for the first time. + if (paintCount === 0n && loading === false) { + const dark = await store.retrieve("dark-mode"); // Read dark mode. + if (dark === true || dark === false) $commonApi.dark = dark; + + // System specific preloaders. + //if ($commonApi?.system?.name === "nopaint" || currentText === "prompt") { + + // Create a new painting if one doesn't already exist. + if (!store["painting"]) { + store["painting"] = + (await store.retrieve("painting", "local:db")) || + painting.api.painting(screen.width, screen.height, ($) => { + $.wipe(64); + }); + + store["painting:resolution-lock"] = await store.retrieve( + "painting:resolution-lock", + "local:db", + ); + + store["painting:transform"] = await store.retrieve( + "painting:transform", + "local:db", + ); + + addUndoPainting(store["painting"]); + } + + const sys = $commonApi.system; + sys.painting = store["painting"]; + + // Set the painting record if one is in storage. + if (!sys.nopaint.recording) { + sys.nopaint.record = + (await store.retrieve("painting:record", "local:db")) || []; + + if (sys.nopaint.record.length === 0) { + $commonApi.system.nopaint.startRecord("new"); + } + + sys.nopaint.recording = sys.nopaint.record.length > 0; + } + + sys.nopaint.translation = + store["painting:transform"]?.translation || sys.nopaint.translation; + sys.nopaint.zoomLevel = + store["painting:transform"]?.zoom || sys.nopaint.zoomLevel; + + try { + const bootStartTime = performance.now(); + diskTimings.bootStarted = Math.round(bootStartTime - diskTimingStart); + // Silent: boot() starting + + // Reset zebra cache at the beginning of boot to ensure consistent state + $api.num.resetZebraCache(); + + // Notify boot progress + // console.log("📢 Disk sending boot-log: running boot"); + send({ + type: "boot-log", + content: "running boot" + }); + + if (system === "nopaint") nopaint_boot({ ...$api, params: $api.params, colon: $api.colon }); + await boot($api); + const bootEndTime = performance.now(); + diskTimings.bootComplete = Math.round(bootEndTime - diskTimingStart); + // Silent: boot() completed + booted = true; + log.boot.success("Boot completed, booted =", booted, "pending events:", pendingExportEvents.length); + + // 📨 Flush any pending export events that arrived during boot + if (pendingExportEvents.length > 0) { + log.boot.debug(`Flushing ${pendingExportEvents.length} pending export events`); + log.boot.verbose("receive === defaults.receive:", receive === defaults.receive); + log.boot.verbose("typeof receive:", typeof receive); + const eventsToFlush = [...pendingExportEvents]; + pendingExportEvents = []; + eventsToFlush.forEach(event => { + if (typeof receive === "function" && receive !== defaults.receive) { + try { + log.boot.verbose("Flushing queued event:", event.type); + receive(event); + } catch (e) { + console.warn("📼 ❌ Error flushing event:", event.type, e); + } + } + }); + } + } catch (e) { + console.warn("🥾 Boot failure...", e); + } + send({ type: "disk-loaded-and-booted" }); + } + + // Paint a frame, which can return false to enable caching via noPaint and by + // default returns undefined (assume a repaint). + // Once paint returns false and noPaint is marked true, `needsPaint` must be called. + // Note: Always marked false on a disk's first frame. + + let painted = false; + let dirtyBox; + + // Render a thumbnail instead of the piece. + if (previewMode) { + try { + // Assign a default resolution on first preview, + // which can be over-ridden using `resolution` inside the + // `preview` function. + if (firstPreviewOrIcon) { + if (currentSearch === "preview") { + $api.resolution(1200 / 8, 630 / 8, 0); + } else { + $api.resolution( + ...currentSearch + .split("=")[1] + .split("x") + .map((n) => floor(parseInt(n) / 8)), + 0, + ); + } + firstPreviewOrIcon = false; + } + + preview($api); + painting.paint(true); + painted = true; + paintCount += 1n; + } catch (err) { + console.warn("🖼️ Preview failure...", err); + previewMode = false; + previewOrIconMode = previewMode || iconMode; + } + } else if (iconMode) { + // Render a favicon instead of the piece. + try { + if (firstPreviewOrIcon) { + $api.resolution(128, 128, 0); + if (currentSearch === "icon") { + $api.resolution(128, 128, 0); + } else { + console.log("Current:", currentSearch); + $api.resolution( + ...currentSearch + .split("=")[1] + .split("x") + .map((n) => parseInt(n)), + 0, + ); + } + firstPreviewOrIcon = false; + } + icon($api); + painting.paint(true); + painted = true; + paintCount += 1n; + } catch (err) { + console.warn("🪷 Icon failure...", err); + iconMode = false; + previewOrIconMode = previewMode || iconMode; + } + } + + // Attempt a paint. + if ( + previewMode === false && + iconMode === false && + (noPaint === false || scream || fairies.length > 0 || (system === "nopaint" && nopaintPerf)) && + booted + ) { + let paintOut; + + // Restore kidlisp's accumulated pan state from previous frame + $api.loadpan(); + + try { + // 📓 Bake any painting from the nopaint system before anything else. + if (system === "nopaint" && $api.system?.nopaint) { + const np = $api.system.nopaint; + // No Paint: baking + + if ( + (brush || filter) && + $api.pen?.drawing /*&& currentHUDButton.down === false*/ + ) { + const brushFilterApi = { ...$api }; + // Add top-level 'color' word that maps to system.nopaint.color + brushFilterApi.color = np.color; + if (currentHUDButton.down === false) { + brushFilterApi.pen = np.brush; + if (brush) { + // $api.page($api.system.nopaint.buffer); + $api.page($api.system.painting); + // 🔥 + // TODO: Use the pen data here to get an interpolation, + // then pan to each interpolated point and repaint. + // console.log( + // "🖌️ Brush:", + // brushFilterApi.pen, + // "🖊️ Pen:", + // $api.pen, + // ); + brush(brushFilterApi); + } + if (filter) { + $api.page($api.system.painting); + filter(brushFilterApi); + } + $api.page(screen); + } + } + + // 🎯 BAKING MOVED TO ACT PHASE: Removed duplicate baking logic + // Baking now happens immediately in act() to fix race conditions + if (np.is("painting") || np.needsPresent) { + np.present($api); // No Paint: prepaint + } + } // All: Paint + + // 🎞️ Piece-level FPS timing control + const now = performance.now(); + let shouldPaint = true; + + if (pieceFPS !== null && pieceFPS > 0) { + const targetFrameTime = 1000 / pieceFPS; // milliseconds per frame + + // If this is the first paint (lastPaintTime === 0), always allow it + if (lastPaintTime === 0) { + lastPaintTime = now; + shouldPaint = true; + } else { + const timeSinceLastPaint = now - lastPaintTime; + + if (timeSinceLastPaint < targetFrameTime) { + shouldPaint = false; // Skip this frame + } else { + lastPaintTime = now; // Update last paint time + shouldPaint = true; + } + } + } + + // Only call paint() if timing allows or no FPS limit is set + if (shouldPaint) { + // Reset zebra cache at the start of each frame so it can advance once per frame + $api.num.resetZebraCache(); + + // Always call paint() - piece paints underneath, GOL overlays on top + paintOut = paint($api); // Returns `undefined`, `false`, or `DirtyBox`. + graph.gpuLogTick(); // 📊 GPU effect logging every 8 frames + // Increment piece frame counter only when we actually paint + pieceFrameCount++; + + // 🎬 Piece Transition: Also capture noise16 frame as fallback (for initial page load) + if (paint === defaults.paint && $api.screen?.pixels) { + // Capture the noise16 state in case no piece was running before (initial load) + golTransition.overlayPixels = new Uint8ClampedArray($api.screen.pixels); + golTransition.width = $api.screen.width; + golTransition.height = $api.screen.height; + } + + // 🎬 Piece Transition: When first piece paint happens, switch to reveal phase + // Skip for KidLisp pieces - they have their own rendering that conflicts + const isKidLispPiece = currentPath && ( + lisp.isKidlispSource(currentPath) || + currentPath === "(...)" || + currentPath.includes("$") || + currentPath.endsWith('.lisp') + ); + + // Skip transitions entirely if TRANSITION_TYPE is "none" + if (TRANSITION_TYPE !== "none" && pieceFrameCount === 1 && paint !== defaults.paint && golTransition.overlayPixels && $api.screen?.pixels && !isKidLispPiece) { + const screenW = $api.screen.width; + const screenH = $api.screen.height; + const overlayW = golTransition.width; + const overlayH = golTransition.height; + + // If dimensions differ, scale the overlay to match the new screen + if (screenW !== overlayW || screenH !== overlayH) { + console.log(`🎬 Transition: Scaling overlay from ${overlayW}x${overlayH} to ${screenW}x${screenH}`); + golTransition.overlayPixels = scaleOverlayPixels( + golTransition.overlayPixels, + overlayW, overlayH, + screenW, screenH + ); + golTransition.width = screenW; + golTransition.height = screenH; + // Re-initialize with new dimensions if needed + if (golTransition.active) { + initGOLCells(screenW, screenH); + } + } + + // If transition was already started during load, switch to reveal phase + // Otherwise start it now (fallback for cases where capture didn't happen) + if (golTransition.active) { + transitionPieceLoaded(); // Switch from loading to reveal phase + } else { + // Fallback: start transition now if it wasn't started during load + initGOLCells(golTransition.width, golTransition.height); + golTransition.generation = 0; + golTransition.active = true; + golTransition.phase = "revealing"; // Skip loading, go straight to reveal + console.log(`🎬 Transition: Started ${TRANSITION_TYPE} (direct reveal)`, golTransition.width, "x", golTransition.height); + } + } + + // Log first piece paint + if (pieceFrameCount === 1) { + diskTimings.firstPaint = Math.round(performance.now() - diskTimingStart); + // Silent: first piece paint + } + + // 🎨 Signal to hide boot canvas when piece's paint takes over from default noise16 + // Only signal once when piece has a custom paint function (not defaults.paint) + // Note: pieceFrameCount may already be >1 if defaults.paint ran while the module + // was loading (e.g., KidLisp pieces that take several seconds to initialize). + // Use acPieceReady itself as the "already fired" guard instead of pieceFrameCount. + if (paint !== defaults.paint && !(typeof window !== "undefined" && window.acPieceReady)) { + send({ type: "piece-paint-ready" }); + // 🔥 Global signal for Puppeteer/Oven to detect piece is ready for screenshot + if (typeof window !== "undefined") { + window.acPieceReady = true; + window.acPieceReadyTime = Date.now(); + const isPackMode = + (typeof window !== "undefined" && window.acPACK_MODE) || + (typeof globalThis !== "undefined" && globalThis.acPACK_MODE); + if (!isPackMode) { + console.log("🟢 acPieceReady = true (first paint complete)"); + } + } + } + + // TODO: Remove old embedded layer rendering - using simplified approach now + // globalThis.renderKidlispProgrammaticLayers(); + } else { + // When skipping paint, use undefined to ensure continuous rendering + // This maintains the render loop while skipping the actual paint call + paintOut = undefined; + } + + // Store paintOut for next frame (only when we actually painted) + if (shouldPaint) { + lastPaintOut = paintOut; + } + + // Save kidlisp's accumulated pan state for next frame + $api.savepan(); + // Reset pan for system UI rendering + $api.unpan(); + } catch (e) { + console.warn("🎨 Paint failure...", e); + } + + // `DirtyBox` and `undefined` always set `noPaint` to `true`. + noPaint = + paintOut === false || (paintOut !== undefined && paintOut !== true); + + // 🧬 GOL Transition: Force pixels to be sent while transition is active + if (golTransition.active) { + noPaint = false; // Override - GOL modified pixels, must send them + } + + if (system === "video") console.log('📝 DISK: paintOut =', paintOut, '→ noPaint =', noPaint); + + // Run everything that was queued to be painted, then devour paintLayers. + //await painting.paint(); + + // Upper layer. + const { page, layer, ink, needsPaint, pieceCount } = $api; + + // Restore screen if page(sub-buffer) calls during paint left it corrupted. + // Brushes like smear call page(system.painting) which mutates screen.pixels/width/height. + // page(screen) at the end of those brushes can't restore because screen is already corrupted. + if (_trueScreenPixels && screen.pixels !== _trueScreenPixels) { + screen.pixels = _trueScreenPixels; + screen.width = _trueScreenWidth; + screen.height = _trueScreenHeight; + } + + page($api.screen); // Make sure we're on the right bufer. + + layer(1000); // Always make sure this stuff draws on top. + + // const piece = $api.slug?.split("~")[0]; + // if ( + // !previewMode && + // !iconMode && + // !hideLabel && + // system !== "prompt" && + // piece !== "textfence" && + // piece !== "bleep" && + // piece !== undefined && + // piece.length > 0 && + // piece !== "painting" && + // pieceCount > 0 + // ) { + // currentPromptButton = + // currentPromptButton || + // new $api.ui.TextButton("Back", { + // left: 6, + // bottom: 6, + // screen: $api.screen, + // }); + // currentPromptButton.paint($api); + // } + + // 😱 Scream - Paint a scream if it exists. + // TODO: Should this overlay after the fact and not force a paint? 23.05.23.19.21 + // Yes probably, because of layering issues? + if (scream || screaming) { + ink("yellow").write(scream, { x: 6, y: 18 }, "red"); + //ink("red").write(scream, { x: 6 + 1, y: 18 + 1 }); + + /* + ink(255) + .wipe(255, 0, 0) + .write( + scream, + { center: "xy", size: 3, thickness: 1 }, + undefined, + $api.screen.width - 8, + needsPaint(), + ); + */ + if (!screaming) { + screaming = true; + clearTimeout(screamingTimer); + screamingTimer = setTimeout(() => { + screaming = false; + scream = null; + }, 1000); + } + } + + // 🧚 Ambient Pen Points - Paint if they exist. + fairies.forEach(({ x, y }) => { + ink().point(x * screen.width, y * screen.height); + }); + if (fairies.length > 0) { + needsPaint(); + // if (system === "nopaint") $api.system.nopaint.needsPresent = true; + } + fairies.length = 0; + + // 🔴 Show a cross-piece "Recording" indicator. + // Currently only implemented for `painting:record`. 23.08.20.21.36 + if ( + $api.system?.nopaint?.recording && + !hideLabel && + pieceHistoryIndex > -1 && + !loading + ) { + // ink("red").box(screen.width - 3, 1, 2); + } + + // Show a notice if necessary. + if (notice) { + ink(noticeColor[0]) + //.pan(help.choose(-1, 0, 1), help.choose(-1, 0, 1)) + .write( + notice, + { center: "xy", size: 2 }, + // { center: "x", y: 32, size: 2 }, + noticeColor[1], + $api.screen.width - 8, + noticeOpts?.wrap === "char" ? false : true, + ); + //.unpan(); + } + + layer(0); + + painting.paint(true); + + // 🎬 Piece Transition: Step and apply overlay AFTER painting.paint() executes + // This ensures the overlay is composited on TOP of the piece's rendered frame + if (golTransition.active && screen?.pixels) { + // Check dimensions still match + if (screen.width !== golTransition.width || screen.height !== golTransition.height) { + console.warn("🎬 Transition: Screen dimensions changed! Aborting."); + golTransition.active = false; + golTransition.overlayPixels = null; + golTransition.blockData = null; + golTransition.dripOffsets = null; + golTransition.dripSpeeds = null; + golTransition.dripDirections = null; + } else { + // Step the transition + const stillActive = golStep(screen.pixels); + + // Apply overlay on top of piece's paint + applyGOLOverlay(screen.pixels); + + // End transition when complete + if (!stillActive) { + console.log(`🎬 Transition: ${TRANSITION_TYPE} complete!`); + golTransition.active = false; + golTransition.overlayPixels = null; + golTransition.blockData = null; + golTransition.dripOffsets = null; + golTransition.dripSpeeds = null; + golTransition.dripDirections = null; + } + } + } + + painted = true; + if (system === "video") console.log('🟢 DISK: painted set to TRUE, paintCount:', paintCount); + paintCount = paintCount + 1n; + + // Check for frame-based recording completion (runs on every painted frame) + if ($api.rec.tapeFrameMode && $api.rec.tapeFrameTarget > 0) { + const currentFrame = Number(paintCount); + const framesPassed = currentFrame - $api.rec.tapeFrameStart; + + $api.rec.tapeProgress = framesPassed / $api.rec.tapeFrameTarget; + + if (framesPassed >= $api.rec.tapeFrameTarget) { + console.log(`🎬 Frame-based recording complete: ${framesPassed}/${$api.rec.tapeFrameTarget} frames`); + + // Clear failsafe since normal timer completed + if ($api.rec.failsafeTimeout) { + clearTimeout($api.rec.failsafeTimeout); + $api.rec.failsafeTimeout = null; + } + + // Reset frame-based recording state + $api.rec.tapeProgress = 0; + $api.rec.tapeFrameMode = false; + $api.rec.tapeFrameStart = 0; + $api.rec.tapeFrameTarget = 0; + + // Trigger cut and jump to video + if (typeof $api.rec.cut === 'function') { + $api.rec.cut(() => { + $commonApi.jump("video"); + }); + } else { + log.tape.warn("Cut function not available"); + $commonApi.jump("video"); + } + } + } + + if (paintOut) dirtyBox = paintOut; + + delete screen.resized; // Remove status from screen after painting. + delete screen.created; + + //console.log("bake") + //send({ type: "3d-bake" }); + } else { + if (system === "video") { + console.log('❌ DISK: PAINT BLOCK SKIPPED - previewMode:', previewMode, 'iconMode:', iconMode, 'noPaint:', noPaint, 'booted:', booted); + } + } + + // 🏷️ corner-label: Draw any Global UI / HUD in an overlay buffer that will get + // composited by the other thread. + + // TODO: ❤️‍🔥 Why is this being composited by a different thread? + // Also... where do I put a scream? + + // System info label (addressability). + let label; + const piece = currentHUDTxt?.split("~")[0]; + const defo = 6; // Default offset + + + + if ( + !previewMode && + !iconMode && + !hideLabel && + piece !== undefined && + piece.length > 0 + ) { + + // Use plain text for width calculation to avoid counting color codes + const textForWidthCalculation = currentHUDPlainTxt || currentHUDTxt; + + // Double-check: strip color codes directly if they're still present + // Handle multiple color code formats: \colorname\, \255,255,255\, etc. + const colorCodeRegex = /\\[^\\]*\\/g; // Matches \anything\ format + let cleanText = textForWidthCalculation.replace(colorCodeRegex, ''); + + // Also try removing RGB color codes like \255,255,255\ if any remain + cleanText = cleanText.replace(/\\\d+,\d+,\d+(,\d+)?\\/g, ''); + + // Remove any remaining single backslashes that might be color code remnants + cleanText = cleanText.replace(/\\/g, ''); + + // Analyze visible lines for accurate sizing + const rawLines = cleanText.split('\n'); + const visibleLines = rawLines.map((line) => line.replace(/\s+$/, '')); + const visibleLineCount = visibleLines.length; + + const defaultTypeface = tf; + const matrixTypeface = typefaceCache.get("MatrixChunky8"); + + const isKidlispPiece = detectKidLispPiece({ + currentPath, + currentHUDTxt, + currentText, + cleanText, + }); + + // 🎨 KidLisp HUD color: green when running, yellow when paused + if (isKidlispPiece && !currentHUDTextColor) { + if (loopPaused) { + currentHUDTextColor = [255, 220, 80]; // Yellow when paused (matches pause button #ffd93d) + } else { + currentHUDTextColor = [128, 255, 128]; // Green when running + } + } + + // 🔤 Font Preloading: Ensure MatrixChunky8 starts loading early if available + if (matrixTypeface && !matrixTypeface.__loadPromise) { + ensureTypefaceLoaded(matrixTypeface); + } + + const measureLineWidth = (line, typeface) => { + if (!line || !line.length || !typeface) return 0; + const advances = typeface.data?.advances; + const defaultAdvance = typeface.blockWidth || DEFAULT_TYPEFACE_BLOCK_WIDTH; + let width = 0; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + width += advances && advances[ch] !== undefined ? advances[ch] : defaultAdvance; + } + return width; + }; + + const buildLayout = (typeface) => { + if (!typeface) return null; + + const fontIdentifier = typeface === defaultTypeface ? undefined : typeface.name; + const blockWidth = typeface.blockWidth || DEFAULT_TYPEFACE_BLOCK_WIDTH; + const blockHeight = typeface.blockHeight || DEFAULT_TYPEFACE_BLOCK_HEIGHT; + const lineWidths = visibleLines.map((line) => measureLineWidth(line, typeface)); + const longestLineWidth = lineWidths.reduce((max, width) => Math.max(max, width), 0); + // 🎨 Use screen width minus padding for KidLisp wrapping + // Account for left margin (HUD_LABEL_TEXT_MARGIN) in measurement bounds + const bounds = isKidlispPiece ? ($api.screen.width - 6 - HUD_LABEL_TEXT_MARGIN) : Math.max($api.screen.width * 4, 1000); + + const naturalBounds = $api.text.box( + cleanText, + { x: 0, y: 0 }, + bounds, + 1, + true, // Enable wrapping for proper measurement + fontIdentifier, + ); + + const wrappedLines = Array.isArray(naturalBounds?.lines) + ? naturalBounds.lines.map((line) => { + if (typeof line === "string") return line; + return line?.join?.(" ") || ""; + }) + : visibleLines; + const wrappedLineWidths = wrappedLines.map((line) => + measureLineWidth((line || "").replace(/\s+$/, ""), typeface), + ); + const wrappedLineStep = blockHeight + 1; + const wrappedLastLineIndex = Math.max(0, wrappedLines.length - 1); + const wrappedLastLineWidth = wrappedLineWidths[wrappedLastLineIndex] || 0; + const wrappedLastLineY = wrappedLastLineIndex * wrappedLineStep; + + if (fontIdentifier === "MatrixChunky8") { + for (const line of visibleLines) { + for (let i = 0; i < line.length; i++) { + typeface.glyphs?.[line[i]]; + } + } + } + + return { + typeface, + fontIdentifier, + blockWidth, + blockHeight, + lineWidths, + longestLineWidth, + textBoxWidth: naturalBounds?.box?.width ?? 0, + textBoxHeight: naturalBounds?.box?.height ?? 0, + naturalBounds, + wrappedLines, + wrappedLineWidths, + wrappedLastLineWidth, + wrappedLastLineY, + }; + }; + + const defaultLayout = buildLayout(defaultTypeface); + let selectedLayout = defaultLayout; + + // 🔤 Force MatrixChunky8 if hud.tinyLabel() was called + if (forceTinyHudLabel && matrixTypeface) { + const matrixLayout = buildLayout(matrixTypeface); + if (matrixLayout) { + selectedLayout = matrixLayout; + } + } + + // Disabled: MatrixChunky8 breakpoint removed per user request + // if ( + // isKidlispPiece && + // matrixTypeface && + // defaultLayout && + // defaultLayout.longestLineWidth > $api.screen.width + // ) { + // const matrixLayout = buildLayout(matrixTypeface); + // if (matrixLayout) { + // selectedLayout = matrixLayout; + // } + // } + + if (!selectedLayout && matrixTypeface) { + selectedLayout = buildLayout(matrixTypeface); + } + + if (!selectedLayout) { + selectedLayout = defaultLayout; + } + + const selectedTypeface = selectedLayout?.typeface || defaultTypeface; + const selectedHudFont = selectedLayout?.fontIdentifier; + const hudBlockWidth = selectedLayout?.blockWidth ?? DEFAULT_TYPEFACE_BLOCK_WIDTH; + const hudBlockHeight = selectedLayout?.blockHeight ?? DEFAULT_TYPEFACE_BLOCK_HEIGHT; + const textBounds = selectedLayout?.naturalBounds; + const textBoxWidth = selectedLayout?.textBoxWidth ?? 0; + const textBoxHeight = selectedLayout?.textBoxHeight ?? 0; + const longestVisibleLineWidth = selectedLayout?.longestLineWidth ?? 0; + const wrappedLineCount = textBounds?.lines?.length ?? visibleLineCount; + const measuredTextHeight = textBoxHeight || (wrappedLineCount * hudBlockHeight); + currentHUDTextBoxWidth = textBounds?.box?.width || 0; + currentHUDTextBoxHeight = textBounds?.box?.height || 0; + currentHUDTextHeight = measuredTextHeight; + const wrappedLastLineWidth = selectedLayout?.wrappedLastLineWidth ?? 0; + const wrappedLastLineY = selectedLayout?.wrappedLastLineY ?? 0; + + // 🔤 Font Loading Check: If MatrixChunky8 was selected but not loaded, skip rendering HUD + // This prevents pop-in where font_1 renders briefly before MatrixChunky8 glyphs load + let skipHudRender = false; + if (selectedHudFont === "MatrixChunky8" && matrixTypeface) { + // Check if actual glyph data exists (glyphs are loaded on-demand via Proxy) + // Test a common character like '$' to verify glyphs are accessible + // A real glyph will have properties like dwidth, advance, or resolution + // The Proxy returns null for missing glyphs (typeof null === 'object') + const dollarGlyph = matrixTypeface.glyphs && matrixTypeface.glyphs['$']; + const hasGlyphs = dollarGlyph && dollarGlyph !== null && + (dollarGlyph.dwidth || dollarGlyph.advance !== undefined || dollarGlyph.resolution); + + if (!hasGlyphs) { + // Trigger loading if not started + if (!matrixTypeface.__loadPromise) { + ensureTypefaceLoaded(matrixTypeface); + } + + // Wait for font to load + if (matrixTypeface.__loadPromise) { + matrixTypeface.__loadPromise.then(() => { + matrixTypeface.__loaded = true; + // Trigger repaint once loaded + if (typeof window !== "undefined" && window.$activePaintApi?.needsPaint) { + window.$activePaintApi.needsPaint(); + } + }).catch((err) => { + console.warn("Failed to load MatrixChunky8:", err); + matrixTypeface.__loaded = true; // Mark as loaded to prevent infinite waiting + }); + } + // Skip rendering this frame - wait for font to load + skipHudRender = true; + } + } + + // Only proceed with HUD rendering if font is ready + if (!skipHudRender) { + + const measuredShareWidth = measureLineWidth("share ", selectedTypeface); + const fallbackShareWidth = hudBlockWidth * "share ".length; + const effectiveShareWidth = measuredShareWidth > 0 + ? Math.max(measuredShareWidth, fallbackShareWidth) + : fallbackShareWidth; + const measuredEditWidth = measureLineWidth("edit ", selectedTypeface); + const fallbackEditWidth = hudBlockWidth * "edit".length; + const dynamicEditWidth = getHudEditScrubThreshold(); + const effectiveEditWidth = Math.max( + measuredEditWidth > 0 ? Math.max(measuredEditWidth, fallbackEditWidth) : fallbackEditWidth, + dynamicEditWidth, + ); + currentHUDShareWidth = effectiveShareWidth; + currentHUDEditWidth = effectiveEditWidth; + const shareWidth = effectiveShareWidth; + currentHUDLeftPad = shareWidth; + const scrubExtension = Math.max(0, currentHUDScrub); + const editExtension = currentHUDScrub < 0 ? effectiveEditWidth : 0; + + currentHUDLabelFontName = selectedHudFont; + currentHUDLabelBlockWidth = hudBlockWidth; + currentHUDLabelBlockHeight = hudBlockHeight; + + // Prefer visible line width for KidLisp pieces to avoid oversized buffers + // When wrapping is enabled, use textBoxWidth (wrapped width) instead + let w = isKidlispPiece ? textBoxWidth : textBoxWidth; + if (!w || w <= 0) { + w = textBoxWidth; + } + + // Add extra padding to buffer width to prevent font_1 character cutoff + const bufferWidthPadding = 6; // Extra pixels for wider glyphs + w += bufferWidthPadding; + + // 📡 LAN badge superscript - add width for device letter if connected to dev session server + // Hide in kidlisp.com embedded mode (NOAUTH_MODE) + const showLanBadge = devIdentity && devIdentity.host && !globalThis.NOAUTH_MODE; + const lanBadgePadding = showLanBadge ? 10 : 0; // Space for letter + margin + w += lanBadgePadding; + + // ✨ Custom superscript (e.g. ".com") - add width for text + const showCustomSuperscript = !!currentHUDSuperscript; + const customSuperscriptPadding = showCustomSuperscript ? (currentHUDSuperscript.length * 5 + 3) : 0; + w += customSuperscriptPadding; + + // 📱 HUD QR code - generate cells and calculate width + let hudQRSize = 0; + let hudQRPadding = 0; + // Debug: Log QR state on first few frames (disabled for cleaner output) + // if (pieceFrameCount < 3) { + // console.log("📱 HUD QR render check - frame:", pieceFrameCount, "currentHUDQR:", JSON.stringify(currentHUDQR), "currentHUDQRCells:", !!currentHUDQRCells); + // } + if (currentHUDQR) { + // Generate QR cells if not cached + if (!currentHUDQRCells) { + try { + currentHUDQRCells = qr(currentHUDQR, { errorCorrectLevel: ErrorCorrectLevel.L }).modules; + console.log("📱 QR cells generated:", currentHUDQRCells?.length, "for URL:", currentHUDQR); + } catch (e) { + console.error("HUD QR generation failed:", e); + currentHUDQRCells = null; + } + } + if (currentHUDQRCells) { + hudQRSize = currentHUDQRCells.length; // 1px per cell + hudQRPadding = 4; // Gap between QR and text + } + } + const hudQRTotalWidth = hudQRSize > 0 ? hudQRSize + hudQRPadding + 2 : 0; // +2 for border + + // Final text dimensions: KidLisp width uses visible line metrics, height from text box + let h = measuredTextHeight; + + if (piece === "video") w = screen.width; + + const baseLabelWidth = w + shareWidth; + + // Use natural text dimensions for buffer - preserve original layout + // Add QR code width to buffer if present + let bufferW = baseLabelWidth + scrubExtension + editExtension + hudQRTotalWidth; + let bufferH = h; + + // Ensure buffer height fits QR code if present + if (hudQRSize > 0 && bufferH < hudQRSize + 4) { + bufferH = hudQRSize + 4; // QR size + padding + } + + // Ensure minimum buffer size for readability + const minTextWidth = Math.max(isKidlispPiece ? textBoxWidth : textBoxWidth, 50); + const minimumAllowedWidth = shareWidth + Math.max(minTextWidth, 0); + const minBufferH = Math.max(hudBlockHeight, 20); // At least one line height + + if (bufferW < minimumAllowedWidth + scrubExtension + editExtension) { + bufferW = minimumAllowedWidth + scrubExtension + editExtension; + } + if (bufferH < minBufferH) { + bufferH = minBufferH; + } + + // Fix height calculation for KidLisp pieces when wordWrap=false + if (isKidlispPiece) { + // When wordWrap=false, text.box doesn't calculate height correctly for multi-line text + // Calculate proper height based on actual line count + const properHeight = measuredTextHeight; + if (properHeight > bufferH) { + bufferH = properHeight; + } + } + + const hudDescenderPadding = Math.max(2, Math.round(hudBlockHeight * 0.2)); + bufferH += hudDescenderPadding; + + // DEBUG: Log buffer dimensions in copy-pasteable format (reduced frequency) + if (false && isKidlispPiece && currentHUDTxt && pieceFrameCount % 120 === 0) { + console.log(`HUD_BUFFER: bufferW=${bufferW} bufferH=${bufferH} textBoxW=${textBounds.box.width} textBoxH=${textBounds.box.height} longestLinePx=${longestVisibleLineWidth} lines=${visibleLineCount} fontHeight=${hudBlockHeight} expectedH=${visibleLineCount * hudBlockHeight}`); + console.log(`HUD_TEXT: "${cleanText.replace(/\n/g, '\\n').substring(0, 150)}"`); + } + + + + // Store actual dimensions for animation calculations + currentHUDLabelMeasuredWidth = baseLabelWidth; + hudAnimationState.labelWidth = bufferW; + hudAnimationState.labelHeight = bufferH; + h = bufferH; + + label = $api.painting(bufferW, bufferH, ($) => { + // Ensure label renders with clean pan state + $.unpan(); + // Ensure label renders without any active mask from the piece + $.unmask(); + + // Clean rendering - no debug elements + + + + let c; + if (currentHUDTextColor) { + c = num.shiftRGB(currentHUDTextColor, [255, 255, 255], 0.75); + } else if (currentHUDStatusColor) { + c = currentHUDStatusColor; + } else { + c = [255, 200, 240]; + } + if (piece !== "video") { + let text = currentHUDTxt; + if (currentHUDTxt.split(" ")[1]?.indexOf("http") !== 0) { + text = currentHUDTxt?.replaceAll("~", " "); + // Handle command separators: replace § with spaces for display + // but keep actual newlines as newlines + text = text?.replaceAll("§", " "); + } + + // 📱 Render QR code to the LEFT of the text if present + let qrOffset = 0; + if (currentHUDQR && currentHUDQRCells) { + const qrCells = currentHUDQRCells; + const qrSize = qrCells.length; + // Position QR at the start of visible area (after share button padding) + // Include scrub offset so QR moves with the label when swiping to "share" + // Move left by 4px for tighter fit (up is handled by label Y offset) + const qrX = currentHUDLeftPad + currentHUDScrub - 4; + const qrY = 0; // Top of buffer + + // White background (1px border around QR) + $.ink(255, 255, 255).box(qrX, qrY, qrSize + 2, qrSize + 2); + + // Draw QR code cells + for (let y = 0; y < qrSize; y++) { + for (let x = 0; x < qrSize; x++) { + if (qrCells[y][x]) { + $.ink(0, 0, 0).box(qrX + 1 + x, qrY + 1 + y, 1, 1); + } + } + } + + qrOffset = qrSize + 4; // QR width + border + gap + } + + const baseX = currentHUDLeftPad + qrOffset; + // Keep text fixed while scrubbing left; only right-scrub shifts label content. + const hudTextX = baseX + HUD_LABEL_TEXT_MARGIN + Math.max(0, currentHUDScrub); + const hudTextY = 0; + const typefaceNameForWrite = selectedHudFont; + const hasColorCodes = textContainsColorCodes(text); + const baseTextColor = currentHUDTextColor || "white"; + + // 🎨 Character wrapping for KidLisp HUD prompts + // Wrap based on screen width minus padding (6px total: 2px margin + 4px padding) + // Subtract the left margin since text starts at x=HUD_LABEL_TEXT_MARGIN + const wrapBounds = isKidlispPiece ? ($api.screen.width - 6 - HUD_LABEL_TEXT_MARGIN) : undefined; + + drawHudLabelText($, text, { + x: hudTextX, + y: hudTextY, + typefaceName: typefaceNameForWrite, + textColor: baseTextColor, + shadowColor: "black", + shadowOffsetX: 1, + shadowOffsetY: 1, + preserveColors: hasColorCodes, + bounds: wrapBounds, + // wordWrap defaults to true when bounds is set, enabling character wrapping + }); + + // ✨ Custom Superscript (e.g. ".com") - draw after the text in cyan + if (showCustomSuperscript) { + const firstLine = text?.split('\n')[0] || text; + const firstLineWidth = cachedAPI.text.width( + stripColorCodes(firstLine), + selectedTypeface + ); + + const superscriptX = hudTextX + firstLineWidth + 2; + const superscriptY = 2; // Moved down 2px + + // Special handling for ".com" - color the "." based on connection status + if (currentHUDSuperscript === ".com") { + // Color dot based on socket connection: green if connected, red if not + if (socket?.connected) { + $.ink(0, 255, 100); // Green for connected + } else { + $.ink(255, 100, 100); // Red/salmon for disconnected + } + $.write(".", { x: superscriptX, y: superscriptY }, undefined, undefined, false, "MatrixChunky8"); + + // Render "com" in bright cyan + const dotWidth = cachedAPI.text.width(".", "MatrixChunky8"); + $.ink(0, 220, 255); // Bright cyan + $.write("com", { x: superscriptX + dotWidth, y: superscriptY }, undefined, undefined, false, "MatrixChunky8"); + } else { + // Default rendering for other superscripts + $.ink(0, 220, 255); // Bright cyan + $.write(currentHUDSuperscript, { x: superscriptX, y: superscriptY }, undefined, undefined, false, "MatrixChunky8"); + } + } + + // 📡 LAN Badge Superscript - draw device letter after the text (+ custom superscript offset) + if (showLanBadge) { + const deviceLetter = devIdentity.letter || + (devIdentity.name ? devIdentity.name.charAt(0).toUpperCase() : null) || + String.fromCharCode(65 + (devIdentity.connectionIndex || 0)); // A, B, C... + + // Get the first line of text for positioning the superscript + const firstLine = text?.split('\n')[0] || text; + const firstLineWidth = cachedAPI.text.width( + stripColorCodes(firstLine), + selectedTypeface + ); + + // Position superscript to the right of first line (after custom superscript if present) + const superscriptX = hudTextX + firstLineWidth + 2 + customSuperscriptPadding; + const superscriptY = 0; // Top of the line (superscript position) + + // Draw letter in cyan using MatrixChunky8 + $.ink(0, 220, 255); // Bright cyan matching original badge + $.write(deviceLetter, { x: superscriptX, y: superscriptY }, undefined, undefined, false, "MatrixChunky8"); + } + + // 🔗 Draw "share" text in the LEFT reveal area when scrubbing + if (currentHUDScrub > 0) { + // Slide "share" in from the left as the scrub progresses. + const shareTextX = + currentHUDLeftPad + currentHUDScrub - shareWidth + HUD_LABEL_TEXT_MARGIN; + const shareTextY = 0; + + drawHudLabelText($, "share", { + x: shareTextX, + y: shareTextY, + typefaceName: undefined, // Use default font, not MatrixChunky8 + textColor: [0, 255, 255], + shadowColor: "black", + preserveColors: false, + }); + } + + // ⬅️ Draw backspace/edit reveal on right when scrubbing left + if (currentHUDScrub < 0) { + const scrubMagnitude = Math.abs(currentHUDScrub); + const editThreshold = Math.max(currentHUDEditWidth || 0, getHudEditScrubThreshold()); + const editProgress = Math.min(1, scrubMagnitude / editThreshold); + const lastLineWidth = Math.max( + 1, + wrappedLastLineWidth || + currentHUDTextBoxWidth || + (stripColorCodes(text || "").length * hudBlockWidth), + ); + const caretWidth = Math.max(2, Math.round(hudBlockWidth * 0.9)); + const caretHeight = Math.max(hudBlockHeight, 6); + const caretX = Math.min( + Math.max(hudTextX + lastLineWidth + 2, 0), + Math.max(bufferW - caretWidth - 2, 0), + ); + const unclampedCaretY = Math.max(0, hudTextY + wrappedLastLineY); + const caretY = Math.min(unclampedCaretY, Math.max(bufferH - caretHeight - 1, 0)); + const filledCols = Math.max(1, Math.round(caretWidth * editProgress)); + const editColor = editProgress >= 1 ? [255, 170, 210] : [255, 107, 157]; + + // Draw per-column with shadows and a fly-in offset from the right. + for (let col = 0; col < filledCols; col++) { + const t = filledCols > 1 ? col / (filledCols - 1) : 1; + const flyIn = Math.round((1 - t) * (caretWidth - filledCols)); + const cx = caretX + col + flyIn; + $.ink(0, 0, 0, 180).box(cx + 1, caretY + 1, 1, caretHeight); + $.ink(...editColor).box(cx, caretY, 1, caretHeight); + } + } + } else { + $.ink(0).line(1, 1, 1, h - 1); + $.ink(c).line(0, 0, 0, h - 2); + } + }); + + if (piece === "video") currentHUDOffset = { x: 0, y: 6 }; + if (!currentHUDOffset) currentHUDOffset = { x: defo, y: defo }; + + // Apply animation effects to HUD label position + if (hudAnimationState.animating) { + const currentTime = performance.now(); + const elapsed = currentTime - hudAnimationState.startTime; + const progress = Math.min(elapsed / hudAnimationState.duration, 1.0); + + // Easing function for smooth macOS-style animation (ease-out) + const easeOut = 1 - Math.pow(1 - progress, 3); + + // Use actual calculated dimensions from when HUD was last drawn + const hudWidth = hudAnimationState.labelWidth || 120; // fallback to default + const hudHeight = hudAnimationState.labelHeight || 40; // fallback to default + const qrSize = hudAnimationState.qrSize || 80; + + // Calculate slide distances: ensure full content slides off-screen + // Add extra padding (20px) to guarantee complete disappearance + // For very large labels, cap the slide distance to keep animation smooth + const maxSlideDistance = 400; // Maximum distance to slide for smooth animation + const hudSlideX = Math.max(-(hudWidth + 20), -maxSlideDistance); // Cap slide distance + const hudSlideY = Math.max(-(hudHeight + 20), -maxSlideDistance); // Cap slide distance + const qrSlideX = qrSize + 20; // Slide right by full size + padding + const qrSlideY = qrSize + 20; // Slide down by full size + padding + + if (hudAnimationState.visible) { + // Animating IN: fade from 0 to 1, slide from respective corners to position + hudAnimationState.opacity = easeOut; + // HUD slides in from top-left corner + hudAnimationState.slideOffset = { + x: (1 - easeOut) * hudSlideX, // Slide in from left using actual width + y: (1 - easeOut) * hudSlideY // Slide in from top using actual height + }; + // QR slides in from bottom-right corner + hudAnimationState.qrSlideOffset = { + x: (1 - easeOut) * qrSlideX, // Slide in from right using actual size + y: (1 - easeOut) * qrSlideY // Slide in from bottom using actual size + }; + } else { + // Animating OUT: fade from 1 to 0, slide to respective corners + hudAnimationState.opacity = 1 - easeOut; + // HUD slides out to top-left corner + hudAnimationState.slideOffset = { + x: easeOut * hudSlideX, // Slide out to left using actual width + y: easeOut * hudSlideY // Slide out to top using actual height + }; + // QR slides out to bottom-right corner + hudAnimationState.qrSlideOffset = { + x: easeOut * qrSlideX, // Slide out to right using actual size + y: easeOut * qrSlideY // Slide out to bottom using actual size + }; + } + + // End animation when progress reaches 1.0 + if (progress >= 1.0) { + hudAnimationState.animating = false; + + // Ensure final values are exact + if (hudAnimationState.visible) { + hudAnimationState.opacity = 1.0; + hudAnimationState.slideOffset = { x: 0, y: 0 }; + hudAnimationState.qrSlideOffset = { x: 0, y: 0 }; + } else { + hudAnimationState.opacity = 0.0; + hudAnimationState.slideOffset = { x: hudSlideX, y: hudSlideY }; + hudAnimationState.qrSlideOffset = { x: qrSlideX, y: qrSlideY }; + } + } + } + + currentHUDButton = + currentHUDButton || + new $api.ui.Button({ + x: 0, + y: 0, + w: currentHUDLabelMeasuredWidth, // FIXED: Don't add currentHUDOffset.x to button width + h: h, // Use just the calculated height without extra y-offset + }); + + // Update button position to match label position (with slide offset) + const finalX = currentHUDOffset.x + hudAnimationState.slideOffset.x - currentHUDLeftPad; + const finalY = currentHUDOffset.y + hudAnimationState.slideOffset.y; + // Match hitbox to full rendered HUD label buffer (including scrub reveal areas). + const hitboxWidth = Math.max( + 1, + Math.round(hudAnimationState.labelWidth || bufferW || currentHUDLabelMeasuredWidth || 1), + ); + const hitboxHeight = Math.max( + 1, + Math.round(hudAnimationState.labelHeight || bufferH || currentHUDTextHeight || currentHUDTextBoxHeight || h || 1), + ); + currentHUDButton.box.x = finalX; + currentHUDButton.box.y = finalY; + currentHUDButton.box.w = hitboxWidth; + currentHUDButton.box.h = Math.max(1, Math.round(hitboxHeight)); + + // Mark HUD button to bypass the global HUD active check (it checks itself) + currentHUDButton.noEdgeDetection = true; + // Prevent HUD button from being activated by dragging from other buttons + currentHUDButton.noRolloverActivation = true; + + // Disable button when HUD is not visible (hidden with Tab key) + currentHUDButton.disabled = !hudAnimationState.visible && !hudAnimationState.animating; + + // $commonApi.hud.currentLabel = { + // text: currentHUDTxt, + // btn: currentHUDButton, + // }; + } + } // End of skipHudRender check - close the if (!skipHudRender) block + + // Return frame data back to the main thread. + let sendData = { width: screen.width, height: screen.height }; + + // Tack on the merry progress bar at the TOP if a pipeline is running + if ($api.system.merry && $api.system.merry.running) { + const merry = $api.system.merry; + + // Calculate progress based on elapsed time (use UTC-synced clock if available) + const now = $commonApi.clock?.time?.()?.getTime?.() || Date.now(); + if (merry.startTime && merry.currentPieceStart) { + const totalElapsed = (now - merry.startTime) / 1000; // in seconds + const pieceElapsed = (now - merry.currentPieceStart) / 1000; + merry.progress = Math.min(1, totalElapsed / merry.totalDuration); + merry.pieceProgress = Math.min(1, pieceElapsed / merry.pipeline[merry.currentIndex].duration); + } + + const mainScreenWidth = screen.width; + + // Create merry progress bar painting with SEGMENTED colored blocks per piece + const merryProgressBarPainting = $api.painting(mainScreenWidth, 1, ($) => { + $.unmask(); // Ensure progress bar renders without piece mask + const animFrame = Number($api.paintCount || 0n); + + // Check for transition flash (use UTC-synced time) + const flashNow = $commonApi.clock?.time?.()?.getTime?.() || Date.now(); + let flashIntensity = 0; + if (merry.transitionFlash && merry.transitionFlash.active) { + const elapsed = flashNow - merry.transitionFlash.startTime; + if (elapsed < merry.transitionFlash.duration) { + // Ease out the flash (starts bright, fades quickly) + const progress = elapsed / merry.transitionFlash.duration; + flashIntensity = 1 - Math.pow(progress, 2); // Quadratic ease out + } else { + // Flash complete + merry.transitionFlash.active = false; + flashIntensity = 0; + } + } + + // Fill with black backdrop (or white during flash) + if (flashIntensity > 0) { + const bgBrightness = Math.floor(255 * flashIntensity); + $.ink(bgBrightness, bgBrightness, bgBrightness, 255).box(0, 0, mainScreenWidth, 1); + } else { + $.ink(0, 0, 0, 255).box(0, 0, mainScreenWidth, 1); + } + + // Define colors for each piece (cycle through palette) + const pieceColors = [ + { r: 100, g: 255, b: 100 }, // Bright green + { r: 100, g: 200, b: 255 }, // Cyan + { r: 255, g: 200, b: 100 }, // Orange + { r: 255, g: 100, b: 200 }, // Pink + { r: 200, g: 100, b: 255 }, // Purple + { r: 255, g: 255, b: 100 }, // Yellow + ]; + + // Calculate pixel width for each piece based on duration + let xOffset = 0; + merry.pipeline.forEach((piece, index) => { + const pieceWidthRatio = piece.duration / merry.totalDuration; + const pieceWidth = Math.floor(mainScreenWidth * pieceWidthRatio); + + // Determine if this piece is completed, current, or upcoming + const isCompleted = index < merry.currentIndex; + const isCurrent = index === merry.currentIndex; + const isUpcoming = index > merry.currentIndex; + + // Get color for this piece + const color = pieceColors[index % pieceColors.length]; + + // Draw this piece's segment + for (let x = xOffset; x < xOffset + pieceWidth && x < mainScreenWidth; x++) { + let baseR, baseG, baseB, alpha; + + if (isCompleted) { + // Completed pieces: Fade out progressively (darker as we move away from them) + const completedAge = merry.currentIndex - index; // How many pieces ago + const fadeFactor = Math.max(0.1, 1 - (completedAge * 0.2)); // Fade by 20% per step + baseR = Math.floor(color.r * fadeFactor * 0.25); // Start at 25% brightness + baseG = Math.floor(color.g * fadeFactor * 0.25); + baseB = Math.floor(color.b * fadeFactor * 0.25); + alpha = 255; + } else if (isCurrent) { + // Current piece: Show progress within this segment + const pieceProgress = merry.pieceProgress || 0; + const localX = x - xOffset; + const progressPoint = Math.floor(pieceWidth * pieceProgress); + + if (localX <= progressPoint) { + // Filled part of current piece - bright with pulsing + const pulse = Math.sin(animFrame * 0.3) * 0.2 + 0.8; + baseR = Math.floor(color.r * pulse); + baseG = Math.floor(color.g * pulse); + baseB = Math.floor(color.b * pulse); + alpha = 255; + + // Leader pixel - extra bright white + if (localX === progressPoint) { + const leaderPulse = Math.sin(animFrame * 0.6) * 0.3 + 0.7; + baseR = Math.floor(255 * leaderPulse); + baseG = Math.floor(255 * leaderPulse); + baseB = Math.floor(255 * leaderPulse); + } + } else { + // Unfilled part of current piece - dim preview + baseR = Math.floor(color.r * 0.3); + baseG = Math.floor(color.g * 0.3); + baseB = Math.floor(color.b * 0.3); + alpha = 255; + } + } else if (isUpcoming) { + // Upcoming pieces: Warm up as turn approaches + const stepsUntil = index - merry.currentIndex; // How many pieces away + const nextIsThis = stepsUntil === 1; + + if (nextIsThis) { + // Next piece warms up based on current piece progress + const warmup = merry.pieceProgress || 0; // 0 to 1 + const baseBrightness = 0.15; // Starting dim + const targetBrightness = 0.5; // Warm up to this + const brightness = baseBrightness + (targetBrightness - baseBrightness) * warmup; + + // Add warm orange/yellow glow as it heats up + const warmGlow = warmup * 0.3; + baseR = Math.floor(color.r * brightness + 255 * warmGlow); + baseG = Math.floor(color.g * brightness + 200 * warmGlow); + baseB = Math.floor(color.b * brightness + 100 * warmGlow * 0.5); + baseR = Math.min(255, baseR); + baseG = Math.min(255, baseG); + baseB = Math.min(255, baseB); + alpha = 255; + } else { + // Further away pieces: Very dim, getting dimmer with distance + const distanceFade = Math.max(0.05, 1 - (stepsUntil - 1) * 0.15); + baseR = Math.floor(color.r * 0.15 * distanceFade); + baseG = Math.floor(color.g * 0.15 * distanceFade); + baseB = Math.floor(color.b * 0.15 * distanceFade); + alpha = 255; + } + } + + // Fade zone overlay: blend toward next piece color at end of each segment + if (merry.fadeDuration > 0 && merry.pipeline.length > 1) { + const fadeZonePixels = Math.max(2, Math.floor(pieceWidth * merry.fadeDuration / piece.duration)); + const pixelOffset = x - xOffset; + if (pixelOffset >= pieceWidth - fadeZonePixels) { + const t = (pixelOffset - (pieceWidth - fadeZonePixels)) / Math.max(1, fadeZonePixels - 1); + const nextIdx = (index + 1) % merry.pipeline.length; + const nc = pieceColors[nextIdx % pieceColors.length]; + const maxCh = Math.max(1, color.r, color.g, color.b); + const bright = Math.max(baseR, baseG, baseB) / maxCh; + const nR = Math.floor(nc.r * bright); + const nG = Math.floor(nc.g * bright); + const nB = Math.floor(nc.b * bright); + baseR = Math.min(255, Math.floor(baseR * (1 - t) + nR * t)); + baseG = Math.min(255, Math.floor(baseG * (1 - t) + nG * t)); + baseB = Math.min(255, Math.floor(baseB * (1 - t) + nB * t)); + } + } + + // Apply transition flash boost to current piece + if (isCurrent && flashIntensity > 0) { + const boost = 1 + flashIntensity * 0.8; // Up to 80% brighter + baseR = Math.min(255, Math.floor(baseR * boost)); + baseG = Math.min(255, Math.floor(baseG * boost)); + baseB = Math.min(255, Math.floor(baseB * boost)); + } + + $.ink(baseR, baseG, baseB, alpha).box(x, 0, 1, 1); + } + + xOffset += pieceWidth; + }); + }); + + if (merryProgressBarPainting && merryProgressBarPainting.pixels && merryProgressBarPainting.pixels.length > 0) { + sendData.merryProgressBar = { + x: 0, + y: 0, // Position at TOP of screen (1px tall) + img: { + width: merryProgressBarPainting.width, + height: merryProgressBarPainting.height, + pixels: merryProgressBarPainting.pixels + } + }; + + // 🎄⏰ Add UTC time overlay for sync debugging + const utcDate = $commonApi.clock?.time?.() || new Date(); + const utcTimeStr = utcDate.toISOString().split('T')[1].split('.')[0]; // HH:MM:SS + const utcMs = utcDate.getMilliseconds().toString().padStart(3, '0'); + const cyclePos = ((now % (merry.totalDuration * 1000)) / 1000).toFixed(2); + sendData.merryUTC = { + time: `${utcTimeStr}.${utcMs}`, + cycle: cyclePos, + piece: merry.pipeline[merry.currentIndex]?.piece || '?', + index: merry.currentIndex + 1, + total: merry.pipeline.length + }; + } + } + + // 🎬 Demoplay text card overlay + if ($commonApi.system.demoplay?.card) { + const card = $commonApi.system.demoplay.card; + const elapsed = Date.now() - card.startTime; + const totalDur = card.duration; + const fadeIn = card.fadeIn || 500; + const fadeOut = card.fadeOut || 500; + + // Calculate opacity with fade in/out + let opacity = 1; + if (elapsed < fadeIn) { + opacity = elapsed / fadeIn; + } else if (elapsed > totalDur - fadeOut) { + opacity = Math.max(0, (totalDur - elapsed) / fadeOut); + } + card.opacity = opacity; + + const sw = screen.width; + const sh = screen.height; + const cardPainting = $api.painting(sw, sh, ($) => { + $.unmask(); + // Semi-transparent background + $.ink(0, 0, 0, Math.floor(opacity * 180)); + $.box(0, 0, sw, sh); + // White text, centered + $.ink(255, 255, 255, Math.floor(opacity * 255)); + const lines = card.text.split("\n"); + const lineHeight = 10; + const startY = Math.floor((sh - lines.length * lineHeight) / 2); + lines.forEach((line, i) => { + $.write(line, { center: "x", y: startY + i * lineHeight }); + }); + }); + + if (cardPainting?.pixels?.length > 0) { + sendData.demoplayCard = { + x: 0, + y: 0, + img: { + width: cardPainting.width, + height: cardPainting.height, + pixels: cardPainting.pixels, + }, + }; + } + } else if (sendData.demoplayCard) { + delete sendData.demoplayCard; + } + + // Tack on the tape progress bar pixel buffer if necessary. + if (!$api.rec.cleanMode && ($api.rec.tapeProgress || ($api.rec.recording && $api.rec.tapeTimerDuration))) { + const progress = $api.rec.tapeProgress || 0; + + // Determine if we should show progress bar at all + const isFrameBased = $api.rec.tapeFrameMode && $api.rec.tapeFrameTarget > 0; + const frameCount = $api.rec.tapeFrameTarget || 1; + + // Hide progress bar for ALL frame-based recordings + if (isFrameBased) { + // Skip creating progress bar entirely for frame recordings + } else { + // IMPORTANT: Access the main screen buffer OUTSIDE the painting context + // because inside painting(), $api.screen refers to the painting's own buffer, not the main screen + const mainScreenPixels = screen.pixels; // This is the actual frame content + const mainScreenWidth = screen.width; + const mainScreenHeight = screen.height; + + const isShortRecording = isFrameBased ? frameCount <= 120 : ($api.rec.tapeTimerDuration || 0) <= 1; + + let currentProgressWidth; + if (isFrameBased) { + // For frame-based recording, create discrete segments + const framesPassed = Math.floor(progress * frameCount); + const segmentWidth = Math.max(1, Math.floor(mainScreenWidth / frameCount)); + currentProgressWidth = framesPassed * segmentWidth; + } else { + // For time-based recording, use smooth progress + currentProgressWidth = Math.floor(mainScreenWidth * progress); + } + + // Create tape progress bar painting with VHS-style red glow + const tapeProgressBarPainting = $api.painting(mainScreenWidth, 2, ($) => { + $.unmask(); // Ensure tape progress bar renders without piece mask + // Animation frame for VHS effects - increased speed for more vibes + const animFrame = Number($api.paintCount || 0n); + + // Helper function to sample pixel color from above the progress bar + const sampleColorFromAbove = (x) => { + // Skip sampling during early frames for performance (first ~5 frames) + const frameCount = Number($api.paintCount || 0n); + if (frameCount < 5 || !mainScreenPixels || mainScreenPixels.length === 0) { + return { r: 0, g: 0, b: 0 }; // Return black during startup + } + + // Sample from a few pixels above the progress bar position + const sampleY = Math.max(0, screen.height - 10); // Sample 10px above progress bar + const pixelIndex = (sampleY * screen.width + x) * 4; + + if (pixelIndex >= 0 && pixelIndex < mainScreenPixels.length - 3) { + return { + r: mainScreenPixels[pixelIndex], + g: mainScreenPixels[pixelIndex + 1], + b: mainScreenPixels[pixelIndex + 2] + }; + } + // Fallback to black if can't sample + return { r: 0, g: 0, b: 0 }; + }; + + // Special color override for first and last frames + const isFirstFrame = progress <= 0.01; // First 1% of progress + const isLastFrame = progress >= 0.99; // Last 1% of progress + + // Calculate smooth alpha fade - progress bar fades from 25% to 75% for longer clean content viewing + // Skip fade animation for short recordings + let progressBarAlpha = 1.0; // Default to fully visible + + if (!isShortRecording) { + if (progress >= 0.20 && progress <= 0.30) { + // Fade out from 20% to 30% (10% fade-out period) + progressBarAlpha = 1.0 - ((progress - 0.20) / 0.10); + } else if (progress > 0.30 && progress < 0.70) { + // Fully hidden from 30% to 70% (40% hidden period) + progressBarAlpha = 0.0; + } else if (progress >= 0.70 && progress <= 0.80) { + // Fade in from 70% to 80% (10% fade-in period) + progressBarAlpha = (progress - 0.70) / 0.10; + } + } + + // Fill entire bar with black backdrop using fade alpha + $.ink(0, 0, 0, Math.floor(progressBarAlpha * 255)).box(0, 0, mainScreenWidth, 2); + + if (isFrameBased) { + // Draw segmented progress for frame-based recording + const segmentWidth = Math.max(1, Math.floor(mainScreenWidth / frameCount)); + const framesPassed = Math.floor(progress * frameCount); + const currentProgressWidth = framesPassed * segmentWidth; + + // Draw VHS-style progress bar pixel by pixel (same as time-based but with discrete frame spacing and no leader pixel) + for (let x = 0; x < mainScreenWidth; x++) { + let baseR, baseG, baseB; + + if (x < currentProgressWidth) { + // FILLED AREA - VHS red with analog glow and scan lines + + if (isFirstFrame) { + // FIRST FRAME - Fully green across entire bar + baseR = 0; + baseG = 255; + baseB = 0; + } else if (isLastFrame) { + // LAST FRAME - Fully red across entire bar + baseR = 255; + baseG = 0; + baseB = 0; + } else { + // NORMAL FRAMES - Enhanced color sampling from buffer above + const sampledColor = sampleColorFromAbove(x); + + // Sample additional colors for more variety + const sampledColor2 = sampleColorFromAbove(Math.max(0, x - 3)); + const sampledColor3 = sampleColorFromAbove(Math.min(mainScreenWidth - 1, x + 3)); + + // Mix multiple sampled colors for richer palette + const mixedR = Math.floor((sampledColor.r + sampledColor2.r + sampledColor3.r) / 3); + const mixedG = Math.floor((sampledColor.g + sampledColor2.g + sampledColor3.g) / 3); + const mixedB = Math.floor((sampledColor.b + sampledColor2.b + sampledColor3.b) / 3); + const mixedColor = { r: mixedR, g: mixedG, b: mixedB }; + + // Base VHS red intensity - reduced to let more color through + let redIntensity = 200; // Reduced from 255 for more color mixing + + // Use shared VHS effects calculation + const effects = calculateVHSEffects(x, animFrame); + + // Combine all VHS effects with brighter base + redIntensity = Math.floor(redIntensity * effects.scanLine * effects.analogGlow * effects.tracking * effects.secondaryGlow); + + // Create VHS red color + const vhsR = Math.max(180, Math.min(255, redIntensity)); // Reduced min from 200 + const vhsG = Math.floor(vhsR * 0.05); // Very minimal green + const vhsB = Math.floor(vhsR * 0.02); // Very minimal blue + + // Use shared blending function (55% sampled, 45% VHS red for more color influence) + const blended = blendColorWithVHS(mixedColor, vhsR, vhsG, vhsB, 0.55); + baseR = blended.r; + baseG = blended.g; + baseB = blended.b; + } + + // Use proper alpha blending for filled area (no special leader pixel treatment for frame-based) + $.ink(baseR, baseG, baseB, Math.floor(progressBarAlpha * 255)).box(x, 0, 1, 2); + } else { + // UNFILLED AREA - Keep black background (already filled above) + // No need to redraw black pixels, they're already set + } + } + } else { + // Draw VHS-style progress bar pixel by pixel (original time-based logic) + for (let x = 0; x < mainScreenWidth; x++) { + let baseR, baseG, baseB; + + // Leading edge pixel - bright glowing leader + const isLeaderPixel = x === currentProgressWidth - 1 && currentProgressWidth > 0; + + if (x < currentProgressWidth) { + // FILLED AREA - VHS red with analog glow and scan lines + + if (isFirstFrame) { + // FIRST FRAME - Fully green across entire bar + baseR = 0; + baseG = 255; + baseB = 0; + } else if (isLastFrame) { + // LAST FRAME - Fully red across entire bar + baseR = 255; + baseG = 0; + baseB = 0; + } else { + // NORMAL FRAMES - Enhanced color sampling from buffer above + const sampledColor = sampleColorFromAbove(x); + + // Sample additional colors for more variety + const sampledColor2 = sampleColorFromAbove(Math.max(0, x - 3)); + const sampledColor3 = sampleColorFromAbove(Math.min(mainScreenWidth - 1, x + 3)); + + // Mix multiple sampled colors for richer palette + const mixedR = Math.floor((sampledColor.r + sampledColor2.r + sampledColor3.r) / 3); + const mixedG = Math.floor((sampledColor.g + sampledColor2.g + sampledColor3.g) / 3); + const mixedB = Math.floor((sampledColor.b + sampledColor2.b + sampledColor3.b) / 3); + const mixedColor = { r: mixedR, g: mixedG, b: mixedB }; + + // Base VHS red intensity - reduced to let more color through + let redIntensity = 200; // Reduced from 255 for more color mixing + + // Use shared VHS effects calculation + const effects = calculateVHSEffects(x, animFrame); + + // Combine all VHS effects with brighter base + redIntensity = Math.floor(redIntensity * effects.scanLine * effects.analogGlow * effects.tracking * effects.secondaryGlow); + + // Create VHS red color + const vhsR = Math.max(180, Math.min(255, redIntensity)); // Reduced min from 200 + const vhsG = Math.floor(vhsR * 0.05); // Very minimal green + const vhsB = Math.floor(vhsR * 0.02); // Very minimal blue + + // Use shared blending function (55% sampled, 45% VHS red for more color influence) + const blended = blendColorWithVHS(mixedColor, vhsR, vhsG, vhsB, 0.55); + baseR = blended.r; + baseG = blended.g; + baseB = blended.b; + } + + // Special leader pixel treatment + if (isLeaderPixel) { + if (isFirstFrame) { + // First frame - bright green leader + baseR = 0; + baseG = 255; + baseB = 0; + } else if (isLastFrame) { + // Last frame - bright red leader + baseR = 255; + baseG = 0; + baseB = 0; + } else { + // Check if we're in the fade period (20%-80%) to enable special blinking + const isInFadePeriod = progress >= 0.20 && progress <= 0.80; + + if (isInFadePeriod && !isShortRecording) { + // During fade period - cycle through yellow, lime, and other colors for visibility + const colorCycle = Math.floor(animFrame * 0.3) % 4; // Slower color cycling + + switch (colorCycle) { + case 0: + baseR = 255; baseG = 255; baseB = 0; // Yellow + break; + case 1: + baseR = 0; baseG = 255; baseB = 0; // Lime + break; + case 2: + baseR = 255; baseG = 128; baseB = 0; // Orange + break; + case 3: + baseR = 0; baseG = 255; baseB = 255; // Cyan + break; + } + } else { + // Normal behavior outside fade period - super bright white-hot leader with pulsing + const leaderPulse = Math.sin(animFrame * 0.6) * 0.2 + 0.8; + + baseR = 255; + baseG = Math.floor(255 * leaderPulse); // Bright white-hot leader + baseB = Math.floor(255 * leaderPulse); + } + } + } + + // Apply alpha fade to final colors, with beacon-like leader pixel + let finalAlpha = progressBarAlpha; + if (isLeaderPixel) { + if (progress >= 0.20 && progress <= 0.80 && !isShortRecording) { + // During fade period - beacon-like signal marker (strong but not full opacity) + finalAlpha = 0.8; // Bright beacon for progress indication + } else { + // Outside fade period - still visible beacon + finalAlpha = Math.min(progressBarAlpha, 0.9); // Strong beacon at 90% opacity + } + } + + // Use proper alpha blending for filled area + $.ink(baseR, baseG, baseB, Math.floor(finalAlpha * 255)).box(x, 0, 1, 2); + } else { + // UNFILLED AREA - Keep black background (already filled above) + // No need to redraw black pixels, they're already set + } + } + } + }); + + // Ensure the painting was created successfully before adding to sendData + if (tapeProgressBarPainting && tapeProgressBarPainting.pixels && tapeProgressBarPainting.pixels.length > 0) { + // Structure the data to match what bios.mjs expects (same as label format) + sendData.tapeProgressBar = { + x: 0, + y: screen.height - 2, // Position at bottom of screen (2px tall) + img: { + width: tapeProgressBarPainting.width, + height: tapeProgressBarPainting.height, + pixels: tapeProgressBarPainting.pixels + } + }; + } else { + console.warn("🎬 Tape progress bar painting FAILED to create:", { + painting: !!tapeProgressBarPainting, + pixels: !!tapeProgressBarPainting?.pixels, + pixelsLength: tapeProgressBarPainting?.pixels?.length + }); + } + } // Close the else block for frame count >= 60 + } + + maybeLeave(); + + // TODO: Write this up to the data in `painting`. + sendData.TwoD = { code: twoDCommands }; + + // Attach a label buffer if necessary. + // Hide label when QR is in fullscreen mode or in device mode (device.html has its own overlay) + if (label && !hudAnimationState.qrFullscreen && !DEVICE_MODE) { + const finalX = currentHUDOffset.x + hudAnimationState.slideOffset.x - currentHUDLeftPad; + // Move label up by 4px when QR is present for tighter fit + const qrYOffset = currentHUDQR ? -4 : 0; + const finalY = currentHUDOffset.y + hudAnimationState.slideOffset.y + qrYOffset; + + sendData.label = { + x: finalX, + y: finalY, + opacity: hudAnimationState.opacity, + img: (({ width, height, pixels }) => ({ width, height, pixels }))( + label, + ), + }; + + // DEBUG: Add hitbox visualization overlay + if (globalThis.debugHudHitbox && currentHUDButton) { + const hitboxWidth = currentHUDButton.box.w; + const hitboxHeight = currentHUDButton.box.h; + const blinkFrame = Number($api.paintCount || 0n); + const blinkOn = (blinkFrame % 30) < 15; + const outerAlpha = blinkOn ? 200 : 80; + const innerAlpha = blinkOn ? 120 : 40; + + const hitboxOverlay = $api.painting(hitboxWidth, hitboxHeight, ($) => { + $.unpan(); + $.unmask(); // Ensure hitbox overlay renders without piece mask + // Draw a blinking green border to show the hitbox + $.ink(0, 255, 0, outerAlpha).box(0, 0, hitboxWidth, hitboxHeight, "outline"); + if (hitboxWidth > 2 && hitboxHeight > 2) { + $.ink(0, 255, 0, innerAlpha).box(1, 1, hitboxWidth - 2, hitboxHeight - 2, "outline"); + } + }); + + sendData.hitboxDebug = { + x: currentHUDButton.box.x, + y: currentHUDButton.box.y, + opacity: hudAnimationState.opacity, + img: (({ width, height, pixels }) => ({ width, height, pixels }))( + hitboxOverlay, + ), + }; + } + } + + // Generate QR code overlay for KidLisp pieces + let qrOverlay; + + // Skip QR/HUD overlay in device mode - device.kidlisp.com has its own DOM overlay + // DEVICE_MODE is set from bios init message (can't use location.search in worker) + const isDeviceModeQR = DEVICE_MODE; + + // Clear QR cache if caching is disabled to prevent memory buildup + if (isQROverlayCacheDisabled() && qrOverlayCache.size > 0) { + qrOverlayCache.clear(); + } + + // Detect if this is a KidLisp piece + const sourceCode = currentText || currentHUDTxt; // Use plain currentText first, then fall back to HUD text + + // Use centralized KidLisp detection for QR code generation + // But exclude proper .lisp files which should not show source HUD + const isInlineKidlispPiece = (currentPath && lisp.isKidlispSource(currentPath) && !currentPath.endsWith('.lisp')) || + currentPath === "(...)" || + // Detect cached KidLisp pieces by $code format + (sourceCode && sourceCode.startsWith("$")) || + (currentPath && currentPath.includes("/disks/$")) || + // Use the centralized KidLisp detection that includes comma syntax + (sourceCode && lisp.isKidlispSource(sourceCode)); + + // Detect if this is a clock piece with a cached code + const isClockPieceWithCode = (currentPath === "clock" || currentPath?.endsWith("/clock") || currentPath?.includes("/disks/clock")) && cachedClockCode; + + // Debug: log clock detection (disabled - too verbose) + // if (cachedClockCode) { + // console.log("🎵 [QR Debug] Clock QR check:", { currentPath, cachedClockCode, isClockPieceWithCode, hideLabel, hudVisible: hudAnimationState.visible }); + // } + + // For clock pieces with cached code, always show the QR (don't require HUD visible) + // For KidLisp pieces, require HUD to be visible/animating/fullscreen + const shouldShowQRForKidLisp = (isInlineKidlispPiece && sourceCode) && (hudAnimationState.visible || hudAnimationState.animating || hudAnimationState.qrFullscreen); + const shouldShowQRForClock = isClockPieceWithCode; // Always show for clock pieces + + // Skip ALL pixelated HUD overlays in device mode (device.html has DOM-based overlays) + if (!isDeviceModeQR && (shouldShowQRForKidLisp || shouldShowQRForClock) && !hideLabel) { + // console.log("🎵 [QR Debug] Entering QR generation for clock"); + try { + // For clock pieces with cached code, use the cached clock code directly + // For KidLisp pieces, determine cachedCode from sourceCode + let cachedCode; + let sigil = "$"; // Default sigil for KidLisp + + if (isClockPieceWithCode) { + // Clock piece: use the cached clock code with * sigil + cachedCode = cachedClockCode; + sigil = "*"; + } else if (sourceCode.startsWith("$")) { + // For $code format, the sourceCode IS the cached code (without $) + cachedCode = sourceCode.substring(1).split(":")[0]; // Remove the $ prefix and any :fps colon params + // console.log("🔑 [QR Debug] Using $code format, cachedCode:", cachedCode); + } else { + // For regular KidLisp source, check if it has been cached + cachedCode = getCachedCode(sourceCode); + // console.log("🔍 [QR Debug] Checked cache, cachedCode:", cachedCode); + + // If not cached yet but QR fullscreen is active (shift was pressed), + // trigger caching and request a repaint when complete + if (!cachedCode && hudAnimationState.qrFullscreen) { + // console.log("⏳ [QR Debug] Code not cached, triggering caching..."); + const kidlispInstance = getGlobalKidLisp(); + if (kidlispInstance) { + // console.log("✅ [QR Debug] Got KidLisp instance, calling cacheKidlispSource"); + // Trigger caching asynchronously + kidlispInstance.cacheKidlispSource(sourceCode, $commonApi).then(() => { + // Check if code was successfully cached + const newCachedCode = getCachedCode(sourceCode); + if (newCachedCode) { + //console.log(`🔄 Code cached after shift press: $${newCachedCode}`); + // Trigger repaint to show the QR code + if (typeof window !== "undefined" && window.$activePaintApi?.needsPaint) { + window.$activePaintApi.needsPaint(); + } + } + }).catch(err => { + console.warn("Failed to cache KidLisp code:", err); + }); + } + } + } + + if (cachedCode) { + // Send the cached code to main thread for tape naming (only for KidLisp) + if (sigil === "$") { + send({ type: "kidlisp:cached-code", content: cachedCode }); + } + + // Use cache key based on sigil + cached code to avoid regenerating the same QR + const cacheKey = `qr_${sigil}${cachedCode}`; + + // Get the font and ensure it's properly loaded before proceeding + const font = typefaceCache.get("MatrixChunky8"); + + // Check if ALL glyphs in the label text are loaded + // The label will be sigil + cachedCode (e.g., "$wipe", "*bako", etc.) + // We need to verify every character's glyph is loaded to avoid pop-in + const labelText = `${sigil}${cachedCode}`; + let allGlyphsLoaded = false; + + if (font && font.glyphs) { + allGlyphsLoaded = true; + const missingGlyphs = []; + + for (const char of labelText) { + const glyph = font.glyphs[char]; + // console.log('[MatrixChunky8] Glyph check:', { char, charCode: char.charCodeAt(0), glyph: glyph ? 'exists' : 'missing', glyphType: typeof glyph }); + // A real glyph has properties like dwidth, advance, or resolution + // The Proxy returns null for missing glyphs + const isRealGlyph = glyph && glyph !== null && + (glyph.dwidth || glyph.advance !== undefined || glyph.resolution); + + if (!isRealGlyph) { + allGlyphsLoaded = false; + missingGlyphs.push(char); + // console.log('[MatrixChunky8] Missing glyph:', char, 'charCode:', char.charCodeAt(0)); + } + } + + // if (!allGlyphsLoaded) { + // console.log('[QR] MatrixChunky8 glyphs pending:', { + // labelText, + // missingGlyphs: missingGlyphs.join('') || 'unknown' + // }); + // } + } + + const shouldShowQR = allGlyphsLoaded; + + // Track when glyphs first become available to delay caching by one frame + // This ensures the painting callback has completed rendering before we cache + if (font && shouldShowQR && font.__qrGlyphsReadyFrame === undefined) { + // First frame where glyphs are loaded - mark the frame number + font.__qrGlyphsReadyFrame = (typeof window !== "undefined" && window.frameCount) || 0; + } + + // Safe to cache if glyphs were ready at least 1 frame ago (or we don't have frameCount) + const currentFrame = (typeof window !== "undefined" && window.frameCount) || 0; + const safeToCache = shouldShowQR && ( + font?.__qrGlyphsReadyFrame === undefined || + currentFrame > font.__qrGlyphsReadyFrame + ); + + // If font exists but glyphs not loaded yet, trigger loading and request repaint + if (font && !shouldShowQR) { + font.__qrGlyphsReadyFrame = undefined; // Reset the ready frame tracking + if (!font.__loadPromise) { + ensureTypefaceLoaded(font); + } + if (font.__loadPromise) { + font.__loadPromise.then(() => { + font.__loaded = true; + // Clear QR cache for this code so it regenerates with proper font + qrOverlayCache.delete(cacheKey); + // Trigger repaint to show QR once font is ready + if (typeof window !== "undefined" && window.$activePaintApi?.needsPaint) { + window.$activePaintApi.needsPaint(); + } + }).catch((err) => { + console.warn("Failed to load MatrixChunky8 for QR:", err); + font.__loaded = true; // Mark as loaded to prevent infinite waiting + }); + } + } + + + // Check if this QR overlay is already cached (unless caching is disabled) + const isQRCacheDisabled = isQROverlayCacheDisabled(); + const hasQRCache = qrOverlayCache.has(cacheKey); + + // Declare variables for QR positioning and sizing (used in both cached and fresh QR paths) + let overlayWidth, overlayHeight, startX, startY; + + + // Render QR from cache if available, or generate fresh + // Note: We render even if glyphs aren't loaded yet - they'll pop in on repaint + if (!isQRCacheDisabled && hasQRCache) { + + const cachedQrData = qrOverlayCache.get(cacheKey); + + // Store QR dimensions in animation state for proper bounding box animations + hudAnimationState.qrSize = Math.max(cachedQrData.width, cachedQrData.height); + + if (hudAnimationState.qrFullscreen) { + // Fullscreen mode: integer scaling only for pixel-perfect QR + const originalWidth = cachedQrData.width; + const originalHeight = cachedQrData.height; + + // Calculate maximum integer scale that fits screen with padding + const padding = Math.min(screen.width, screen.height) * 0.1; // 10% padding + const maxWidth = screen.width - padding * 2; + const maxHeight = screen.height - padding * 2; + + const maxScaleX = Math.floor(maxWidth / originalWidth); + const maxScaleY = Math.floor(maxHeight / originalHeight); + const scale = Math.min(maxScaleX, maxScaleY, 8); // Cap at 8x scale + + // Use integer scaling for pixel-perfect results + overlayWidth = originalWidth * scale; + overlayHeight = originalHeight * scale; + + // Calculate text dimensions for canvas sizing + const codeText = `${sigil}${cachedCode}`; + const fontSize = 16; // Fixed 2x scale (8px base font * 2 = 16px) + const textPadding = 8; + + // Calculate actual text width using font advances for proper sizing + const font = typefaceCache.get("MatrixChunky8"); + const advances = font?.data?.advances || {}; + let actualTextWidth = 0; + for (const char of codeText) { + const charWidth = advances[char] || 4; // Default 4px per char if no advance data + actualTextWidth += charWidth; + } + // Scale up by 2x for our size: 2 scaling + actualTextWidth *= 2; + + const textWidth = actualTextWidth + textPadding * 2; // Add padding + const textHeight = fontSize + textPadding * 2; + + // Use full screen canvas to position QR code (centered) and text + const canvasHeight = screen.height; + const canvasWidth = screen.width; + + // Center QR on screen (ensure integer coordinates) + const qrX = Math.floor((screen.width - overlayWidth) / 2); + const qrY = Math.floor((screen.height - overlayHeight) / 2); + + // Position starts at top-left of screen since we're using full screen canvas + startX = 0; + startY = 0; + + // Create scaled QR overlay with styled code text + qrOverlay = $api.painting(canvasWidth, canvasHeight, async ($) => { + $.unmask(); // Ensure QR overlay renders without piece mask + // Draw scaled QR with integer scaling (centered in canvas) + + for (let y = 0; y < originalHeight; y++) { + for (let x = 0; x < originalWidth; x++) { + const srcIndex = (y * originalWidth + x) * 4; + + if (srcIndex < cachedQrData.basePixels.length) { + const r = cachedQrData.basePixels[srcIndex]; + const g = cachedQrData.basePixels[srcIndex + 1]; + const b = cachedQrData.basePixels[srcIndex + 2]; + + $.ink(r, g, b); + // Draw scale x scale pixel block + $.box(qrX + x * scale, qrY + y * scale, scale, scale); + } + } + } + + // Add styled code text positioned at top-left corner of screen + const textX = 10; // Small margin from left edge of screen + const textY = 10; // Small margin from top edge of screen + + // Draw black shadow + $.ink("black"); + $.write(codeText, { x: textX + 1, y: textY + 1, size: 2 }); + + // Draw white text on black background - try MatrixChunky8 first, fallback to default + $.ink("white"); + const matrixFont = typefaceCache.get("MatrixChunky8"); + if (matrixFont && matrixFont.glyphs) { + try { + $.write(codeText, { x: textX, y: textY, size: 2 }, undefined, undefined, false, "MatrixChunky8"); + } catch (error) { + // Fallback to default font if MatrixChunky8 fails + $.write(codeText, { x: textX, y: textY, size: 2 }); + } + } else { + // Use default font if MatrixChunky8 not available + $.write(codeText, { x: textX, y: textY, size: 2 }); + } + + // Add "TAP TO CLOSE" instruction at bottom center of screen + const closeText = "TAP TO CLOSE"; + const closeTextY = screen.height - 20; // 20px from bottom + const closeTextX = Math.floor(screen.width / 2) - 30; // Rough center + + // Draw black shadow for close text + $.ink("black"); + $.write(closeText, { x: closeTextX + 1, y: closeTextY + 1, size: 1 }); + + // Draw white close text + $.ink("white"); + $.write(closeText, { x: closeTextX, y: closeTextY, size: 1 }); + }); + } else { + // Normal mode: use original positioning + const margin = 4; + overlayWidth = cachedQrData.width; + overlayHeight = cachedQrData.height; + startX = screen.width - overlayWidth - margin; + startY = screen.height - overlayHeight - margin; + + // Create fresh overlay for transfer from cached data + qrOverlay = { + width: cachedQrData.width, + height: cachedQrData.height, + pixels: new Uint8ClampedArray(cachedQrData.basePixels) // Fresh copy for transfer + }; + } + + // Add QR overlay to sendData with animation effects + sendData.qrOverlay = { + x: startX + (hudAnimationState.qrFullscreen ? 0 : hudAnimationState.qrSlideOffset.x), + y: startY + (hudAnimationState.qrFullscreen ? 0 : hudAnimationState.qrSlideOffset.y), + opacity: hudAnimationState.opacity, + img: qrOverlay + }; + + // Add clickable hitbox for QR code + if (hudAnimationState.qrFullscreen) { + // Fullscreen QR: make entire QR tappable to go back + send({ + type: "button:hitbox:add", + content: { + label: "qr-fullscreen", + box: { + x: startX, + y: startY, + w: overlayWidth, + h: overlayHeight + }, + message: "qr-fullscreen-tap" + } + }); + } else { + // Corner QR: make it tappable to go fullscreen + if (!SOLO_MODE) { + send({ + type: "button:hitbox:add", + content: { + label: "qr-corner", + box: { + x: startX + hudAnimationState.qrSlideOffset.x, + y: startY + hudAnimationState.qrSlideOffset.y, + w: overlayWidth, + h: overlayHeight + }, + message: "qr-corner-tap" + } + }); + } + } + + } else { + + // Always use prompt.ac for QR codes (even in dev/local) + let url = "https://prompt.ac"; + + // Use the cached nanoid code with appropriate sigil prefix for a short URL + // $ for KidLisp, * for clock melodies + url += `/${sigil}${cachedCode}`; + + // Generate QR code with medium error correction for better scannability + const cells = qr(url, { errorCorrectLevel: ErrorCorrectLevel.M }).modules; + + // Calculate size and position for bottom-right corner with 4px margin + const margin = 4; + const cellSize = 1; // 1 pixel per cell for smallest 1:1 size + const qrSize = cells.length * cellSize; + + // Store QR dimensions in animation state for proper bounding box animations + hudAnimationState.qrSize = qrSize; + + // Position in bottom-right corner + let startX = screen.width - qrSize - margin - 1; // Account for shadow width + const textHeight = 12; // Space for MatrixChunky8 8px font with shadow (8px + 4px padding) + const totalHeight = qrSize + textHeight; + let startY = screen.height - totalHeight - margin; // Move 1px closer to bottom for balanced margins + + // Create QR overlay using painting API with extra space for shadow + const textAreaHeight = 9; + const qrOffsetY = textAreaHeight; // QR starts right after text area (no gap) + const canvasHeight = qrOffsetY + qrSize + 2; // Ensure room for bottom shadow + const generatedQR = $api.painting(qrSize + 1, canvasHeight, async ($) => { + $.unmask(); // Ensure QR renders without piece mask + // Draw QR code at offset position to make room for text above + for (let y = 0; y < cells.length; y++) { + for (let x = 0; x < cells.length; x++) { + const isBlack = cells[y][x]; + if (isBlack) { + $.ink("black"); + } else { + $.ink("white"); + } + $.box(x * cellSize, (y * cellSize) + qrOffsetY, cellSize); // Add qrOffsetY to move QR down + } + } + + // QR text style configuration + const useBackdrop = true; // Set to true for backdrop style, false for shadow style + + // Prepare text for rendering (using sigil variable: $ for KidLisp, * for clock) + const codeToRender = `${sigil}${cachedCode}`; + + // Calculate actual rendered width for mathematical centering + // Get character advances from the font definition + const advances = font?.data?.advances || typefaceCache.get("MatrixChunky8")?.data?.advances || {}; + + // Calculate text dimensions and positioning + let actualTextWidth = 0; + for (const char of codeToRender) { + const charWidth = advances[char] || 4; + actualTextWidth += charWidth; + } + + // Text area configuration - be more generous with sizing + const textPaddingLeft = 1; // Minimal left padding + const textPaddingRight = 1; // Minimal right padding for safety + const textAreaWidth = actualTextWidth + textPaddingLeft + textPaddingRight; + const textAreaHeight = 10; // Increased height (was 9) + + // Position text area above QR code (which is now at qrOffsetY) + const textAreaX = qrSize - textAreaWidth; // Still flush right + const textAreaY = 0; // At the top of the canvas + + // Text position within the text area (right-aligned with left padding only) + const textX = textAreaX + textPaddingLeft; // Only left padding + const textY = textAreaY + 1; // Vertical centering within smaller text area (was 2) + + // Render text with appropriate style + if (useBackdrop) { + // Draw black background for text area (sized to fit text) + $.ink("black"); // Black background + $.box(textAreaX, textAreaY, textAreaWidth, textAreaHeight); + + // Render white text (no rotation for now) + $.ink("white"); // White text on black background + + if (getPackMode()) { + // In OBJKT mode, try MatrixChunky8 first, fall back to default if not available + let matrixFont = typefaceCache.get("MatrixChunky8"); + if (matrixFont) { + + // Check if this is a proper Typeface instance with all methods + if (!matrixFont.getGlyph || typeof matrixFont.getGlyph !== 'function') { + // Create a new proper Typeface instance + const newMatrixFont = new Typeface("MatrixChunky8"); + await newMatrixFont.load($commonApi.net.preload); + // Copy over existing glyphs data if any + if (matrixFont.glyphs) { + Object.assign(newMatrixFont.glyphs, matrixFont.glyphs); + } + matrixFont = newMatrixFont; + typefaceCache.set("MatrixChunky8", matrixFont); + } + + // Check if glyphs actually work by testing a specific character + const testGlyph = matrixFont.glyphs['$']; + // MatrixChunky8 uses BDF structure: {resolution, offset, baselineOffset, advance, commands, bbx} + const glyphsWorking = testGlyph && typeof testGlyph === 'object' && + (testGlyph.pixels || testGlyph.commands || testGlyph.resolution); + + if (!glyphsWorking) { + // Create simple fallback glyphs for common characters + const fallbackGlyph = { + resolution: [6, 8], + pixels: [ + [0, 1, 1, 1, 1, 0], + [1, 0, 0, 0, 0, 1], + [1, 0, 1, 1, 0, 1], + [1, 0, 1, 1, 0, 1], + [1, 0, 0, 0, 0, 1], + [1, 0, 0, 0, 0, 1], + [0, 1, 1, 1, 1, 0], + [0, 0, 0, 0, 0, 0] + ] + }; + + // DO NOT replace glyphs object - it's a Proxy that loads on-demand + // Just populate common characters if they're missing + if (matrixFont.glyphs) { + const commonChars = '$rozeabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + for (const char of commonChars) { + if (!matrixFont.glyphs[char]) { + matrixFont.glyphs[char] = fallbackGlyph; + matrixFont[char] = fallbackGlyph; // Also set directly on font object + } + } + } + } + + // Check if font has reasonable glyph data before rendering + const testChar = matrixFont.glyphs && (matrixFont.glyphs['$'] || matrixFont.glyphs['A'] || matrixFont.glyphs['a']); + const hasValidGlyphs = testChar && (testChar.pixels || testChar.commands || testChar.resolution); + if (hasValidGlyphs) { + $.write(codeToRender, { x: textX, y: textY }, undefined, undefined, false, "MatrixChunky8"); + } else { + // Fallback to default font if MatrixChunky8 isn't ready + $.write(codeToRender, { x: textX, y: textY }); + } + } else { + // Fallback to default font if MatrixChunky8 not available + $.write(codeToRender, { x: textX, y: textY }); + } + } else { + // Use MatrixChunky8 font in normal mode if available + const matrixFont = typefaceCache.get("MatrixChunky8"); + + // Check if MatrixChunky8 glyphs are actually loaded and ready + let useMatrixFont = false; + if (matrixFont && matrixFont.glyphs) { + // Check if ALL glyphs in codeToRender are loaded + useMatrixFont = true; + for (const char of codeToRender) { + const glyph = matrixFont.glyphs[char]; + const isLoaded = glyph && (glyph.pixels || glyph.commands || glyph.resolution); + if (!isLoaded) { + useMatrixFont = false; + break; + } + } + } + + // Only render text if MatrixChunky8 is ready (will trigger repaint when it loads) + if (useMatrixFont) { + $.write(codeToRender, { x: textX, y: textY }, undefined, undefined, false, "MatrixChunky8"); + } + // Otherwise skip rendering text - it will pop in on next repaint + } + } else { + // Shadow style: black shadow first, then white text + // Draw black shadow (1px offset) - no rotation for now + $.ink("black"); + + if (getPackMode()) { + // In OBJKT mode, try MatrixChunky8 first, fall back to default if not available + const matrixFont = typefaceCache.get("MatrixChunky8"); + if (matrixFont && matrixFont.glyphs) { + const testChar = matrixFont.glyphs['$'] || matrixFont.glyphs['A'] || matrixFont.glyphs['a']; + const hasValidGlyphs = testChar && (testChar.pixels || testChar.commands || testChar.resolution); + if (hasValidGlyphs) { + $.write(codeToRender, { x: textX + 1, y: textY + 1 }, undefined, undefined, false, "MatrixChunky8"); + } else { + $.write(codeToRender, { x: textX + 1, y: textY + 1 }); + } + } else { + $.write(codeToRender, { x: textX + 1, y: textY + 1 }); + } + } else { + // Check if MatrixChunky8 glyphs are loaded for shadow + const matrixFont = typefaceCache.get("MatrixChunky8"); + let useMatrixFont = false; + if (matrixFont && matrixFont.glyphs) { + const testGlyph = matrixFont.glyphs['$'] || matrixFont.glyphs['a']; + useMatrixFont = !!(testGlyph && (testGlyph.pixels || testGlyph.commands || testGlyph.resolution)); + } + + // Only render if MatrixChunky8 is ready + if (useMatrixFont) { + $.write(codeToRender, { x: textX + 1, y: textY + 1 }, undefined, undefined, false, "MatrixChunky8"); + } + } + + // Draw white text on top - no rotation for now + $.ink("white"); + if (getPackMode()) { + // In OBJKT mode, try MatrixChunky8 first, fall back to default if not available + const matrixFont = typefaceCache.get("MatrixChunky8"); + if (matrixFont && matrixFont.glyphs) { + const testChar = matrixFont.glyphs['$'] || matrixFont.glyphs['A'] || matrixFont.glyphs['a']; + const hasValidGlyphs = testChar && (testChar.pixels || testChar.commands || testChar.resolution); + if (hasValidGlyphs) { + $.write(codeToRender, { x: textX, y: textY }, undefined, undefined, false, "MatrixChunky8"); + } else { + $.write(codeToRender, { x: textX, y: textY }); + } + } else { + $.write(codeToRender, { x: textX, y: textY }); + } + } else { + // Check if MatrixChunky8 glyphs are loaded for white text + const matrixFont = typefaceCache.get("MatrixChunky8"); + let useMatrixFont = false; + if (matrixFont && matrixFont.glyphs) { + const testGlyph = matrixFont.glyphs['$'] || matrixFont.glyphs['a']; + useMatrixFont = !!(testGlyph && (testGlyph.pixels || testGlyph.commands || testGlyph.resolution)); + } + + // Only render if MatrixChunky8 is ready + if (useMatrixFont) { + $.write(codeToRender, { x: textX, y: textY }, undefined, undefined, false, "MatrixChunky8"); + } + } + } + + // Draw gray shadow along the right side and bottom of the entire overlay + $.ink("gray", 128); + // Right shadow - offset 1px down from top like a drop shadow + $.box(qrSize, 1, 1, qrOffsetY + qrSize); + // Bottom shadow - offset 1px from left edge like a drop shadow + $.box(1, qrOffsetY + qrSize, qrSize, 1); + + // Add a subtle white highlight to indicate the QR is clickable (corner only) + // This creates a "button-like" appearance + $.ink("white", 64); // Semi-transparent white + // Top highlight line + $.line(0, qrOffsetY, qrSize - 1, qrOffsetY); + // Left highlight line + $.line(0, qrOffsetY, 0, qrOffsetY + qrSize - 1); + }); + + // Don't cache QR overlay if font isn't fully loaded yet (text label needs to re-render) + // Cache the base QR data (not the transferable pixels) + const qrData = { + width: generatedQR.width, + height: generatedQR.height, + basePixels: new Uint8ClampedArray(generatedQR.pixels) // Keep a safe copy for caching + }; + + // Store QR dimensions in animation state for proper bounding box animations + hudAnimationState.qrSize = Math.max(qrData.width, qrData.height); + + if (hudAnimationState.qrFullscreen) { + // Fullscreen mode: integer scaling only for pixel-perfect QR + const originalQrSize = cells.length; + + // Calculate maximum integer scale that fits screen with padding + const padding = Math.min(screen.width, screen.height) * 0.1; // 10% padding + const maxSize = Math.min(screen.width, screen.height) - padding * 2; + + const maxCellSize = Math.floor(maxSize / originalQrSize); + const cellSize = Math.max(2, Math.min(maxCellSize, 12)); // Integer cell size, 2-12px + const finalQrSize = originalQrSize * cellSize; + + overlayWidth = finalQrSize; + overlayHeight = finalQrSize; + + // Calculate text dimensions for canvas sizing + const codeText = `${sigil}${cachedCode}`; + const fontSize = 16; // Fixed 2x scale (8px base font * 2 = 16px) + const textPadding = 8; + + // Calculate actual text width using font advances for proper sizing + const font = typefaceCache.get("MatrixChunky8"); + const advances = font?.data?.advances || {}; + let actualTextWidth = 0; + for (const char of codeText) { + const charWidth = advances[char] || 4; // Default 4px per char if no advance data + actualTextWidth += charWidth; + } + // Scale up by 2x for our size: 2 scaling + actualTextWidth *= 2; + + const textWidth = actualTextWidth + textPadding * 2; // Add padding + const textHeight = fontSize + textPadding * 2; + + // Use full screen canvas to position QR code (centered) and text (top-left) + const canvasHeight = screen.height; + const canvasWidth = screen.width; + + // Position QR code in center of screen + const qrX = Math.floor((screen.width - finalQrSize) / 2); + const qrY = Math.floor((screen.height - finalQrSize) / 2); + + // Position starts at top-left of screen since we're using full screen canvas + startX = 0; + startY = 0; + + // Generate fullscreen canvas with centered QR and top-left text + const fullscreenQR = $api.painting(canvasWidth, canvasHeight, ($) => { + $.unmask(); // Ensure fullscreen QR renders without piece mask + // Draw QR code centered on screen + for (let y = 0; y < cells.length; y++) { + for (let x = 0; x < cells.length; x++) { + const isBlack = cells[y][x]; + if (isBlack) { + $.ink("black"); + } else { + $.ink("white"); + } + $.box(qrX + x * cellSize, qrY + y * cellSize, cellSize, cellSize); + } + } + + // Draw text in absolute top-left corner of screen + const codeText = `${sigil}${cachedCode}`; + const textX = 10; // Small margin from left edge of screen + const textY = 10; // Small margin from top edge of screen + + // Draw black shadow offset by 1px + $.ink("black"); + $.write(codeText, { x: textX + 1, y: textY + 1, size: 2 }); // 2x scale for shadow + + // Draw white text on top + $.ink("white"); + $.write(codeText, { x: textX, y: textY, size: 2 }); // 2x scale for main text + + // Add "TAP TO CLOSE" instruction at bottom center of screen + const closeText = "TAP TO CLOSE"; + const closeTextY = screen.height - 20; // 20px from bottom + const closeTextX = Math.floor(screen.width / 2) - 30; // Rough center (adjust as needed) + + // Draw black shadow for close text + $.ink("black"); + $.write(closeText, { x: closeTextX + 1, y: closeTextY + 1, size: 1 }); + + // Draw white close text + $.ink("white"); + $.write(closeText, { x: closeTextX, y: closeTextY, size: 1 }); + }); + + // Update qrData for fullscreen + qrData.width = fullscreenQR.width; + qrData.height = fullscreenQR.height; + qrData.basePixels = new Uint8ClampedArray(fullscreenQR.pixels); + + qrOverlay = { + width: qrData.width, + height: qrData.height, + pixels: new Uint8ClampedArray(qrData.basePixels) + }; + } else { + // Normal mode: use original positioning and size + overlayWidth = qrData.width; + overlayHeight = qrData.height; + startX = screen.width - overlayWidth - margin; + startY = screen.height - overlayHeight - margin; // Removed +1 to prevent label shadow overlap + + // Create fresh overlay for transfer each time + qrOverlay = { + width: qrData.width, + height: qrData.height, + pixels: new Uint8ClampedArray(qrData.basePixels) // Fresh copy for transfer + }; + } + + // Cache the QR data for this piece once glyphs are confirmed rendered + // We use safeToCache to ensure the painting callback has completed + if (!isQRCacheDisabled && safeToCache && !hudAnimationState.qrFullscreen) { + qrOverlayCache.set(cacheKey, qrData); + console.log('[QR] ✅ Cached QR with MatrixChunky8 glyphs for:', cacheKey); + } + + // Add QR overlay to sendData with exact position and animation effects + sendData.qrOverlay = { + x: startX + (hudAnimationState.qrFullscreen ? 0 : hudAnimationState.qrSlideOffset.x), + y: startY + (hudAnimationState.qrFullscreen ? 0 : hudAnimationState.qrSlideOffset.y), + opacity: hudAnimationState.opacity, + img: qrOverlay + }; + + // Add clickable hitbox for QR code + if (hudAnimationState.qrFullscreen) { + // Fullscreen QR: make entire QR tappable to go back + send({ + type: "button:hitbox:add", + content: { + label: "qr-fullscreen", + box: { + x: startX, + y: startY, + w: overlayWidth || qrData.width, + h: overlayHeight || qrData.height + }, + message: "qr-fullscreen-tap" + } + }); + } else { + // Corner QR: make it tappable to go fullscreen + if (!SOLO_MODE) { + send({ + type: "button:hitbox:add", + content: { + label: "qr-corner", + box: { + x: startX + hudAnimationState.qrSlideOffset.x, + y: startY + hudAnimationState.qrSlideOffset.y, + w: overlayWidth || qrData.width, + h: overlayHeight || qrData.height + }, + message: "qr-corner-tap" + } + }); + } + } + + } + } else { + // No cached code yet - QR will appear once caching is complete + } + } catch (err) { + console.warn("Failed to generate QR overlay:", err); + } + } + + // 👤 Author attribution overlay for KidLisp pieces (bottom-left corner) + // Only show when: HUD is visible, we have an author, and it's a KidLisp piece + // Skip in device mode (device.kidlisp.com has its own DOM overlay) + // Note: Reuse isDeviceModeQR from earlier in the function + // In device mode, completely skip author overlay creation + if (isDeviceModeQR) { + // Don't create author overlay - device.html has its own DOM-based display + } else if (currentHUDAuthor && hudAnimationState.visible && !hudAnimationState.qrFullscreen) { + try { + const authorText = currentHUDAuthor; // Already formatted as @handle + const hitsText = currentHUDHits ? `${currentHUDHits.toLocaleString()} hits` : null; + + // Use MatrixChunky8 for compact display + const font = typefaceCache.get("MatrixChunky8"); + const charWidth = 4; // MatrixChunky8 char width + const charHeight = 8; // MatrixChunky8 char height + const padding = 2; + const lineGap = 2; + + // Calculate dimensions + const authorWidth = authorText.length * charWidth; + const hitsWidth = hitsText ? hitsText.length * charWidth : 0; + const maxWidth = Math.max(authorWidth, hitsWidth); + const height = hitsText ? (charHeight * 2 + lineGap + padding * 2) : (charHeight + padding * 2); + const width = maxWidth + padding * 2; + + // Create attribution overlay painting + const authorOverlay = $api.painting(width + 1, height + 1, ($) => { + $.unmask(); + + // Semi-transparent black background for readability + $.ink(0, 0, 0, 180).box(0, 0, width, height); + + // Draw author handle in pink (matching give.aesthetic.computer style) + $.ink(255, 107, 157); // Pink color from give page CSS --pink + if (font && font.glyphs) { + $.write(authorText, { x: padding, y: padding }, undefined, undefined, false, "MatrixChunky8"); + } else { + $.write(authorText, { x: padding, y: padding }); + } + + // Draw hits count in gold if available + if (hitsText) { + $.ink(255, 217, 61); // Gold color from give page CSS --gold + if (font && font.glyphs) { + $.write(hitsText, { x: padding, y: padding + charHeight + lineGap }, undefined, undefined, false, "MatrixChunky8"); + } else { + $.write(hitsText, { x: padding, y: padding + charHeight + lineGap }); + } + } + }); + + // Position in bottom-left corner with same margin as QR code + const margin = 4; + const startX = margin; + const startY = screen.height - height - margin; + + // Add author overlay to sendData with animation effects + sendData.authorOverlay = { + x: startX - hudAnimationState.qrSlideOffset.x, // Slide opposite direction from QR + y: startY + hudAnimationState.qrSlideOffset.y, + opacity: hudAnimationState.opacity, + img: (({ width, height, pixels }) => ({ width, height, pixels }))(authorOverlay), + }; + } catch (err) { + console.warn("Failed to generate author overlay:", err); + } + } + + let transferredPixels; + + // Check to see if we have a dirtyBox to render from. + const croppedBox = dirtyBox?.croppedBox?.(screen); + + if (croppedBox?.w > 0 && croppedBox?.h > 0) { + transferredPixels = dirtyBox.crop(screen); + sendData.pixels = transferredPixels; + sendData.dirtyBox = croppedBox; + if ($commonApi.rec.presenting) { + // Log dirtyBox usage during tape playback + } + } else if (painted === true) { + // TODO: Toggling this causes a flicker in `line`... but helps prompt. 2022.01.29.13.21 + // Otherwise render everything if we drew anything! + transferredPixels = screen.pixels; + sendData.pixels = transferredPixels; + } else { + // Painted is false, skip pixel transfer + } + + + // Optional messages to send. + if (painted === true) sendData.paintChanged = true; + if (loading === true) sendData.loading = true; + + // WebGPU state (tell main thread whether to skip CPU rendering) + if ($commonApi.webgpu.enabled) { + sendData.webgpuEnabled = true; + // Send backend preference if specified + if ($commonApi.webgpu.backend) { + sendData.gpuBackend = $commonApi.webgpu.backend; + } + // Signal end of frame to flush WebGPU command queue + send({ type: "webgpu-command", content: { type: "frame-end" } }); + } + + // These fields are one time `signals`. + if (reframe || glazeAfterReframe) { + sendData.reframe = reframe || glazeAfterReframe !== undefined; + if (glazeAfterReframe) { + send(glazeAfterReframe); + glazeAfterReframe = undefined; + } + } + + if (cursorCode) sendData.cursorCode = cursorCode; + + // Note: transferredPixels will be undefined when sendData === {}. + if (sendData.pixels) { + // 🛡️ Create a copy for transfer to avoid detaching the main rendering buffer + const pixelsCopy = new Uint8ClampedArray(sendData.pixels); + sendData.pixels = pixelsCopy.buffer; + } else { + sendData.pixels = content.pixels; + } + + if (sendData.pixels?.byteLength === 0) sendData.pixels = undefined; + + let transferredObjects = [sendData.pixels]; + + if (sendData.label) { + // 🛡️ Create a copy for transfer to avoid detaching the label buffer + const labelPixelsCopy = new Uint8ClampedArray(sendData.label.img.pixels); + transferredObjects.push(labelPixelsCopy.buffer); + } + + if (sendData.qrOverlay) { + // 🛡️ Create a copy for transfer to avoid detaching the QR overlay buffer + const qrPixelsCopy = new Uint8ClampedArray(sendData.qrOverlay.img.pixels); + transferredObjects.push(qrPixelsCopy.buffer); + } + + if (sendData.qrCornerText) { + // 🛡️ Create a copy for transfer to avoid detaching the QR corner text buffer + const qrCornerPixelsCopy = new Uint8ClampedArray(sendData.qrCornerText.img.pixels); + transferredObjects.push(qrCornerPixelsCopy.buffer); + } + + if (sendData.tapeProgressBar) { + // 🛡️ Create a copy for transfer to avoid detaching the tape progress bar buffer + const tapePixelsCopy = new Uint8ClampedArray(sendData.tapeProgressBar.img.pixels); + transferredObjects.push(tapePixelsCopy.buffer); + } + + if (sendData.merryProgressBar) { + // 🎄 Create a copy for transfer to avoid detaching the merry progress bar buffer + const merryPixelsCopy = new Uint8ClampedArray(sendData.merryProgressBar.img.pixels); + transferredObjects.push(merryPixelsCopy.buffer); + } + + if (sendData.demoplayCard) { + // 🎬 Create a copy for transfer to avoid detaching the demoplay card buffer + const demoplayPixelsCopy = new Uint8ClampedArray(sendData.demoplayCard.img.pixels); + transferredObjects.push(demoplayPixelsCopy.buffer); + } + + if (sendData.lanBadge) { + // 🔗 Create a copy for transfer to avoid detaching the LAN badge buffer + const lanBadgePixelsCopy = new Uint8ClampedArray(sendData.lanBadge.img.pixels); + transferredObjects.push(lanBadgePixelsCopy.buffer); + } + + if (sendData.authorOverlay) { + // 👤 Create a copy for transfer to avoid detaching the author overlay buffer + const authorPixelsCopy = new Uint8ClampedArray(sendData.authorOverlay.img.pixels); + transferredObjects.push(authorPixelsCopy.buffer); + } + + // 🎬 Add recording UI overlays (visible on screen but NOT captured in tape) + const recordingUIData = $commonApi.recordingUI._getPending(); + if (Object.keys(recordingUIData).length > 0) { + sendData.recordingUI = {}; + for (const [name, overlayData] of Object.entries(recordingUIData)) { + // Create a copy of the pixels for transfer + const pixelsCopy = new Uint8ClampedArray(overlayData.img.pixels); + sendData.recordingUI[name] = { + x: overlayData.x, + y: overlayData.y, + opacity: overlayData.opacity, + img: { + width: overlayData.img.width, + height: overlayData.img.height, + pixels: pixelsCopy, + }, + }; + transferredObjects.push(pixelsCopy.buffer); + } + $commonApi.recordingUI._clearPending(); + } + + // console.log("TO:", transferredObjects); + // console.log("Sent data:", sendData); + + sendData.sound = sound; + + // Log first render sent back to main thread + if (!globalThis._firstRenderSent) { + globalThis._firstRenderSent = true; + diskTimings.firstRenderSent = Math.round(performance.now() - diskTimingStart); + // Silent: first render sent + + // Send timing data to main thread (only in dev mode) + if (!getPackMode()) send({ type: "disk-timings", content: diskTimings }); + } + + send({ type: "render", content: sendData }, transferredObjects); + + sound.sounds.length = 0; // Empty the sound command buffer. + sound.bubbles.length = 0; + sound.kills.length = 0; + + twoDCommands.length = 0; // Empty the 2D GPU command buffer. + + // Flush the `signals` after sending. + if (reframe) reframe = undefined; + if (cursorCode) cursorCode = undefined; + } else { + // Send update (sim). + maybeLeave(); + // TODO: How necessary is this - does any info ever need to actually + // get sent? 23.01.06.16.02 + + // console.log(pixels); + // 🛡️ Create a copy for transfer to avoid detaching the pixels buffer + const transferPixels = pixels ? new Uint8ClampedArray(pixels) : null; + + send( + { + type: "update", + content: { + didntRender: true, + loading, + pixels: transferPixels?.buffer, + width: content.width, + height: content.height, + sound, + }, + }, + [transferPixels?.buffer], + ); + } + + // Wait 8 frames of the default piece before loading the initial piece. + // And also make sure the session has been queried. + // console.log(sessionStarted); + + // 🕷️ SPIDER MODE: Debug why piece isn't loading + if (typeof window !== 'undefined' && window.acSPIDER && paintCount <= 10n) { + console.log("🕷️ SPIDER: Frame check:", { + paintCount: paintCount.toString(), + sessionStarted, + PREVIEW_OR_ICON, + sandboxed: $commonApi.net.sandboxed, + loadAfterPreambleExists: !!loadAfterPreamble + }); + } + + if ( + paintCount > 8n && + (sessionStarted || PREVIEW_OR_ICON || $commonApi.net.sandboxed) + ) { + if (paintCount === 9n) { + const preambleTime = Math.round(performance.now() - diskTimingStart); + diskTimings.preambleComplete = preambleTime; + // Silent: preamble complete + } + if (typeof window !== 'undefined' && window.acSPIDER) { + console.log("🕷️ SPIDER: Calling loadAfterPreamble now!"); + } + loadAfterPreamble?.(); // Start loading after the first disk if necessary. + } + + // 🛡️ Safety net: if session:started was never received (auth failure, network issue, + // or race condition in boot.mjs), force-proceed after ~2 seconds (120 frames at 60fps) + // to prevent the system from being stuck on noise16 forever. + if ( + paintCount > 120n && + !sessionStarted && + !PREVIEW_OR_ICON && + !$commonApi.net.sandboxed && + loadAfterPreamble + ) { + console.warn("⚠️ session:started not received after 120 frames — proceeding without auth"); + sessionStarted = true; + // Try to recover handle from localStorage even without full auth + const cachedHandle = store["handle"]; + if (cachedHandle && !HANDLE) { + HANDLE = "@" + cachedHandle; + if (typeof window !== 'undefined') window.acHANDLE = HANDLE; + if (typeof window !== 'undefined' && window.acBootCanvas?.setHandle) window.acBootCanvas.setHandle(HANDLE); + send({ type: "handle", content: HANDLE }); + console.log(`🔐 Recovered cached handle: ${HANDLE}`); + } + loadAfterPreamble?.(); + } + + // soundClear?.(); + + // ***Frame State Reset*** + // Reset video transcoding / print progress. + + //console.log(performance.now() - frameTime, "ms"); + + //performance.mark("b"); + + //performance.measure("a", "b"); + //console.log("Frame perf:", performance.getEntriesByType("measure")[0].duration); + //performance.clearMarks(); + //performance.clearMeasures(); + } +} + +// 📚 Utilities + +// Get the active user's handle from the server if one exists, updating +// $commonApi.handle +let HANDLE; +let handleRetryInProgress = false; +// TODO: Cache this in localStorage and clear it on log in and log out? +// 24.05.23.22.16 + +async function handle(retryCount = 0) { + const MAX_RETRIES = 3; + const RETRY_DELAYS = [500, 1000, 2000]; // Exponential backoff + + if (USER) { + // Fast path: use handle already fetched by boot.mjs (via /user?withHandle=true) + const bootHandle = USER.handle; + // TODO: Check to see if this is in localStorage or not... + const storedHandle = bootHandle || store["handle"]; // || (await store.retrieve("handle")); + + // console.log("Stored handle...", storedHandle); + + if (storedHandle) { + const newHandle = "@" + storedHandle; + if (HANDLE === newHandle) return; + HANDLE = "@" + storedHandle; + if (USER && !USER.handle) USER.handle = storedHandle; // Sync to user object + if (typeof window !== 'undefined') window.acHANDLE = HANDLE; // Expose for UDP identity + if (typeof window !== 'undefined' && window.acBootCanvas?.setHandle) window.acBootCanvas.setHandle(HANDLE); + send({ type: "handle", content: HANDLE }); + store["handle:received"] = true; + if (bootHandle) store["handle"] = bootHandle; // Cache boot-fetched handle + // console.log("Retrieved handle from store:", storedHandle); + return; // Leave early if a stored handle was found. + } + + try { + log.auth.debug("Fetching handle for:", USER.sub); + const response = await fetch(`/handle?for=${USER.sub}`); + if (response.status === 200) { + const data = await response.json(); + const newHandle = "@" + data.handle; + if (newHandle === HANDLE) return; + HANDLE = newHandle; + if (USER && !USER.handle) USER.handle = data.handle; // Sync to user object + if (typeof window !== 'undefined') window.acHANDLE = HANDLE; // Expose for UDP identity + if (typeof window !== 'undefined' && window.acBootCanvas?.setHandle) window.acBootCanvas.setHandle(HANDLE); + send({ type: "handle", content: HANDLE }); + store["handle:received"] = true; + store["handle"] = data.handle; + store["handle:failed"] = false; // Clear any previous failure flag + // store.persist("handle"); // Maybe this shouldn't persist. + } else { + const errorText = await response.text(); + console.warn("🤚 Handle fetch failed:", response.status, errorText); + + // Retry on 5xx server errors + if (response.status >= 500 && retryCount < MAX_RETRIES && !handleRetryInProgress) { + handleRetryInProgress = true; + const delay = RETRY_DELAYS[retryCount] || 2000; + console.log(`🔄 Retrying handle fetch in ${delay}ms (attempt ${retryCount + 1}/${MAX_RETRIES})...`); + await new Promise(resolve => setTimeout(resolve, delay)); + handleRetryInProgress = false; + return handle(retryCount + 1); + } + + store["handle:failed"] = true; + } + } catch (error) { + console.error("🤚 Handle fetch error:", error); + + // Retry on network errors + if (retryCount < MAX_RETRIES && !handleRetryInProgress) { + handleRetryInProgress = true; + const delay = RETRY_DELAYS[retryCount] || 2000; + console.log(`🔄 Retrying handle fetch in ${delay}ms (attempt ${retryCount + 1}/${MAX_RETRIES})...`); + await new Promise(resolve => setTimeout(resolve, delay)); + handleRetryInProgress = false; + return handle(retryCount + 1); + } + + store["handle:failed"] = true; + } + } +} + +// Tell the `bios` to pull a screenshot of the next frame. +function downloadScreenshot() { + send({ + type: "$creenshot", + content: { + filename: `$creenshot-${num.timestamp()}.png`, + modifiers: { scale: 6 }, + }, + }); +} + +// Run the piece's "leave" function which will trigger +// a new load before sending off the final frame. +function maybeLeave() { + // 🚪 Leave (Skips act and sim and paint...) + if (leaving && leaveLoad) { + try { + leave({ ...painting.api, screen, ...$commonApi }); // Trigger leave. + } catch (e) { + console.warn("👋 Leave failure...", e); + } + leaveLoad(); + leaveLoad = null; + } +} + +// Debug utility for HUD hitbox visualization +globalThis.toggleHudHitboxDebug = () => { + globalThis.debugHudHitbox = !globalThis.debugHudHitbox; + $commonApi.needsPaint(); // Force repaint to show/hide the debug visualization +}; + +// Play a sound when "notice" fires. +const noticeBell = (api, { tone } = { tone: 600 }) => { + api.beep(tone); + + noticeTimer = new gizmo.Hourglass(160, { + completed: () => { + notice = ""; + noticeOpts = null; + noticeTimer = null; + $commonApi.needsPaint(); + }, + }); +}; diff --git a/system/public/aesthetic.computer/systems/nopaint.mjs b/system/public/aesthetic.computer/systems/nopaint.mjs index 0cd23c91ea..b7c604c937 100644 --- a/system/public/aesthetic.computer/systems/nopaint.mjs +++ b/system/public/aesthetic.computer/systems/nopaint.mjs @@ -1,826 +1,835 @@ -// No Paint -// This module contains *most* of the nopaint system template functionality. -// Shared functionality can be found in `disk.mjs`. - -import { generateNopaintHUDLabel, colorizeColorName } from "../lib/color-highlighting.mjs"; -import { isFadeColor } from "../lib/num.mjs"; - -// Utility function to strip color codes from text -function stripColorCodes(str) { - if (!str) return ''; - return str.replace(/\\[^\\]*\\/g, ''); -} - -let state = "idle"; -let previousState = "idle"; // Track state before panning to restore it later - -// 📊 Flash effect for bake events -let bakeFlashTime = 0; -let bakeFlashDuration = 300; // Flash duration in milliseconds -let needsPaintRef = null; // Store reference to needsPaint function -let animationId = null; // Store animation frame ID -const cursor = { x: 0, y: 0 }; - -// Enhanced logging system for debugging touch behavior -let sessionId = null; -let logBuffer = []; -const LOG_BUFFER_SIZE = 100; -let currentStrokePointCount = 0; // Track points in current stroke only - -// Initialize session logging -function initSessionLogging(api) { - if (!sessionId) { - sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - log(api, 'SESSION_START', { sessionId, timestamp: Date.now(), userAgent: navigator.userAgent }); - } -} - -// Enhanced logging function with device-specific file handling -function log(api, event, data = {}) { - const timestamp = Date.now(); - const logEntry = { - timestamp, - sessionId, - event, - data: { ...data } - }; - - // Add to buffer - logBuffer.push(logEntry); - if (logBuffer.length > LOG_BUFFER_SIZE) { - logBuffer.shift(); // Remove oldest entry - } - - // Send to server with device-specific filename - const device = data.device || 'unknown'; - const filename = `${device}_touch_debug.log`; - - try { - api.net.log(`/tmp/dev-logs/${filename}`, JSON.stringify(logEntry)); - } catch (error) { - console.error('Failed to send log:', error); - } -} - -// Used when defining a custom piece functions in a nopaint system brush to -// inherit common behavior. -function nopaint_boot({ - system, - store, - nopaint, - screen, - wipe, - api, - params, - colon, - hud, - num, - painting -}) { - cursor.x = screen.width / 2; - cursor.y = screen.height / 2; - nopaint_adjust(api); - - // Initialize session logging - initSessionLogging(api); - - // Guard: system.painting must exist before nopaint can initialize - if (!system.painting) { - console.warn("🥾 nopaint_boot: system.painting is undefined, skipping nopaint initialization"); - return; - } - - system.nopaint.buffer = painting( - system.painting.width, - system.painting.height, - (p) => { - p.wipe(255, 255, 255, 0); - }, - ); - - // 🎨 CENTRALIZED BRUSH PARAMETER PARSING - if (params && num) { - // Parse color centrally for all brushes - system.nopaint.color = num.parseColor(params); - - // Generate colored HUD label using the full syntax highlighting system - const modifiers = colon && colon.length > 0 ? `:${colon.join(":")}` : ""; - const brushName = api.slug || "brush"; - - // Extract just the piece name (without parameters) for the base - const pieceName = brushName.split('~')[0].split(':')[0]; - - // Use generateNopaintHUDLabel for proper color syntax highlighting - const label = generateNopaintHUDLabel(pieceName, system.nopaint.color, params, modifiers); - - // Completely replace currentHUDTxt directly instead of using hud.label - if (typeof window !== 'undefined' && window.currentHUDTxt !== undefined) { - window.currentHUDTxt = label; - window.currentHUDPlainTxt = stripColorCodes(label); - } else if (hud && hud.label) { - // Fallback to hud.label if direct access isn't available - hud.label(label); - } - } - - system.nopaint.present(api); -} - -function nopaint_is(stateQuery) { - return state === stateQuery; -} - -function nopaint_cancelStroke() { - state = "idle"; -} - -// 📊 Trigger bake flash effect -function nopaint_triggerBakeFlash() { - bakeFlashTime = performance.now(); - - // Cancel any existing animation - if (animationId) { - cancelAnimationFrame(animationId); - } - - // Start continuous animation using requestAnimationFrame - const animateFlash = () => { - const currentTime = performance.now(); - const timeSinceBake = currentTime - bakeFlashTime; - - if (timeSinceBake < bakeFlashDuration) { - // Still flashing, continue animation and trigger needsPaint - if (needsPaintRef) { - needsPaintRef(); - } - animationId = requestAnimationFrame(animateFlash); - } else { - // Flash finished - animationId = null; - } - }; - - // Start the animation - animationId = requestAnimationFrame(animateFlash); -} - -function nopaint_act({ - event: e, - download, - screen, - system, - painting, - loading, - store, - pens, - pen, - api, - num, - jump, - debug, -}) { - // Debug logging: Log ALL events with device info to see what's actually happening - if (e.device && (e.device === "touch" || e.device === "pen" || e.device === "robot" || e.device !== "mouse")) { - log(api, 'RAW_EVENT', { - device: e.device, - eventType: e.type || 'unknown', - touchState: e.is ? `touch:${e.is("touch:1") ? "1" : e.is("touch:2") ? "2" : "none"}` : 'no-is-method', - liftState: e.is ? `lift:${e.is("lift:1") ? "1" : e.is("lift:2") ? "2" : "none"}` : 'no-is-method', - drawState: e.is ? `draw:${e.is("draw:1") ? "1" : "none"}` : 'no-is-method', - rawX: e.x, - rawY: e.y, - currentPaintingState: nopaint_is("painting"), - timestamp: num.timestamp() - }); - } - - // Handle lift events for painting and panning - if (e.is && (e.is("lift:1") || e.is("lift:2"))) { - // (debug logging removed) - } - - // if (e.is("keyboard:down:enter")) { - // download(`painting-${num.timestamp()}.png`, system.painting, { - // scale: 6, - // cropToScreen: true, - // }); - // } - - // 🖌️ Painting - - // Start - // console.log(e); - // TODO: Fix artifacts that occur while touching to draw, after using the - // pen. - - // 🔥 - // TODO: Add each of these to the record if it exists.. - - if (e.is("touch:1")) { - const timestamp = performance.now(); - - state = "painting"; - - system.nopaint.updateBrush(api, "touch"); - - // Reset preserved geometry for new stroke - system.nopaint.finalDragBox = null; - system.nopaint.finalStartDrag = null; - system.nopaint.finalEndPoint = null; - - // Reset stroke point count for new stroke - currentStrokePointCount = 0; - - // Enhanced logging for touch events - log(api, 'TOUCH_START', { - device: e.device, - rawX: e.x, - rawY: e.y, - brushX: system.nopaint.brush.x, - brushY: system.nopaint.brush.y, - state: state, - timestamp: num.timestamp() - }); - - // if (debug) console.log("🖌️ Painting!"); - - // TODO: - // 🔥 Add this to the current gestures, which will be packed - // when `addToRecord` is run. - system.nopaint.gestureRecord.push([ - num.timestamp(), - "touch:1", - system.nopaint.brush.x, - system.nopaint.brush.y, - ]); - } - - // Track - if (nopaint_is("painting") && e.is("draw:1")) { - system.nopaint.updateBrush(api, "draw"); - - const rec = system.nopaint.gestureRecord; - - // Safe check for gesture record to prevent undefined access - const lastRecord = rec.length > 0 ? rec[rec.length - 1] : null; - const shouldAddPoint = !lastRecord || - system.nopaint.brush.x !== lastRecord[2] || - system.nopaint.brush.y !== lastRecord[3]; - - if (shouldAddPoint) { - // Increment stroke-specific point count - currentStrokePointCount++; - - // Enhanced logging for draw events - log(api, 'DRAW_POINT', { - device: e.device, - rawX: e.x, - rawY: e.y, - brushX: system.nopaint.brush.x, - brushY: system.nopaint.brush.y, - state: state, - pointCount: currentStrokePointCount, - timestamp: num.timestamp() - }); - - rec.push([ - num.timestamp(), - "draw:1", - system.nopaint.brush.x, - system.nopaint.brush.y, - ]); - } - } - - // Stop - if ( - nopaint_is("painting") && - e.is("lift:1") && - (e.device === "mouse" || e.device === "pen" || e.device === "touch" || e.device === "robot") - ) { - const timestamp = performance.now(); - - state = "idle"; - - if (!system.nopaint.bakeOnLeave) system.nopaint.needsBake = true; - - // 📊 Trigger needsPaint after lift to ensure HUD updates - api.needsPaint(); - - // Enhanced logging for lift events - log(api, 'TOUCH_END', { - device: e.device, - rawX: e.x, - rawY: e.y, - brushX: system.nopaint.brush.x, - brushY: system.nopaint.brush.y, - previousState: 'painting', - newState: state, - gestureLength: currentStrokePointCount + 1, // +1 to include the final point - timestamp: num.timestamp() - }); - - // if (debug) console.log("🖌️ Not painting..."); - system.nopaint.gestureRecord.push([ - num.timestamp(), - "lift:1", - system.nopaint.brush.x, - system.nopaint.brush.y, - ]); - - // 🎯 PRESERVE coordinates for baking before clearing state - // This fixes the geometry mismatch issue where bake() had no access to dragBox coordinates - - if (system.nopaint.brush && system.nopaint.brush.dragBox) { - system.nopaint.finalDragBox = { - x: system.nopaint.brush.dragBox.x, - y: system.nopaint.brush.dragBox.y, - w: system.nopaint.brush.dragBox.w, - h: system.nopaint.brush.dragBox.h - }; - } - if (system.nopaint.brush) { - system.nopaint.finalEndPoint = { - x: system.nopaint.brush.x, - y: system.nopaint.brush.y - }; - } - if (system.nopaint.startDrag) { - system.nopaint.finalStartDrag = { - x: system.nopaint.startDrag.x, - y: system.nopaint.startDrag.y - }; - } - - // Clear the brush and dragBox state to prevent flickering between gestures - system.nopaint.brush = null; - system.nopaint.startDrag = null; - } - - // 🔭 Zooming... - - if (e.is("move")) { - cursor.x = pen.x; - cursor.y = pen.y; - - // 📊 Only update brush during move if we're actively painting - // This prevents overwriting preserved coordinates after lift - if (nopaint_is("painting")) { - system.nopaint.updateBrush(api, "move"); - } - - // 🚫 ROBOT FIX: Don't call needsPaint() if everything is already complete - // After robot completes, needsBake=false and needsPresent=false, so no need to trigger more painting - // BUT: Also call needsPaint() if needsPresent=true to process pending display updates - if (nopaint_is("painting") || system.nopaint.needsBake || system.nopaint.needsPresent) { - api.needsPaint(); - } - } - - if (e.is("keyboard:down:arrowup")) { - // console.log("Zoom in..."); - system.nopaint.zoom(api, "in", cursor); - system.nopaint.present(api); - // Trigger live preview update if we have an active dragBox (ongoing rect operation) - if (system.nopaint.brush?.dragBox) { - api.needsPaint(); - } - } - - if (e.is("keyboard:down:arrowdown")) { - // console.log("Zoom out..."); - system.nopaint.zoom(api, "out", cursor); - system.nopaint.present(api); - // Trigger live preview update if we have an active dragBox (ongoing rect operation) - if (system.nopaint.brush?.dragBox) { - api.needsPaint(); - } - } - - // 🧭 Panning (held 'shift' key or two finger drag) - - // Start - if ( - !nopaint_is("panning") && - (e.is("keyboard:down:shift") || - ((e.is("touch:2") || e.is("touch:1")) && pens().length === 2)) - ) { - // if (debug) console.log("🧭 Panning!"); - previousState = state; // Preserve current state - state = "panning"; - } - - // Track - if ( - nopaint_is("panning") && - ((e.is("move") && e.device === "mouse") || e.is("draw")) - ) { - system.nopaint.translate(api, e.delta.x, e.delta.y); - - const p = pens(); - if (p.length === 2) { - // console.log(p); // TODO: Set the zoom level here. - } - - system.nopaint.present(api); - // Always trigger needsPaint during panning to ensure visual updates - api.needsPaint(); - } - - // End - if ( - nopaint_is("panning") && - (e.is("keyboard:up:shift") || e.is("lift:2") || e.is("lift:1") || (e.is("keyboard") && !e.shift)) - ) { - // 🚨 SAFEGUARD: If this is a lift event and previousState was "painting", - // we should go to "idle" instead to prevent stuck painting state - if ((e.is("lift:1") || e.is("lift:2")) && previousState === "painting") { - state = "idle"; - } else { - // if (debug) console.log("🧭 Not panning..."); - state = previousState; // Restore previous state - } - - previousState = "idle"; // Reset - system.nopaint.storeTransform(store, system); // Store the translation after completion. - } - - // Reset: By holding `alt` while `shift` (aka meta) is pressed down. - if ( - nopaint_is("panning") && - (e.is("keyboard:down:meta") || e.is("touch:3")) - ) { - state = previousState; // Restore previous state - previousState = "idle"; // Reset - system.nopaint.resetTransform(api); - system.nopaint.present(api); - } - - // Auto-resizing... - if (e.is("reframed")) { - nopaint_adjust(api); - system.nopaint.present(api); - } - - // No and then return to the prompt. - // if (e.is("keyboard:down:n") && !loading) { - // system.nopaint.abort(); - // jump("prompt"); - // } - - // Paint and then go to the prompt, same as default behavior "`". - // if (e.is("keyboard:down:p") && !loading) jump("prompt"); -} - -// 📚 Library -// Adjust painting resolution dynamically to match the screen, -// or provide a custom resolution. -// (Also used in `prompt`.) -function nopaint_adjust( - api, - size = null, - slug = "resize", -) { - const { screen, system: sys, painting, store, dark, theme } = api; - if (!size && store["painting:resolution-lock"] === true) return; - - if ( - !size && - (sys.nopaint.translation.x !== 0 || sys.nopaint.translation.y !== 0) - ) { - return; // Stop auto-resizing if we are panned. - } - - let resizing = false; - - if (!size) { - // Assume we are auto-resizing now. - if (sys.nopaint.undo.paintings.length === 0) { - size = { w: screen.width, h: screen.height }; - } else { - size = { - w: Math.max(screen.width, sys.painting?.width || 0), - h: Math.max(screen.height, sys.painting?.height || 0), - }; - } - - resizing = true; - } - - // TODO: Add auto-resizing back to paintings. - - if (size || !sys.painting) { - // Check to see if size?.w has an x at the end. - let width, height; - if (size.w && size.h) { - // Allow for "2x or 3x" modifiers. - - if (typeof size.w === "string") { - width = size.w.endsWith("x") - ? parseFloat(size.w.slice(0, -1)) * - (sys.painting?.width || screen.width) - : parseInt(size.w); - } else { - width = size.w; // Assume number. - } - - if (typeof size.h === "string") { - height = size.h.endsWith("x") - ? parseFloat(size.h.slice(0, -1)) * - (sys.painting?.height || screen.height) - : parseInt(size.h); - } else { - height = size.h; // Assume number. - } - } else { - width = screen.width; - height = screen.height; - } - - if (isNaN(width) || isNaN(height)) return false; - - // Save and reset pan translation to prevent offset during resize - const savedPan = api.graph?.savepan ? - { x: api.graph.panTranslation?.x || 0, y: api.graph.panTranslation?.y || 0 } : null; - if (api.graph?.unpan) api.graph.unpan(); - - sys.painting = painting(width, height, (p) => { - if (size?.scale) { - p.paste(sys.painting, 0, 0, { width, height }); - } else { - // TODO: Put in some custom noise function here... - // Detect if we are in light or dark mode... - // $common - p.wipe(theme[dark ? "dark" : "light"].wipeNum); - - // Only paste the old painting if we're resizing, not creating a new one - // Check if slug indicates this is a "new" operation (which should start fresh) - const isNewPainting = slug && (slug === "new" || slug.startsWith("new~")); - - if (!isNewPainting && sys.painting) { - p.paste(sys.painting); - } - } - }); - - store["painting"] = { - width: sys.painting.width, - height: sys.painting.height, - pixels: sys.painting.pixels, - }; // sys.painting; - - if (!resizing) sys.nopaint.addUndoPainting(sys.painting, slug); - } - - // Set a flag to prevent auto-resize. - if (size && !resizing) { - store["painting:resolution-lock"] = true; - store.persist("painting:resolution-lock", "local:db"); - store.persist("painting", "local:db"); // Also persist the painting. - - // 🪝 Frame-based monitoring will handle resize broadcast with storage completion hooks - // No direct broadcast needed - the frame monitoring system will detect dimension changes - // and use the proper storage completion sequence for cross-tab sync - - sys.nopaint.resetTransform({ system: sys, screen }); // Reset transform. - sys.nopaint.storeTransform(store, sys); - } - return true; -} - -// 🔍 Debug overlay function to be called during paint phase -function nopaint_paint({ - screen, - ink, - write, - line, - circle, - box, - system, - needsPaint -}) { - // Store reference to needsPaint for flash animations - needsPaintRef = needsPaint; - - // Ensure needsPresent is set to trigger display updates - if (system?.nopaint) { - system.nopaint.needsPresent = true; - } - - // 📊 ALWAYS return true to ensure continuous painting for performance HUD - return true; -} - -// 📊 Performance HUD for nopaint system pen state debugging -function nopaint_renderPerfHUD({ - ink, - write, - box, - screen, - pen, - system, - nopaintPerf -}) { - if (!nopaintPerf || !ink || !write || !box) return; - - // 🧹 CRITICAL: Reset fade mode by calling ink with a non-fade string - // This clears the global fadeMode, fadeColors, fadeDirection variables - ink("black"); // Forces findColor to reset fade state completely - // NOTE: Don't clear fade alpha here - it might be needed by other systems like neobrush - // Fade system no longer uses global state - - // 🎨 Trigger next frame for smooth animation - if (needsPaintRef) { - needsPaintRef(); - } - - // 📊 Console log current state for debugging - // console.log("🟢 CURRENT STATE:", state, "| nopaint_is('painting'):", nopaint_is("painting")); - - const hudWidth = 90; // Reduced from 120 to make narrower - const hudHeight = 80; - const x = screen.width - hudWidth - 2; - const y = 2; - - // 📊 Check if we should flash (bake effect) - const currentTime = performance.now(); - const timeSinceBake = currentTime - bakeFlashTime; - const isFlashing = timeSinceBake < bakeFlashDuration; - - // Semi-transparent background (flash bright when baking) - if (isFlashing) { - // Flash bright white/yellow during bake - const flashIntensity = Math.max(0, 1 - (timeSinceBake / bakeFlashDuration)); // 1.0 to 0.0 - const flashAlpha = Math.floor(100 + (flashIntensity * 155)); // 100 to 255 - ink(255, 255, 200, flashAlpha); - } else { - ink(0, 0, 0, 200); - } - box(x - 1, y - 1, hudWidth + 2, hudHeight + 2, "fill"); - - // Border (flash bright during bake) - if (isFlashing) { - const flashIntensity = Math.max(0, 1 - (timeSinceBake / bakeFlashDuration)); - const flashColor = Math.floor(100 + (flashIntensity * 155)); // 100 to 255 - ink(flashColor, 255, flashColor, 255); - } else { - ink(100, 255, 100, 255); - } - box(x - 1, y - 1, hudWidth + 2, hudHeight + 2, "outline"); - - // Pen state data - let lineY = y + 1; - const lineHeight = 8; - - // Title - ink(255, 255, 255); - write("NOPAINT PERF", { x, y: lineY }, undefined, undefined, false, "MatrixChunky8"); - lineY += lineHeight + 2; - - if (pen) { - // Pen coordinates - ink(100, 200, 255); - const penX = (typeof pen.x === 'number') ? pen.x : 0; - const penY = (typeof pen.y === 'number') ? pen.y : 0; - write(`X:${penX.toFixed(1)}`, { x, y: lineY }, undefined, undefined, false, "MatrixChunky8"); - lineY += lineHeight; - - ink(100, 200, 255); - write(`Y:${penY.toFixed(1)}`, { x, y: lineY }, undefined, undefined, false, "MatrixChunky8"); - lineY += lineHeight; - - // Drag box info if available - if (pen.dragBox && typeof pen.dragBox.x === 'number' && typeof pen.dragBox.y === 'number') { - ink(255, 100, 200); - write(`DRG X:${pen.dragBox.x.toFixed(1)}`, { x, y: lineY }, undefined, undefined, false, "MatrixChunky8"); - lineY += lineHeight; - - write(`DRG Y:${pen.dragBox.y.toFixed(1)}`, { x, y: lineY }, undefined, undefined, false, "MatrixChunky8"); - lineY += lineHeight; - - if (typeof pen.dragBox.w === 'number') { - write(`DRG W:${pen.dragBox.w.toFixed(1)}`, { x, y: lineY }, undefined, undefined, false, "MatrixChunky8"); - lineY += lineHeight; - } - - if (typeof pen.dragBox.h === 'number') { - write(`DRG H:${pen.dragBox.h.toFixed(1)}`, { x, y: lineY }, undefined, undefined, false, "MatrixChunky8"); - lineY += lineHeight; - } - } - - // Additional pen state - if (pen.pressure !== undefined && typeof pen.pressure === 'number') { - ink(150, 150, 255); - write(`PRESS:${pen.pressure.toFixed(2)}`, { x, y: lineY }, undefined, undefined, false, "MatrixChunky8"); - lineY += lineHeight; - } - } else { - ink(255, 0, 0); - write("NO PEN DATA", { x, y: lineY }, undefined, undefined, false, "MatrixChunky8"); - } - - // Nopaint system state if available - if (system?.nopaint) { - // Clean state display - the debugging proved the state management works correctly - ink(255, 255, 0); - write(`STATE:${state}`, { x, y: lineY }, undefined, undefined, false, "MatrixChunky8"); - lineY += lineHeight; - - ink(200, 200, 0); - write(`PREV:${previousState}`, { x, y: lineY }, undefined, undefined, false, "MatrixChunky8"); - lineY += lineHeight; - - // Show current painting status with clear color coding - const isPainting = nopaint_is("painting"); - ink(isPainting ? [0, 255, 0] : [255, 0, 0]); - write(`PAINT:${isPainting ? "YES" : "NO"}`, { x, y: lineY }, undefined, undefined, false, "MatrixChunky8"); - lineY += lineHeight; - - // Show timestamp for reference - const now = performance.now(); - ink(128, 128, 128); - write(`TIME:${Math.floor(now % 10000)}`, { x, y: lineY }, undefined, undefined, false, "MatrixChunky8"); - } -} - -// Helper function for nopaint brushes to generate colored HUD labels -function nopaint_generateColoredLabel(brushName, colorParams, rawParams, modifiers = "", api) { - if (!api || !api.hud || !api.hud.label) return; - - const coloredLabel = generateNopaintHUDLabel(brushName, colorParams, rawParams, modifiers); - api.hud.label(coloredLabel); -} - -// Central color handling function for nopaint brushes -// Automatically manages fade alpha, color processing, and HUD labels -function nopaint_handleColor(color, ink, brushName, params, modifiers = "", api = null) { - // Generate colored HUD label if API is provided - if (api && api.hud) { - const coloredLabel = generateNopaintHUDLabel(brushName, color, params, modifiers); - api.hud.label(coloredLabel); - } - - if (isFadeColor(color)) { - // Fade colors now handle alpha automatically via totalizing alpha - return ink(color); - } else { - // Regular color - just use it directly - return ink(color); - } -} - -// Cleanup function for when brush operations are complete -function nopaint_cleanupColor() { - // No longer needed with new fade system -} - -// 🎨 Centralized brush parameter parsing for consistent behavior across all brushes -function nopaint_parseBrushParams({ params, num, colon }) { - // Store original params for HUD labeling - const originalParams = params; - const modifiers = colon.length > 0 ? `:${colon.join(":")}` : ""; - - // Parse color using num.parseColor - const color = num.parseColor(params); - - // Parse mode and thickness from colon parameters - let mode = "fill"; // default - let thickness = 1; // default - let centered = false; - - if (colon.length > 0) { - const modeParam = colon[0]; - - // Check for centered flag - if (modeParam.includes("c")) { - centered = true; - } - - // Parse mode (o=outline, i=inline, f=fill) - if (modeParam.includes("o")) { - mode = "outline"; - // Extract thickness for outline mode - const thickMatch = modeParam.match(/o-?(\d+)/); - if (thickMatch) thickness = parseInt(thickMatch[1]); - } else if (modeParam.includes("i")) { - mode = "inline"; - // Extract thickness for inline mode - const thickMatch = modeParam.match(/i-?(\d+)/); - if (thickMatch) thickness = parseInt(thickMatch[1]); - } else if (modeParam.includes("f")) { - mode = "fill"; - } - } - - return { - color, - mode, - thickness, - centered, - originalParams, - modifiers - }; -} - -export { nopaint_boot, nopaint_act, nopaint_paint, nopaint_is, nopaint_cancelStroke, nopaint_adjust, nopaint_generateColoredLabel, nopaint_handleColor, nopaint_cleanupColor, nopaint_parseBrushParams, nopaint_renderPerfHUD, nopaint_triggerBakeFlash }; +// No Paint +// This module contains *most* of the nopaint system template functionality. +// Shared functionality can be found in `disk.mjs`. + +import { generateNopaintHUDLabel, colorizeColorName } from "../lib/color-highlighting.mjs"; +import { isFadeColor } from "../lib/num.mjs"; + +// Utility function to strip color codes from text +function stripColorCodes(str) { + if (!str) return ''; + return str.replace(/\\[^\\]*\\/g, ''); +} + +let state = "idle"; +let previousState = "idle"; // Track state before panning to restore it later + +// 📊 Flash effect for bake events +let bakeFlashTime = 0; +let bakeFlashDuration = 300; // Flash duration in milliseconds +let needsPaintRef = null; // Store reference to needsPaint function +let animationId = null; // Store animation frame ID +const cursor = { x: 0, y: 0 }; + +// Enhanced logging system for debugging touch behavior +let sessionId = null; +let logBuffer = []; +const LOG_BUFFER_SIZE = 100; +let currentStrokePointCount = 0; // Track points in current stroke only + +// Initialize session logging +function initSessionLogging(api) { + if (!sessionId) { + sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + log(api, 'SESSION_START', { sessionId, timestamp: Date.now(), userAgent: navigator.userAgent }); + } +} + +// Enhanced logging function with device-specific file handling +function log(api, event, data = {}) { + const timestamp = Date.now(); + const logEntry = { + timestamp, + sessionId, + event, + data: { ...data } + }; + + // Add to buffer + logBuffer.push(logEntry); + if (logBuffer.length > LOG_BUFFER_SIZE) { + logBuffer.shift(); // Remove oldest entry + } + + // Send to server with device-specific filename + const device = data.device || 'unknown'; + const filename = `${device}_touch_debug.log`; + + try { + api.net.log(`/tmp/dev-logs/${filename}`, JSON.stringify(logEntry)); + } catch (error) { + console.error('Failed to send log:', error); + } +} + +// Used when defining a custom piece functions in a nopaint system brush to +// inherit common behavior. +function nopaint_boot({ + system, + store, + nopaint, + screen, + wipe, + api, + params, + colon, + hud, + num, + painting +}) { + cursor.x = screen.width / 2; + cursor.y = screen.height / 2; + nopaint_adjust(api); + + // Initialize session logging + initSessionLogging(api); + + // Guard: system.painting must exist before nopaint can initialize + if (!system.painting) { + console.warn("🥾 nopaint_boot: system.painting is undefined, skipping nopaint initialization"); + return; + } + + system.nopaint.buffer = painting( + system.painting.width, + system.painting.height, + (p) => { + p.wipe(255, 255, 255, 0); + }, + ); + + // 🎨 CENTRALIZED BRUSH PARAMETER PARSING + if (params && num) { + // Parse color centrally for all brushes + system.nopaint.color = num.parseColor(params); + + // Generate colored HUD label using the full syntax highlighting system + const modifiers = colon && colon.length > 0 ? `:${colon.join(":")}` : ""; + const brushName = api.slug || "brush"; + + // Extract just the piece name (without parameters) for the base + const pieceName = brushName.split('~')[0].split(':')[0]; + + // Use generateNopaintHUDLabel for proper color syntax highlighting + const label = generateNopaintHUDLabel(pieceName, system.nopaint.color, params, modifiers); + + // Completely replace currentHUDTxt directly instead of using hud.label + if (typeof window !== 'undefined' && window.currentHUDTxt !== undefined) { + window.currentHUDTxt = label; + window.currentHUDPlainTxt = stripColorCodes(label); + } else if (hud && hud.label) { + // Fallback to hud.label if direct access isn't available + hud.label(label); + } + } + + system.nopaint.present(api); +} + +function nopaint_is(stateQuery) { + return state === stateQuery; +} + +function nopaint_cancelStroke() { + state = "idle"; +} + +// 📊 Trigger bake flash effect +function nopaint_triggerBakeFlash() { + bakeFlashTime = performance.now(); + + // Cancel any existing animation + if (animationId) { + cancelAnimationFrame(animationId); + } + + // Start continuous animation using requestAnimationFrame + const animateFlash = () => { + const currentTime = performance.now(); + const timeSinceBake = currentTime - bakeFlashTime; + + if (timeSinceBake < bakeFlashDuration) { + // Still flashing, continue animation and trigger needsPaint + if (needsPaintRef) { + needsPaintRef(); + } + animationId = requestAnimationFrame(animateFlash); + } else { + // Flash finished + animationId = null; + } + }; + + // Start the animation + animationId = requestAnimationFrame(animateFlash); +} + +function nopaint_act({ + event: e, + download, + screen, + system, + painting, + loading, + store, + pens, + pen, + api, + num, + jump, + debug, +}) { + // 🤖 Robot lock: when a robot is driving the nopaint system, ignore all + // non-robot pen events so the hardware mouse can't interfere with the + // synthetic gesture. + if (system?.nopaint?.robotActive && e?.device && e.device !== "robot") { + const penEventNames = ["touch", "lift", "draw", "move"]; + const isPenEvent = penEventNames.some((name) => e.is?.(name) || e.is?.(`${name}:1`) || e.is?.(`${name}:2`)); + if (isPenEvent) return; + } + + // Debug logging: Log ALL events with device info to see what's actually happening + if (e.device && (e.device === "touch" || e.device === "pen" || e.device === "robot" || e.device !== "mouse")) { + log(api, 'RAW_EVENT', { + device: e.device, + eventType: e.type || 'unknown', + touchState: e.is ? `touch:${e.is("touch:1") ? "1" : e.is("touch:2") ? "2" : "none"}` : 'no-is-method', + liftState: e.is ? `lift:${e.is("lift:1") ? "1" : e.is("lift:2") ? "2" : "none"}` : 'no-is-method', + drawState: e.is ? `draw:${e.is("draw:1") ? "1" : "none"}` : 'no-is-method', + rawX: e.x, + rawY: e.y, + currentPaintingState: nopaint_is("painting"), + timestamp: num.timestamp() + }); + } + + // Handle lift events for painting and panning + if (e.is && (e.is("lift:1") || e.is("lift:2"))) { + // (debug logging removed) + } + + // if (e.is("keyboard:down:enter")) { + // download(`painting-${num.timestamp()}.png`, system.painting, { + // scale: 6, + // cropToScreen: true, + // }); + // } + + // 🖌️ Painting + + // Start + // console.log(e); + // TODO: Fix artifacts that occur while touching to draw, after using the + // pen. + + // 🔥 + // TODO: Add each of these to the record if it exists.. + + if (e.is("touch:1")) { + const timestamp = performance.now(); + + state = "painting"; + + system.nopaint.updateBrush(api, "touch"); + + // Reset preserved geometry for new stroke + system.nopaint.finalDragBox = null; + system.nopaint.finalStartDrag = null; + system.nopaint.finalEndPoint = null; + + // Reset stroke point count for new stroke + currentStrokePointCount = 0; + + // Enhanced logging for touch events + log(api, 'TOUCH_START', { + device: e.device, + rawX: e.x, + rawY: e.y, + brushX: system.nopaint.brush.x, + brushY: system.nopaint.brush.y, + state: state, + timestamp: num.timestamp() + }); + + // if (debug) console.log("🖌️ Painting!"); + + // TODO: + // 🔥 Add this to the current gestures, which will be packed + // when `addToRecord` is run. + system.nopaint.gestureRecord.push([ + num.timestamp(), + "touch:1", + system.nopaint.brush.x, + system.nopaint.brush.y, + ]); + } + + // Track + if (nopaint_is("painting") && e.is("draw:1")) { + system.nopaint.updateBrush(api, "draw"); + + const rec = system.nopaint.gestureRecord; + + // Safe check for gesture record to prevent undefined access + const lastRecord = rec.length > 0 ? rec[rec.length - 1] : null; + const shouldAddPoint = !lastRecord || + system.nopaint.brush.x !== lastRecord[2] || + system.nopaint.brush.y !== lastRecord[3]; + + if (shouldAddPoint) { + // Increment stroke-specific point count + currentStrokePointCount++; + + // Enhanced logging for draw events + log(api, 'DRAW_POINT', { + device: e.device, + rawX: e.x, + rawY: e.y, + brushX: system.nopaint.brush.x, + brushY: system.nopaint.brush.y, + state: state, + pointCount: currentStrokePointCount, + timestamp: num.timestamp() + }); + + rec.push([ + num.timestamp(), + "draw:1", + system.nopaint.brush.x, + system.nopaint.brush.y, + ]); + } + } + + // Stop + if ( + nopaint_is("painting") && + e.is("lift:1") && + (e.device === "mouse" || e.device === "pen" || e.device === "touch" || e.device === "robot") + ) { + const timestamp = performance.now(); + + state = "idle"; + + if (!system.nopaint.bakeOnLeave) system.nopaint.needsBake = true; + + // 📊 Trigger needsPaint after lift to ensure HUD updates + api.needsPaint(); + + // Enhanced logging for lift events + log(api, 'TOUCH_END', { + device: e.device, + rawX: e.x, + rawY: e.y, + brushX: system.nopaint.brush.x, + brushY: system.nopaint.brush.y, + previousState: 'painting', + newState: state, + gestureLength: currentStrokePointCount + 1, // +1 to include the final point + timestamp: num.timestamp() + }); + + // if (debug) console.log("🖌️ Not painting..."); + system.nopaint.gestureRecord.push([ + num.timestamp(), + "lift:1", + system.nopaint.brush.x, + system.nopaint.brush.y, + ]); + + // 🎯 PRESERVE coordinates for baking before clearing state + // This fixes the geometry mismatch issue where bake() had no access to dragBox coordinates + + if (system.nopaint.brush && system.nopaint.brush.dragBox) { + system.nopaint.finalDragBox = { + x: system.nopaint.brush.dragBox.x, + y: system.nopaint.brush.dragBox.y, + w: system.nopaint.brush.dragBox.w, + h: system.nopaint.brush.dragBox.h + }; + } + if (system.nopaint.brush) { + system.nopaint.finalEndPoint = { + x: system.nopaint.brush.x, + y: system.nopaint.brush.y + }; + } + if (system.nopaint.startDrag) { + system.nopaint.finalStartDrag = { + x: system.nopaint.startDrag.x, + y: system.nopaint.startDrag.y + }; + } + + // Clear the brush and dragBox state to prevent flickering between gestures + system.nopaint.brush = null; + system.nopaint.startDrag = null; + } + + // 🔭 Zooming... + + if (e.is("move")) { + cursor.x = pen.x; + cursor.y = pen.y; + + // 📊 Only update brush during move if we're actively painting + // This prevents overwriting preserved coordinates after lift + if (nopaint_is("painting")) { + system.nopaint.updateBrush(api, "move"); + } + + // 🚫 ROBOT FIX: Don't call needsPaint() if everything is already complete + // After robot completes, needsBake=false and needsPresent=false, so no need to trigger more painting + // BUT: Also call needsPaint() if needsPresent=true to process pending display updates + if (nopaint_is("painting") || system.nopaint.needsBake || system.nopaint.needsPresent) { + api.needsPaint(); + } + } + + if (e.is("keyboard:down:arrowup")) { + // console.log("Zoom in..."); + system.nopaint.zoom(api, "in", cursor); + system.nopaint.present(api); + // Trigger live preview update if we have an active dragBox (ongoing rect operation) + if (system.nopaint.brush?.dragBox) { + api.needsPaint(); + } + } + + if (e.is("keyboard:down:arrowdown")) { + // console.log("Zoom out..."); + system.nopaint.zoom(api, "out", cursor); + system.nopaint.present(api); + // Trigger live preview update if we have an active dragBox (ongoing rect operation) + if (system.nopaint.brush?.dragBox) { + api.needsPaint(); + } + } + + // 🧭 Panning (held 'shift' key or two finger drag) + + // Start + if ( + !nopaint_is("panning") && + (e.is("keyboard:down:shift") || + ((e.is("touch:2") || e.is("touch:1")) && pens().length === 2)) + ) { + // if (debug) console.log("🧭 Panning!"); + previousState = state; // Preserve current state + state = "panning"; + } + + // Track + if ( + nopaint_is("panning") && + ((e.is("move") && e.device === "mouse") || e.is("draw")) + ) { + system.nopaint.translate(api, e.delta.x, e.delta.y); + + const p = pens(); + if (p.length === 2) { + // console.log(p); // TODO: Set the zoom level here. + } + + system.nopaint.present(api); + // Always trigger needsPaint during panning to ensure visual updates + api.needsPaint(); + } + + // End + if ( + nopaint_is("panning") && + (e.is("keyboard:up:shift") || e.is("lift:2") || e.is("lift:1") || (e.is("keyboard") && !e.shift)) + ) { + // 🚨 SAFEGUARD: If this is a lift event and previousState was "painting", + // we should go to "idle" instead to prevent stuck painting state + if ((e.is("lift:1") || e.is("lift:2")) && previousState === "painting") { + state = "idle"; + } else { + // if (debug) console.log("🧭 Not panning..."); + state = previousState; // Restore previous state + } + + previousState = "idle"; // Reset + system.nopaint.storeTransform(store, system); // Store the translation after completion. + } + + // Reset: By holding `alt` while `shift` (aka meta) is pressed down. + if ( + nopaint_is("panning") && + (e.is("keyboard:down:meta") || e.is("touch:3")) + ) { + state = previousState; // Restore previous state + previousState = "idle"; // Reset + system.nopaint.resetTransform(api); + system.nopaint.present(api); + } + + // Auto-resizing... + if (e.is("reframed")) { + nopaint_adjust(api); + system.nopaint.present(api); + } + + // No and then return to the prompt. + // if (e.is("keyboard:down:n") && !loading) { + // system.nopaint.abort(); + // jump("prompt"); + // } + + // Paint and then go to the prompt, same as default behavior "`". + // if (e.is("keyboard:down:p") && !loading) jump("prompt"); +} + +// 📚 Library +// Adjust painting resolution dynamically to match the screen, +// or provide a custom resolution. +// (Also used in `prompt`.) +function nopaint_adjust( + api, + size = null, + slug = "resize", +) { + const { screen, system: sys, painting, store, dark, theme } = api; + if (!size && store["painting:resolution-lock"] === true) return; + + if ( + !size && + (sys.nopaint.translation.x !== 0 || sys.nopaint.translation.y !== 0) + ) { + return; // Stop auto-resizing if we are panned. + } + + let resizing = false; + + if (!size) { + // Assume we are auto-resizing now. + if (sys.nopaint.undo.paintings.length === 0) { + size = { w: screen.width, h: screen.height }; + } else { + size = { + w: Math.max(screen.width, sys.painting?.width || 0), + h: Math.max(screen.height, sys.painting?.height || 0), + }; + } + + resizing = true; + } + + // TODO: Add auto-resizing back to paintings. + + if (size || !sys.painting) { + // Check to see if size?.w has an x at the end. + let width, height; + if (size.w && size.h) { + // Allow for "2x or 3x" modifiers. + + if (typeof size.w === "string") { + width = size.w.endsWith("x") + ? parseFloat(size.w.slice(0, -1)) * + (sys.painting?.width || screen.width) + : parseInt(size.w); + } else { + width = size.w; // Assume number. + } + + if (typeof size.h === "string") { + height = size.h.endsWith("x") + ? parseFloat(size.h.slice(0, -1)) * + (sys.painting?.height || screen.height) + : parseInt(size.h); + } else { + height = size.h; // Assume number. + } + } else { + width = screen.width; + height = screen.height; + } + + if (isNaN(width) || isNaN(height)) return false; + + // Save and reset pan translation to prevent offset during resize + const savedPan = api.graph?.savepan ? + { x: api.graph.panTranslation?.x || 0, y: api.graph.panTranslation?.y || 0 } : null; + if (api.graph?.unpan) api.graph.unpan(); + + sys.painting = painting(width, height, (p) => { + if (size?.scale) { + p.paste(sys.painting, 0, 0, { width, height }); + } else { + // TODO: Put in some custom noise function here... + // Detect if we are in light or dark mode... + // $common + p.wipe(theme[dark ? "dark" : "light"].wipeNum); + + // Only paste the old painting if we're resizing, not creating a new one + // Check if slug indicates this is a "new" operation (which should start fresh) + const isNewPainting = slug && (slug === "new" || slug.startsWith("new~")); + + if (!isNewPainting && sys.painting) { + p.paste(sys.painting); + } + } + }); + + store["painting"] = { + width: sys.painting.width, + height: sys.painting.height, + pixels: sys.painting.pixels, + }; // sys.painting; + + if (!resizing) sys.nopaint.addUndoPainting(sys.painting, slug); + } + + // Set a flag to prevent auto-resize. + if (size && !resizing) { + store["painting:resolution-lock"] = true; + store.persist("painting:resolution-lock", "local:db"); + store.persist("painting", "local:db"); // Also persist the painting. + + // 🪝 Frame-based monitoring will handle resize broadcast with storage completion hooks + // No direct broadcast needed - the frame monitoring system will detect dimension changes + // and use the proper storage completion sequence for cross-tab sync + + sys.nopaint.resetTransform({ system: sys, screen }); // Reset transform. + sys.nopaint.storeTransform(store, sys); + } + return true; +} + +// 🔍 Debug overlay function to be called during paint phase +function nopaint_paint({ + screen, + ink, + write, + line, + circle, + box, + system, + needsPaint +}) { + // Store reference to needsPaint for flash animations + needsPaintRef = needsPaint; + + // Ensure needsPresent is set to trigger display updates + if (system?.nopaint) { + system.nopaint.needsPresent = true; + } + + // 📊 ALWAYS return true to ensure continuous painting for performance HUD + return true; +} + +// 📊 Performance HUD for nopaint system pen state debugging +function nopaint_renderPerfHUD({ + ink, + write, + box, + screen, + pen, + system, + nopaintPerf +}) { + if (!nopaintPerf || !ink || !write || !box) return; + + // 🧹 CRITICAL: Reset fade mode by calling ink with a non-fade string + // This clears the global fadeMode, fadeColors, fadeDirection variables + ink("black"); // Forces findColor to reset fade state completely + // NOTE: Don't clear fade alpha here - it might be needed by other systems like neobrush + // Fade system no longer uses global state + + // 🎨 Trigger next frame for smooth animation + if (needsPaintRef) { + needsPaintRef(); + } + + // 📊 Console log current state for debugging + // console.log("🟢 CURRENT STATE:", state, "| nopaint_is('painting'):", nopaint_is("painting")); + + const hudWidth = 90; // Reduced from 120 to make narrower + const hudHeight = 80; + const x = screen.width - hudWidth - 2; + const y = 2; + + // 📊 Check if we should flash (bake effect) + const currentTime = performance.now(); + const timeSinceBake = currentTime - bakeFlashTime; + const isFlashing = timeSinceBake < bakeFlashDuration; + + // Semi-transparent background (flash bright when baking) + if (isFlashing) { + // Flash bright white/yellow during bake + const flashIntensity = Math.max(0, 1 - (timeSinceBake / bakeFlashDuration)); // 1.0 to 0.0 + const flashAlpha = Math.floor(100 + (flashIntensity * 155)); // 100 to 255 + ink(255, 255, 200, flashAlpha); + } else { + ink(0, 0, 0, 200); + } + box(x - 1, y - 1, hudWidth + 2, hudHeight + 2, "fill"); + + // Border (flash bright during bake) + if (isFlashing) { + const flashIntensity = Math.max(0, 1 - (timeSinceBake / bakeFlashDuration)); + const flashColor = Math.floor(100 + (flashIntensity * 155)); // 100 to 255 + ink(flashColor, 255, flashColor, 255); + } else { + ink(100, 255, 100, 255); + } + box(x - 1, y - 1, hudWidth + 2, hudHeight + 2, "outline"); + + // Pen state data + let lineY = y + 1; + const lineHeight = 8; + + // Title + ink(255, 255, 255); + write("NOPAINT PERF", { x, y: lineY }, undefined, undefined, false, "MatrixChunky8"); + lineY += lineHeight + 2; + + if (pen) { + // Pen coordinates + ink(100, 200, 255); + const penX = (typeof pen.x === 'number') ? pen.x : 0; + const penY = (typeof pen.y === 'number') ? pen.y : 0; + write(`X:${penX.toFixed(1)}`, { x, y: lineY }, undefined, undefined, false, "MatrixChunky8"); + lineY += lineHeight; + + ink(100, 200, 255); + write(`Y:${penY.toFixed(1)}`, { x, y: lineY }, undefined, undefined, false, "MatrixChunky8"); + lineY += lineHeight; + + // Drag box info if available + if (pen.dragBox && typeof pen.dragBox.x === 'number' && typeof pen.dragBox.y === 'number') { + ink(255, 100, 200); + write(`DRG X:${pen.dragBox.x.toFixed(1)}`, { x, y: lineY }, undefined, undefined, false, "MatrixChunky8"); + lineY += lineHeight; + + write(`DRG Y:${pen.dragBox.y.toFixed(1)}`, { x, y: lineY }, undefined, undefined, false, "MatrixChunky8"); + lineY += lineHeight; + + if (typeof pen.dragBox.w === 'number') { + write(`DRG W:${pen.dragBox.w.toFixed(1)}`, { x, y: lineY }, undefined, undefined, false, "MatrixChunky8"); + lineY += lineHeight; + } + + if (typeof pen.dragBox.h === 'number') { + write(`DRG H:${pen.dragBox.h.toFixed(1)}`, { x, y: lineY }, undefined, undefined, false, "MatrixChunky8"); + lineY += lineHeight; + } + } + + // Additional pen state + if (pen.pressure !== undefined && typeof pen.pressure === 'number') { + ink(150, 150, 255); + write(`PRESS:${pen.pressure.toFixed(2)}`, { x, y: lineY }, undefined, undefined, false, "MatrixChunky8"); + lineY += lineHeight; + } + } else { + ink(255, 0, 0); + write("NO PEN DATA", { x, y: lineY }, undefined, undefined, false, "MatrixChunky8"); + } + + // Nopaint system state if available + if (system?.nopaint) { + // Clean state display - the debugging proved the state management works correctly + ink(255, 255, 0); + write(`STATE:${state}`, { x, y: lineY }, undefined, undefined, false, "MatrixChunky8"); + lineY += lineHeight; + + ink(200, 200, 0); + write(`PREV:${previousState}`, { x, y: lineY }, undefined, undefined, false, "MatrixChunky8"); + lineY += lineHeight; + + // Show current painting status with clear color coding + const isPainting = nopaint_is("painting"); + ink(isPainting ? [0, 255, 0] : [255, 0, 0]); + write(`PAINT:${isPainting ? "YES" : "NO"}`, { x, y: lineY }, undefined, undefined, false, "MatrixChunky8"); + lineY += lineHeight; + + // Show timestamp for reference + const now = performance.now(); + ink(128, 128, 128); + write(`TIME:${Math.floor(now % 10000)}`, { x, y: lineY }, undefined, undefined, false, "MatrixChunky8"); + } +} + +// Helper function for nopaint brushes to generate colored HUD labels +function nopaint_generateColoredLabel(brushName, colorParams, rawParams, modifiers = "", api) { + if (!api || !api.hud || !api.hud.label) return; + + const coloredLabel = generateNopaintHUDLabel(brushName, colorParams, rawParams, modifiers); + api.hud.label(coloredLabel); +} + +// Central color handling function for nopaint brushes +// Automatically manages fade alpha, color processing, and HUD labels +function nopaint_handleColor(color, ink, brushName, params, modifiers = "", api = null) { + // Generate colored HUD label if API is provided + if (api && api.hud) { + const coloredLabel = generateNopaintHUDLabel(brushName, color, params, modifiers); + api.hud.label(coloredLabel); + } + + if (isFadeColor(color)) { + // Fade colors now handle alpha automatically via totalizing alpha + return ink(color); + } else { + // Regular color - just use it directly + return ink(color); + } +} + +// Cleanup function for when brush operations are complete +function nopaint_cleanupColor() { + // No longer needed with new fade system +} + +// 🎨 Centralized brush parameter parsing for consistent behavior across all brushes +function nopaint_parseBrushParams({ params, num, colon }) { + // Store original params for HUD labeling + const originalParams = params; + const modifiers = colon.length > 0 ? `:${colon.join(":")}` : ""; + + // Parse color using num.parseColor + const color = num.parseColor(params); + + // Parse mode and thickness from colon parameters + let mode = "fill"; // default + let thickness = 1; // default + let centered = false; + + if (colon.length > 0) { + const modeParam = colon[0]; + + // Check for centered flag + if (modeParam.includes("c")) { + centered = true; + } + + // Parse mode (o=outline, i=inline, f=fill) + if (modeParam.includes("o")) { + mode = "outline"; + // Extract thickness for outline mode + const thickMatch = modeParam.match(/o-?(\d+)/); + if (thickMatch) thickness = parseInt(thickMatch[1]); + } else if (modeParam.includes("i")) { + mode = "inline"; + // Extract thickness for inline mode + const thickMatch = modeParam.match(/i-?(\d+)/); + if (thickMatch) thickness = parseInt(thickMatch[1]); + } else if (modeParam.includes("f")) { + mode = "fill"; + } + } + + return { + color, + mode, + thickness, + centered, + originalParams, + modifiers + }; +} + +export { nopaint_boot, nopaint_act, nopaint_paint, nopaint_is, nopaint_cancelStroke, nopaint_adjust, nopaint_generateColoredLabel, nopaint_handleColor, nopaint_cleanupColor, nopaint_parseBrushParams, nopaint_renderPerfHUD, nopaint_triggerBakeFlash }; diff --git a/system/public/builds.false.work/index.html b/system/public/builds.false.work/index.html index 8398845073..e331ed011a 100644 --- a/system/public/builds.false.work/index.html +++ b/system/public/builds.false.work/index.html @@ -190,6 +190,7 @@ .platform-windows { color: var(--platform-windows); border-color: var(--platform-windows); } .platform-mac { color: var(--platform-mac); border-color: var(--platform-mac); } .platform-ios { color: var(--platform-ios); border-color: var(--platform-ios); } + .platform-plugin { color: var(--accent-secondary); border-color: var(--accent-secondary); } .project-name { color: var(--accent); font-weight: bold; @@ -237,6 +238,83 @@ background: var(--card-ios-bg); border-left: 4px solid var(--platform-ios); } + .plugin-card { + padding: 1.25rem 1.5rem; + border-radius: 4px; + margin-bottom: 1rem; + background: linear-gradient(135deg, var(--bg-card) 0%, var(--bg-secondary) 100%); + border-left: 4px solid var(--accent-secondary); + } + .plugin-header { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 0.5rem; + } + .plugin-name { + color: var(--accent-secondary); + font-weight: bold; + font-size: 1.05rem; + } + .plugin-version { + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.85rem; + } + .plugin-engine-tag { + display: inline-block; + padding: 0.15rem 0.5rem; + background: var(--tag-bg); + color: var(--text-secondary); + border-radius: 3px; + font-size: 0.75rem; + font-family: var(--font-mono); + } + .plugin-description { + color: var(--text-secondary); + font-size: 0.9rem; + margin: 0.5rem 0 0.75rem 0; + line-height: 1.4; + } + .plugin-artifacts { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.5rem; + } + .plugin-artifact-btn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.4rem 0.75rem; + background: var(--bg-button); + color: var(--text-primary) !important; + border: 1px solid var(--border-primary); + border-radius: 3px; + font-size: 0.85rem; + font-weight: 500 !important; + text-decoration: none !important; + transition: all 0.15s; + } + .plugin-artifact-btn:hover { + background: var(--bg-button-hover); + border-color: var(--accent-secondary); + } + .plugin-artifact-btn .size { + color: var(--text-muted); + font-size: 0.75rem; + font-family: var(--font-mono); + } + .plugin-upstream { + font-size: 0.8rem; + color: var(--text-muted); + margin-top: 0.5rem; + } + .plugin-upstream a { + color: var(--text-secondary); + font-weight: normal; + } .build-header { display: flex; align-items: center; @@ -1017,7 +1095,7 @@ h1 { font-size: 1.25rem; } h2 { font-size: 1.1rem; } .container { padding: 0; } - + /* Platform filter tabs - wrap on mobile */ .platform-filters { gap: 0.4rem; @@ -1028,7 +1106,7 @@ padding: 0.4rem 0.6rem; font-size: 0.8rem; } - + /* Build cards */ .build-card { padding: 1rem; @@ -1052,7 +1130,7 @@ .meta { font-size: 0.8rem; } - + /* Story cards */ .story-feed-card { padding: 0.875rem 1rem; @@ -1076,13 +1154,13 @@ .story-time { font-size: 0.75rem; } - + /* Nested stories container */ .build-stories-container { margin-left: 1rem; padding-left: 0.75rem; } - + /* Modals */ .modal-content { padding: 1.25rem; @@ -1108,7 +1186,7 @@ .modal-actions button { width: 100%; } - + /* Story detail view */ .story-meta { flex-direction: column; @@ -1127,19 +1205,19 @@ .item-desc { font-size: 0.8rem; } - + /* Date headers */ .date-header { font-size: 0.9rem; padding: 0.5rem 0; } - + /* Footer */ .footer { font-size: 0.75rem; padding: 1rem 0; } - + /* iOS install button - larger touch target */ .ios-install-btn { padding: 0.5rem 0.75rem; @@ -1205,7 +1283,7 @@
- +
@@ -1214,24 +1292,32 @@ +
- +
Loading builds
- + + + +
- +