diff --git a/ai_animation/Dockerfile b/ai_animation/Dockerfile new file mode 100644 index 0000000..d73a33e --- /dev/null +++ b/ai_animation/Dockerfile @@ -0,0 +1,24 @@ +# vite-server/Dockerfile +FROM node:20.4.0-alpine + +# Create app directory +WORKDIR /app + +# Copy package.json and lock file first for better caching +COPY package.json package-lock.json ./ + +# Install dependencies +RUN npm install + +# Now copy everything else +COPY . . + +# Build the production-ready files (dist/) +#RUN npm run build + +# Expose the port that `npm run preview` uses (default is 4173) +EXPOSE 4173 + +# Finally, serve the built app +CMD ["npm", "run", "preview"] + diff --git a/ai_animation/package.json b/ai_animation/package.json index d1b4703..7cd97a0 100644 --- a/ai_animation/package.json +++ b/ai_animation/package.json @@ -5,8 +5,9 @@ "type": "module", "scripts": { "dev": "vite", + "dev-all": "vite --host 0.0.0.0", "build": "tsc && vite build", - "preview": "vite preview" + "preview": "vite preview --host 0.0.0.0" }, "devDependencies": { "typescript": "~5.7.2", diff --git a/ai_animation/src/speech.ts b/ai_animation/src/speech.ts index c8fd5f7..981b35f 100644 --- a/ai_animation/src/speech.ts +++ b/ai_animation/src/speech.ts @@ -1,5 +1,4 @@ - -import { isSpeaking } from "./gameState"; +import { gameState } from "./gameState"; // --- ElevenLabs Text-to-Speech configuration --- const ELEVENLABS_API_KEY = import.meta.env.VITE_ELEVENLABS_API_KEY || ""; @@ -20,7 +19,7 @@ export async function speakSummary(summaryText: string): Promise { } // Set the speaking flag to block other animations/transitions - isSpeaking = true; + gameState.isSpeaking = true; try { // Truncate text to first 100 characters for ElevenLabs @@ -59,13 +58,13 @@ export async function speakSummary(summaryText: string): Promise { audio.play().then(() => { audio.onended = () => { // Clear the speaking flag when audio finishes - isSpeaking = false; + gameState.isSpeaking = false; resolve(); }; }).catch(err => { console.error("Audio playback error", err); // Make sure to clear the flag even if there's an error - isSpeaking = false; + gameState.isSpeaking = false; reject(err); }); }); @@ -73,6 +72,6 @@ export async function speakSummary(summaryText: string): Promise { } catch (err) { console.error("Failed to generate TTS from ElevenLabs:", err); // Make sure to clear the flag if there's any exception - isSpeaking = false; + gameState.isSpeaking = false; } } diff --git a/ai_animation/src/types/gameState.ts b/ai_animation/src/types/gameState.ts index dd0d5d4..0398faf 100644 --- a/ai_animation/src/types/gameState.ts +++ b/ai_animation/src/types/gameState.ts @@ -3,107 +3,24 @@ import { PowerENUMSchema } from './map'; import { OrderFromString } from './unitOrders'; import { ProvinceENUMSchema } from './map'; -// Define the unit structure in the JSON -const UnitSchema = z.object({ - type: z.string(), - power: PowerENUMSchema, - location: z.string(), - // Add any other possible fields with optional() - region: z.string().optional(), - coast: z.string().optional(), - // Catch-all for any other fields -}).catchall(z.any()); - -// Define the order structure in the JSON -const OrderSchema = z.object({ - text: z.string(), - power: PowerENUMSchema, - region: z.string().optional(), - // Add any other possible fields with optional() - result: z.any().optional(), - unit: z.any().optional(), - type: z.string().optional(), - // Catch-all for any other fields -}).catchall(z.any()); const PhaseSchema = z.object({ + messages: z.array(z.any()), name: z.string(), - year: z.number().optional(), - season: z.string().optional(), - type: z.string().optional(), - // Make messages optional with default empty array - messages: z.array(z.object({ - sender: PowerENUMSchema, - recipient: z.union([PowerENUMSchema, z.literal('GLOBAL')]), - time_sent: z.number(), - message: z.string() - })).optional().default([]), - // Units as an array of objects - units: z.array(UnitSchema).optional(), - // Orders - standardize on array format, with preprocessor to convert from object format - orders: z.preprocess( - (val) => { - // If it's already an array, return it - if (Array.isArray(val)) { - return val; - } - - // If it's an object with power keys and arrays of order strings - if (val && typeof val === 'object') { - const orderArray: any[] = []; - - // Convert from {POWER: [orderText1, orderText2]} to [{text: orderText1, power: POWER}, {text: orderText2, power: POWER}] - Object.entries(val).forEach(([power, orders]) => { - if (Array.isArray(orders)) { - orders.forEach(orderText => { - // Extract region from order text if possible - let region = ''; - const match = orderText.match(/^[AF]\s+([A-Z]{3})/); - if (match) { - region = match[1]; - } - - orderArray.push({ - text: orderText, - power: power, - region: region - }); - }); - } - }); - - return orderArray; - } - - // Otherwise return empty array - return []; - }, - z.array(OrderSchema).optional().default([]) - ), - // Results as an optional record - results: z.record(z.string(), z.array(z.any())).optional().default({}), - // State as an optional object + orders: z.record(PowerENUMSchema, z.array(OrderFromString).nullable()), + results: z.record(z.string(), z.array(z.any())), state: z.object({ - units: z.record(PowerENUMSchema, z.array(z.string())).optional(), - centers: z.record(PowerENUMSchema, z.array(ProvinceENUMSchema)).optional() - }).optional().default({}), - // Summary for phase completion - summary: z.string().optional() + units: z.record(PowerENUMSchema, z.array(z.string())), + centers: z.record(PowerENUMSchema, z.array(ProvinceENUMSchema)) + }), + year: z.number().optional(), }); export const GameSchema = z.object({ - map_name: z.string().optional(), - map: z.string().optional(), - id: z.string().optional(), - game_id: z.string().optional(), + map: z.string(), + id: z.string(), phases: z.array(PhaseSchema), - // Add other possible fields - powers: z.any().optional(), - current_phase: z.any().optional(), - status: z.any().optional(), - created_at: z.any().optional(), - updated_at: z.any().optional(), -}).catchall(z.any()); +}); export type GamePhase = z.infer; export type GameSchemaType = z.infer; diff --git a/ai_animation/vite.config.js b/ai_animation/vite.config.js new file mode 100644 index 0000000..3cdc087 --- /dev/null +++ b/ai_animation/vite.config.js @@ -0,0 +1,9 @@ +/** @type {import('vite').UserConfig} */ +export default { + "preview": { + "allowedHosts": ["diplomacy"] + }, + "dev": { + "allowedHosts": ["diplomacy"] + } +} diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..e18fcba --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,15 @@ +services: + twitch-streamer: + build: twitch-streamer + env_file: ".env" + cap_add: + - SYS_ADMIN + environment: + - DISPLAY=:99 + - SCREEN_GEOMETRY=1920x1080x24 + + diplomacy: + build: ai_animation + ports: + - "4173:4173" + - "5173:5173" diff --git a/twitch-streamer/Dockerfile b/twitch-streamer/Dockerfile new file mode 100644 index 0000000..80dfa86 --- /dev/null +++ b/twitch-streamer/Dockerfile @@ -0,0 +1,46 @@ +# chrome-twitch/Dockerfile +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + xvfb \ + ffmpeg \ + x11vnc \ + wget \ + gnupg \ + ca-certificates \ + fonts-liberation \ + pulseaudio \ + dbus dbus-x11 \ + && rm -rf /var/lib/apt/lists/* + +# Install Google Chrome +RUN wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | apt-key add - \ + && echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" \ + > /etc/apt/sources.list.d/google-chrome.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + google-chrome-stable \ + && rm -rf /var/lib/apt/lists/* + +# Create a non-root user and group (chrome:chrome) +# and add user to audio/video groups if needed +RUN groupadd --system chrome \ + && useradd --system --create-home --gid chrome --groups audio,video chrome + +# Environment variables for the virtual display +ENV DISPLAY=:1 +ENV SCREEN_GEOMETRY=1920x1280x30 + +# Copy PulseAudio and entrypoint scripts +RUN mkdir /twitch-streamer/ +COPY pulse-virtual-audio.sh /twitch-streamer/pulse-virtual-audio.sh +COPY entrypoint.sh /twitch-streamer/entrypoint.sh +RUN chmod +x /twitch-streamer/pulse-virtual-audio.sh /twitch-streamer/entrypoint.sh +WORKDIR /twitch-streamer/ + +# Switch to non-root user +USER chrome + +ENTRYPOINT ["/twitch-streamer/entrypoint.sh"] diff --git a/twitch-streamer/entrypoint.sh b/twitch-streamer/entrypoint.sh new file mode 100755 index 0000000..3dbef76 --- /dev/null +++ b/twitch-streamer/entrypoint.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# X display can put a lock in, that sometimes will stay in the container. Nuke it as it isn't needed +rm /tmp/.X99-lock +set -e +# Check if STREAM_KEY is empty +if [ -z "$STREAM_KEY" ]; then + echo "Error: STREAM_KEY is not set or empty" + exit 1 +fi +# Start PulseAudio (virtual audio) in the background +/twitch-streamer/pulse-virtual-audio.sh & + +# Start Xvfb (the in-memory X server) in the background +Xvfb $DISPLAY -screen 0 $SCREEN_GEOMETRY & +echo "Display is ${DISPLAY}" +XVFB_PID=$! + +# Give Xvfb a moment to start +sleep 2 + +mkdir -p /home/chrome + +# Launch Chrome in the background, pointing at your site. +# --app=... to open it as a single-window "app" +# --no-sandbox / --disable-gpu often needed in Docker +# --use-fake-device-for-media-stream / etc. if you need to simulate mic/cam +DISPLAY=$DISPLAY google-chrome \ + --start-fullscreen \ + --disable-gpu \ + --no-first-run \ + --diable-infobars \ + --user-data-dir=/home/chrome/chrome-data \ + --app="http://diplomacy:4173" & + +CHROME_PID=$! +sleep 5 # let the page load or animations start + +# Start streaming with FFmpeg. +# - For video: x11grab from DISPLAY +# - For audio: pulse from the "default" device +# Adjust your bitrate, resolution, frame rate, etc. as desired. +exec ffmpeg -y \ + -f x11grab -thread_queue_size 512 -r 30 -s 1920x1080 -i $DISPLAY \ + -f pulse -thread_queue_size 512 -i default \ + -c:v libx264 -preset veryfast -b:v 6000k -maxrate 6000k -bufsize 12000k \ + -pix_fmt yuv420p \ + -c:a aac -b:a 160k \ + -vsync 1 -async 1 \ + -f flv "rtmp://ingest.global-contribute.live-video.net/app/$STREAM_KEY" + +# 'exec' ensures ffmpeg catches any SIGTERM and stops gracefully, +# which will then terminate the container once ffmpeg ends. diff --git a/twitch-streamer/pulse-virtual-audio.sh b/twitch-streamer/pulse-virtual-audio.sh new file mode 100644 index 0000000..6e2f3e6 --- /dev/null +++ b/twitch-streamer/pulse-virtual-audio.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -e + +# Start pulseaudio with a null sink so we can capture audio +pulseaudio -D --exit-idle-time=-1 --disable-shm=true --system=false + +# The above automatically loads module-null-sink by default in most distros, +# but if you need it explicitly, you can do something like: +# pactl load-module module-null-sink sink_name=MySink +# pactl set-default-sink MySink +# For many cases, the default config is enough. + +# Just sleep forever to keep the container from stopping if we ran only this script +sleep infinity