| name | openmaic-deployment |
| description | UMBRELLA: Deploy, batch-generate, and maintain OpenMAIC (Next.js interactive classroom platform) — Docker local, Cloudflare Pages static, batch generation pipeline, build troubleshooting, TTS, subtitle overlay, video export, and the openmaic-pages static viewer. |
OpenMAIC Deployment (Umbrella)
This skill is the umbrella for all OpenMAIC operations. Sub-skills openmaic-batch and openmaic-pages-deployment have been absorbed into this umbrella; their complete content (scripts, references) is preserved under .archive/ — see the subsections below for entry points.
OpenMAIC is a Next.js 16 app at ~/PycharmProjects/OpenMAIC. Generates AI/ML interactive classrooms with slides, TTS, video.
Subsections
OpenMAIC Projects at a Glance
| openmaic-pages | main OpenMAIC | openmaic-batch |
|---|
| Path | ~/openmaic-pages | ~/PycharmProjects/OpenMAIC | (scripts in .archive/) |
| Port | 8004 (Docker) | 8003 (Docker) | — |
| Layout | Simple text + sidebar | Slides + playback + AI teacher | — |
| Purpose | Lightweight static viewer | Full interactive platform | Batch generation pipeline |
Docker Deployment (Local)
docker-compose.yml
services:
openmaic:
build: .
container_name: openmaic
user: "1001:1001"
ports:
- "8003:3000"
env_file:
- .env.local
volumes:
- ./data:/app/data
restart: unless-stopped
Key Fixes
-
Volume permission mismatch after reboot — Container runs as nextjs (uid=1001), but host data/ directory often owned by uid=501 (macOS default). After host reboot, the container starts but /app/data/classrooms/ appears empty — API returns total: 0. Diagnosis: curl -s localhost:8003/api/classrooms | grep total returns "total":0 even though ls /opt/openmaic/data/classrooms/ | wc -l shows 700+ on host. Fix: sudo chmod -R 755 /opt/openmaic/data/ && sudo chown -R 1001:1001 /opt/openmaic/data/ then sudo docker compose restart. Permanent fix: add user: "1001:1001" to docker-compose.yml service block so the container process matches host ownership.
-
COPY scripts/ missing — Dockerfile deps stage copies packages/ but not scripts/. sync-maic-importer.mjs fails in postinstall. Fix: add COPY scripts/ ./scripts/ before RUN pnpm install.
-
TypeScript ApiErrorCode — regenerate-tts/route.ts uses 'MISSING_FIELD', 'NOT_FOUND', 'NO_SCENES' which aren't in API_ERROR_CODES. Fix: use 'MISSING_REQUIRED_FIELD' / 'INVALID_REQUEST' / 'INVALID_REQUEST'.
-
mimo-tts missing from settings — lib/store/settings.ts ttsProvidersConfig is Record<TTSProviderId, ...> but missing 'mimo-tts'. Add it.
-
useCallback not imported — If adding useCallback to page.tsx, add it to the React import.
Build & Run
cd ~/PycharmProjects/OpenMAIC
docker compose build
docker compose up -d
Cloudflare Pages (Static Browse)
For browse-only deployment (no classroom generation), export as static site.
Project: ~/openmaic-pages
- Export data:
node scripts/export-data.mjs reads data/classroom-classification.json + data/classrooms/*/classroom.json → public/data/
- Next.js config:
output: 'export', trailingSlash: true, images: { unoptimized: true }
- Dynamic route:
[id]/page.tsx must export generateStaticParams() (server component) + client component for interactivity
- Build:
npx next build → out/ (~120MB for 722 classrooms)
- Deploy:
npx wrangler pages deploy out --project-name=openmaic-pages
Pitfalls
generateStaticParams must be in server component, not 'use client' file. Split: page.tsx (server, exports params) → client.tsx (client, renders UI)
params is a Promise in Next.js 16 — Must use { params: Promise<{ id: string }> } and await params. WRONG: { params: { id: string } }. See nextjs-docker-deployment skill for full pattern.
- Cloudflare Pages 25MB file limit —
search/search_index.json or large data files may exceed this. Remove or split before deploying.
- wrangler.toml needs
pages_build_output_dir field or wrangler ignores it
- Tailwind v4 CSS not generating —
@import "tailwindcss" needs @tailwindcss/postcss + postcss.config.mjs. If still no CSS, use CDN fallback: <script src="https://cdn.tailwindcss.com"></script> in layout.tsx head
- Custom domain: API needs
zone:write scope (not just zone:read). If OAuth only has zone:read, DNS CNAME must be added manually in Dashboard
- Cloudflare API:
POST /accounts/{id}/pages/projects/{name}/domains with {"name":"ai.jinguo.tech"}. Then add CNAME DNS record pointing to {project}.pages.dev
- Canvas element content is HTML strings —
scene.content.canvas.elements[].content is an HTML string (e.g. <p style="font-size: 32px;"><strong>Title</strong></p>), NOT an object with text/children. Use dangerouslySetInnerHTML to render, or strip tags with .replace(/<[^>]+>/g, '') for plain text. Check typeof el.content === 'string' before accessing .text.
- Proxy blocks custom domain — Browser through proxy gets
ERR_CONNECTION_CLOSED on custom domain (e.g. ai.jinguo.tech) but *.pages.dev works. Diagnose: curl -sL --noproxy '*' https://ai.jinguo.tech -o /dev/null -w "%{http_code}". Fix: add domain to proxy's direct/no-proxy list.
- Domain status API — Check:
GET /accounts/{ACCOUNT}/pages/projects/{PROJECT}/domains/{DOMAIN} → result.status should be , verification_data.status should be .
Update Flow
cd ~/openmaic-pages
OPENMAIC_DATA=~/PycharmProjects/OpenMAIC/data npm run export-data
npm run build
npx wrangler pages deploy out --project-name=openmaic-pages
TTS Default State
ttsEnabled defaults to false in lib/store/settings.ts (line 872). Auto-enables only when !state.autoConfigApplied AND server TTS providers are detected. Once autoConfigApplied is set to true, adding new env vars won't auto-enable.
To make TTS default-on: change ttsEnabled: false → ttsEnabled: true in lib/store/settings.ts, rebuild Docker image.
Pitfall: .env.local multi-key-per-line corruption — .env.local can end up with multiple TTS_* keys on one line (e.g. TTS_OPENAI_API_KEY=xxx TTS_AZURE_API_KEY=xxx). Docker/Node reads the entire value including trailing keys. Detection: awk '/^TTS_/{print NR": "$0}' .env.local — if a line contains more than one TTS_ prefix, it's corrupted. Fix: sed -i '' 's/ TTS_/\nTTS_/g' .env.local.
Pitfall: MiMo TTS fallback — lib/server/provider-config.ts auto-inherits XIAOMI_API_KEY as mimo-tts API key if TTS_MIMO_API_KEY is not set. So setting TTS_MIMO_ENABLED=true + having XIAOMI_API_KEY configured is sufficient. No need for a separate TTS_MIMO_API_KEY unless using a different key.
Pitfall: Docker restart doesn't load new env vars — Adding new TTS_* keys to .env.local then docker compose restart won't make them visible. Must docker compose down && docker compose create && docker compose start to rebuild the container with new env_file.
Audio File Mismatch Fix (Cross-Environment Migration)
See references/docker-port-mismatch-fix.md for the full fix including APP_BASE_URL env var and AudioPlayer URL rewriting.
When migrating classroom data between environments (Mac → HP), audio filenames may have different random IDs:
JSON references: tts_s1_action_zf6rhJIg.mp3
Disk has: tts_s1_action_Crsonb-a.mp3 (different random ID)
Root cause: regenerate-tts API generates new random IDs but doesn't update existing JSON references. If you delete and regenerate audio, old references become invalid.
Fix script — run on the target server:
import os, json, re
base = "/opt/openmaic/data/classrooms"
for dirname in sorted(os.listdir(base)):
dirpath = os.path.join(base, dirname)
json_path = os.path.join(dirpath, "classroom.json")
audio_dir = os.path.join(dirpath, "audio")
if not os.path.isfile(json_path) or not os.path.isdir(audio_dir):
continue
with open(json_path) as f:
classroom = json.load(f)
existing_audio = sorted(os.listdir(audio_dir))
classroom_id = dirname.split("_")[0]
all_speech = [a for s in classroom.get("scenes", []) for a in s.get("actions", []) if a.get("type") == "speech"]
audio_by_scene = {}
for f in existing_audio:
m = re.match(r"tts_s(\d+)_action_(.+)\.mp3", f)
if m: audio_by_scene.setdefault(int(m.group(1)), []).append(f)
all_audio = []
for sn in sorted(audio_by_scene.keys()):
all_audio.extend(sorted(audio_by_scene[sn]))
for i, action in enumerate(all_speech):
if i < len(all_audio):
action["audioId"] = all_audio[i].replace(".mp3", )
action[] =
(json_path, ) f:
json.dump(classroom, f, indent=, ensure_ascii=)
Also fix audioUrl to use external URL (not localhost):
action["audioUrl"] = action["audioUrl"].replace("http://localhost:8003", "https://ai.jinguo.tech")
action["audioUrl"] = action["audioUrl"].replace("http://localhost:3000", "https://ai.jinguo.tech")
Lesson: Never delete audio directories during debugging without backup. The regenerate-tts API creates new random IDs, breaking all existing references.
Audio Autoplay on Mobile (Browser Policy)
Mobile browsers block audio.play() without user gesture. Desktop Mac is lenient — this is NOT about IndexedDB cache.
Solution: Create a standalone /enable-audio page:
function enableAudio() {
var ctx = new (window.AudioContext || window.webkitAudioContext)();
var osc = ctx.createOscillator();
var gain = ctx.createGain();
gain.gain.value = 0.001;
osc.connect(gain); gain.connect(ctx.destination);
osc.start(0); osc.stop(ctx.currentTime + 0.1);
var audio = new Audio();
audio.volume = 0.001;
audio.src = "data:audio/wav;base64,UklGRl9vT19tZW...";
audio.play().catch(function(){});
setTimeout(function() { window.location.href = "/"; }, 500);
}
Deploy: Run as separate Node.js service on port 9999, add path-based route in cloudflared config:
ingress:
- hostname: ai.jinguo.tech
path: /enable-audio
service: http://localhost:9999
- hostname: ai.jinguo.tech
service: http://localhost:8003
- service: http_status:404
Systemd service: Create /etc/systemd/system/enable-audio.service for auto-start.
TTS Provider Configuration
Never disable browser-native-tts — it uses the browser's built-in Web Speech API and consumes ZERO API tokens. Disabling it forces all TTS through paid providers.
The real issue is usually:
- Audio file name mismatch (fix with batch script above)
- Browser autoplay policy (fix with /enable-audio page)
.env.local TTS keys set to *** placeholder (replace with real keys)
TTS provider priority: Server auto-selects first configured provider. If TTS_MIMO_API_KEY is set and TTS_MIMO_ENABLED=true, MiMo TTS will be used. No need to disable other providers.
Docker env reload: docker restart does NOT reload .env.local. Must docker compose down && docker compose up -d to pick up new environment variables.
Pitfall: buildRequestOrigin uses internal Docker port — lib/server/classroom-storage.ts buildRequestOrigin(req) returns req.nextUrl.origin which is http://localhost:3000 inside the Docker container. Audio URLs get stored with this internal port, making them inaccessible from the browser (which uses localhost:8003).
Fix (two parts):
-
Server-side: APP_BASE_URL env var — Modify buildRequestOrigin in lib/server/classroom-storage.ts:
export function buildRequestOrigin(req: NextRequest): string {
const envBase = process.env.APP_BASE_URL;
if (envBase) return envBase.replace(/\/+$/, '');
return req.headers.get('x-forwarded-host')
? `${req.headers.get('x-forwarded-proto') || 'http'}://${req.headers.get('x-forwarded-host')}`
: req.nextUrl.origin;
}
Then add to docker-compose.yml:
environment:
- APP_BASE_URL=http://localhost:8003
This fixes NEW classrooms. Existing classrooms still have stale URLs.
-
Client-side: AudioPlayer URL rewriting — For existing classrooms with stale URLs, modify lib/utils/audio-player.ts play() method:
public async play(audioId: string, audioUrl?: string): Promise<boolean> {
{
(audioUrl) {
origin = !== ? .. : ;
(origin && !audioUrl.(origin)) {
audioUrl = audioUrl.(, origin);
}
}
Diagnosis: curl -s localhost:8003/api/classroom?id=<id> | grep audioUrl — if URLs show :3000 instead of :8003, they're broken.
Docker rebuild required after both changes: docker compose down && docker compose build --no-cache && docker compose up -d
Pitfall: Cached builds miss client-side changes — docker compose build may use cached layers even after modifying client-side files like audio-player.ts or subtitle-overlay.tsx. The bundled chunks in .next/static/chunks/ won't include the changes. Always use docker compose build --no-cache when modifying client-side code. Verify with: docker exec openmaic grep "your-change-marker" /app/.next/static/chunks/*.js
Cloudflare Tunnel Setup
See cloudflare-tunnel-setup skill for full instructions. Key points for OpenMAIC:
- Install cloudflared on HP server
cloudflared tunnel login → authorize in browser
cloudflared tunnel create hp-tunnel
cloudflared tunnel route dns hp-tunnel ai.jinguo.tech
- Configure
/root/.cloudflared/config.yml with ingress rules
- Create systemd service with proxy bypass:
[Service]
Environment="HTTP_PROXY="
Environment="HTTPS_PROXY="
Environment="NO_PROXY=*"
ExecStart=/usr/bin/cloudflared tunnel run --protocol http2 hp-tunnel
Critical: HP server has v2rayN global proxy. Must clear proxy env vars in systemd service, otherwise cloudflared DNS resolution fails with "i/o timeout".
Video streaming prohibited on free tier — Cloudflare ToS prohibits using Tunnel for video streaming services (Plex/Jellyfin/media streaming). Violates ToS, gets throttled or banned. OpenMAIC itself is fine (web app, not streaming), but do NOT expose a video player through Tunnel for continuous playback. For remote video access, use Tailscale or direct connection instead.
Subtitle Overlay (字幕)
Both projects (main OpenMAIC + openmaic-pages) have a SubtitleOverlay component that renders speech text as a floating overlay at the bottom of the classroom.
Current Design (Static Single-line + One-shot Scroll on Overflow)
The subtitle overlay uses a static single-line display with fade-in animation. When text overflows the container width, it scrolls once slowly (not looping) to reveal the hidden portion, then stops. No looping marquee, no truncation with ellipsis.
Text fits: fade-in → static display
Text overflows: fade-in → 0.5s pause → slow scroll to reveal hidden text → stop
Style: Transparent background, pure black text, 18px, weight 500, centered.
style={{
position: 'fixed',
bottom: isFullscreen ? '48px' : '72px',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 9999,
width: '85%',
maxWidth: '900px',
padding: '8px 20px',
background: 'transparent',
color: '#000',
fontSize: '18px',
fontWeight: 500,
lineHeight: 1.6,
textAlign: 'center',
overflow: 'hidden',
whiteSpace: 'nowrap',
letterSpacing: '0.04em',
pointerEvents: 'none',
userSelect: 'none',
}}
Scrolling implementation (Web Animations API, not CSS @keyframes):
const textW = textEl.scrollWidth;
const containerW = containerEl.clientWidth;
if (textW > containerW) {
const distance = textW - containerW;
const duration = Math.max(distance * 0.008, 4);
textEl.animate([
{ transform: 'translateX(0)' },
{ transform: `translateX(-${distance}px)` },
], {
duration: duration * 1000,
delay: 500,
fill: 'forwards',
easing: 'linear',
});
}
Style History (User Preference Evolution)
- White bg → user said "透明的底"
- Dark bg + white text → approved
- Transparent bg + black text, CSS looping marquee → user said "缓慢走马灯" then rejected it
- Static multi-line (line-clamp: 3) + fade-in → user said "能控制最多一行显示"
overflow: hidden truncation → user said "超出直接裁掉这么做不对,超出内容应该刷新字幕"
- Static single-line + one-shot scroll on overflow (current) — scrolls once to reveal hidden text, no loop
Two Projects, Two Implementations
| Project | Path | Port | Subtitle Logic |
|---|
| Main OpenMAIC | ~/PycharmProjects/OpenMAIC | Docker :8003 | PlaybackChromeRoot.tsx → onSpeechStart → setLectureSpeech → <SubtitleOverlay text={lectureSpeech} /> |
| openmaic-pages | ~/openmaic-pages | Static export | client.tsx → reads scene.actions[].text → <SubtitleOverlay text={subtitleText} /> |
openmaic-pages subtitle: Shows first speech text from current scene automatically (no audio playback needed). Filter: actions.filter(a => a.type === 'speech' && a.text).
Main OpenMAIC subtitle: Shows during playback when PlaybackEngine fires onSpeechStart. Already wired in PlaybackChromeRoot.tsx line 1369.
Docker Rebuild for Main OpenMAIC
After modifying components/stage/subtitle-overlay.tsx or any client-side component:
cd ~/PycharmProjects/OpenMAIC
docker compose down
docker compose up -d --build
Pitfall: docker compose up -d --force-recreate fails if old container is still running (name conflict). Must docker compose down first.
Pitfall: Code already changed but Docker image not rebuilt — If source code already has the fix (e.g. useState(true)) but the running container still shows old behavior, the Docker image wasn't rebuilt. Always verify: docker exec openmaic grep "your-marker" /app/.next/static/chunks/*.js. If not found, rebuild with --no-cache.
Pitfall: docker compose up -d blocked by system — Hermes terminal tool blocks docker compose up -d as "appears to start a long-lived server/watch process". Workaround:
- Run
docker compose down separately (foreground, gets user approval)
- Run
docker compose up -d with background=true + notify_on_complete=true
- Use
process(action='wait') to confirm completion
docker compose down
docker compose up -d
Pitfall: --no-cache build timeout — No-cache builds can take 5+ minutes. If the terminal tool times out at 300s, run the build in background:
docker compose build --no-cache openmaic
openmaic-pages Docker Deployment
The openmaic-pages project (~/openmaic-pages) is a lightweight static-export viewer. It can also be deployed via Docker for local serving.
Setup
- Dockerfile — Standard Next.js standalone build:
FROM node:22-alpine AS base
RUN apk add --no-cache libc6-compat
WORKDIR /app
FROM base AS deps
COPY package.json package-lock.json* ./
RUN npm ci || npm install
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 HOSTNAME=0.0.0.0 PORT=3000
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
- docker-compose.yml:
services:
openmaic-pages:
build: .
container_name: openmaic-pages
ports:
- "8004:3000"
restart: unless-stopped
- next.config.mjs — Must change
output: 'export' to output: 'standalone' for Docker.
Critical Pitfalls
-
26GB+ video directories — videos/, videos-sorted/, videos-hd/ are in the project root. MUST add to .dockerignore or Docker context transfer takes 5+ minutes and wastes disk:
node_modules
.next
.git
videos
videos-sorted
videos-hd
out
docs
-
Hardcoded path in page.tsx — app/classroom/[id]/page.tsx may reference /Users/jinguo/PycharmProjects/OpenMAIC/data/classroom-classification.json. Fix: use public/data/classrooms.json with process.cwd():
readFileSync(join(process.cwd(), 'public/data/classrooms.json'), 'utf-8')
Note: classrooms.json uses id field (not classroomId).
-
Docker image name — docker compose build auto-generates openmaic-pages-openmaic-pages:latest. Container name is openmaic-pages.
Build & Run
cd ~/openmaic-pages
docker compose build
docker compose up -d
Video Export (Playwright + ffmpeg)
Export classrooms as 1080P MP4 with audio. Script: ~/openmaic-pages/scripts/export-video.mjs (note: this script may not exist on all machines — check before running).
Canvas Viewport Scaling (Critical Pitfall)
Canvas elements use viewportSize (typically 1000px). Must scale to 1920×1080:
const canvasW = canvas.viewportSize || 1000;
const scaleX = 1920 / canvasW;
const scaleY = 1080 / (canvasW * (canvas.viewportRatio || 0.5625));
const left = (el.left || 0) * scaleX;
content = content.replace(/font-size:\s*(\d+)px/g, (_, px) => `font-size:${parseInt(px) * scaleX}px`);
Without scaling, content fills only ~1/4 of the video frame.
Audio Matching
Files: audio/tts_s{N}_action_*.mp3 (scene index is 1-based). Multiple per scene → merge with ffmpeg -f concat.
Subtitles
FFmpeg subtitles= needs libass (often missing). drawtext= has CJK escaping issues. Best: generate .srt as companion file, not burned in.
Performance
~90 sec/classroom. Bottleneck is ffmpeg, not Playwright. Reuse browser instance (launch once, new context per classroom). Node.js buffers stdout — track via tmp dir count.
node scripts/export-video.mjs <id>
node scripts/export-video.mjs --top 10
node scripts/export-video.mjs --all
Batch Generation Pipeline
Absorbed from the archived openmaic-batch skill. The complete scripts and reference files are preserved under ~/.hermes/skills/.archive/openmaic-batch/.
OpenMAIC exposes POST /api/generate-classroom as an async job: POST a requirement, get a jobId and a location header. Poll GET /api/classroom-jobs/{jobId} for status (progress, completed, failed).
Key Parameters
| Parameter | Description |
|---|
| CONCURRENCY | Parallel generation jobs (4-6) |
| PARALLEL_LIMIT | API calls per job (10) |
| Total concurrency | CONCURRENCY × PARALLEL_LIMIT = 40-60 concurrent calls |
| Throughput | ~9-13 classrooms/hour |
Core Scripts (archived skill)
| Script | Purpose |
|---|
scripts/mega_run.sh | Multi-phase concurrent generation runner |
scripts/batch_runner.sh | Single batch orchestration |
scripts/run_batch.py | Python batch controller |
scripts/harvest.py | Wiki harvest with file:// paths |
scripts/batch_status.py | Batch progress monitoring |
scripts/verify_provider_switch.sh | Provider migration verification |
Referenced Topics (archived skill's references/ dir)
- API surface (
api-surface.md)
- Provider switching (
provider-switching.md)
- Stuck job recovery (
stuck-job-recovery.md)
- Cost estimation (
cost-estimation.md)
- Post-batch cleanup (
post-batch-cleanup.md, post-batch-operations-and-dashboard.md)
- MIMO TTS integration (
mimo-tts-integration.md, mimo-tts-provider.md)
- Video augmentation (
video-augmentation.md)
- Video export pipeline (
video-export-pipeline.md)
- Concurrency pitfalls (
concurrent-mega-run-pitfalls.md)
- Concurrent generation patterns (
batch-chaining-and-cost.md, batch-chaining-and-timeout.md)
Load the archived skill's references for detailed guidance on any topic above.
Data Structure
data/
├── classroom-classification.json # metadata: id, title, difficulty, domain, tags
├── classrooms/<id>/
│ ├── classroom.json # full data: stage + scenes + slides
│ ├── slug.txt
│ └── audio/
├── classroom-jobs/ # generation jobs
└── wiki-link/ # wiki→classroom links