首次提交:脑晴测 H5 项目

包含前端源码、预构建产物(dist/)、零依赖 Node.js 服务器(server.mjs)、
Cloudflare Worker 版本(worker/)、部署文档(DEPLOY.md)和交接文档。

技术总监可直接 clone 后运行 node server.mjs 启动,无需安装 React 或 build。
DEEPSEEK_API_KEY 通过环境变量注入,未纳入仓库。
This commit is contained in:
MingNian
2026-07-28 10:24:47 +08:00
commit b0fce840a3
152 changed files with 8536 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const lockPath = path.join(root, "mobile-runtime.lock.json");
const lockedFiles = JSON.parse(readFileSync(lockPath, "utf8"));
const failures = [];
for (const [relativePath, expectedHash] of Object.entries(lockedFiles)) {
const filePath = path.join(root, relativePath);
if (!existsSync(filePath)) {
failures.push(`${relativePath} is missing`);
continue;
}
const actualHash = createHash("sha256").update(readFileSync(filePath)).digest("hex");
if (actualHash !== expectedHash) {
failures.push(`${relativePath} was modified`);
}
}
if (failures.length > 0) {
console.error("Mobile runtime integrity check failed:\n");
for (const failure of failures) console.error(`- ${failure}`);
console.error("\nRestore the protected runtime. Put app UI in src/Prototype.tsx and src/prototype.css.");
process.exit(1);
}
console.log(`Mobile runtime integrity check passed (${Object.keys(lockedFiles).length} protected files).`);

View File

@@ -0,0 +1,122 @@
param(
[Parameter(Mandatory = $true)]
[string]$VideoPath,
[Parameter(Mandatory = $true)]
[string]$OutputDirectory,
[int]$FrameCount = 12,
[double]$StartSeconds = 0,
[double]$EndSeconds = -1
)
$ErrorActionPreference = "Stop"
Add-Type -AssemblyName PresentationCore
Add-Type -AssemblyName WindowsBase
function Invoke-DispatcherWait {
param([int]$Milliseconds)
$frame = [System.Windows.Threading.DispatcherFrame]::new()
$timer = [System.Windows.Threading.DispatcherTimer]::new(
[System.Windows.Threading.DispatcherPriority]::Background
)
$timer.Interval = [TimeSpan]::FromMilliseconds($Milliseconds)
$timer.Add_Tick({
$timer.Stop()
$frame.Continue = $false
})
$timer.Start()
[System.Windows.Threading.Dispatcher]::PushFrame($frame)
}
$resolvedVideo = (Resolve-Path -LiteralPath $VideoPath).Path
$resolvedOutput = [System.IO.Path]::GetFullPath($OutputDirectory)
[System.IO.Directory]::CreateDirectory($resolvedOutput) | Out-Null
$player = [System.Windows.Media.MediaPlayer]::new()
$player.ScrubbingEnabled = $true
$player.Open([Uri]::new($resolvedVideo))
$player.Play()
for ($attempt = 0; $attempt -lt 100 -and $player.NaturalVideoWidth -eq 0; $attempt++) {
Invoke-DispatcherWait -Milliseconds 100
}
if ($player.NaturalVideoWidth -eq 0 -or -not $player.NaturalDuration.HasTimeSpan) {
$player.Close()
throw "Unable to decode video: $resolvedVideo"
}
$width = $player.NaturalVideoWidth
$height = $player.NaturalVideoHeight
$duration = $player.NaturalDuration.TimeSpan.TotalSeconds
$rangeStart = [Math]::Min([Math]::Max($StartSeconds, 0), $duration)
$rangeEnd = if ($EndSeconds -lt 0) {
$duration
} else {
[Math]::Min([Math]::Max($EndSeconds, $rangeStart), $duration)
}
$metadata = [ordered]@{
path = $resolvedVideo
width = $width
height = $height
durationSeconds = [Math]::Round($duration, 3)
frames = @()
}
$player.Stop()
$player.Position = [TimeSpan]::Zero
$player.SpeedRatio = 16
$player.Play()
for ($index = 0; $index -lt $FrameCount; $index++) {
$ratio = if ($FrameCount -eq 1) { 0.5 } else { $index / ($FrameCount - 1) }
$seconds = $rangeStart + (($rangeEnd - $rangeStart) * $ratio)
$seconds = [Math]::Min([Math]::Max($seconds, 0), [Math]::Max($duration - 0.08, 0))
$deadline = [DateTime]::UtcNow.AddSeconds(45)
while ($player.Position.TotalSeconds -lt $seconds -and [DateTime]::UtcNow -lt $deadline) {
Invoke-DispatcherWait -Milliseconds 40
}
Invoke-DispatcherWait -Milliseconds 80
$visual = [System.Windows.Media.DrawingVisual]::new()
$context = $visual.RenderOpen()
$context.DrawVideo(
$player,
[Windows.Rect]::new(0, 0, $width, $height)
)
$context.Close()
$bitmap = [System.Windows.Media.Imaging.RenderTargetBitmap]::new(
$width,
$height,
96,
96,
[System.Windows.Media.PixelFormats]::Pbgra32
)
$bitmap.Render($visual)
$milliseconds = [long][Math]::Round($seconds * 1000)
$fileName = "frame-{0:D2}-{1:D6}ms.png" -f ([int]($index + 1)), $milliseconds
$filePath = Join-Path $resolvedOutput $fileName
$encoder = [System.Windows.Media.Imaging.PngBitmapEncoder]::new()
$encoder.Frames.Add([System.Windows.Media.Imaging.BitmapFrame]::Create($bitmap))
$stream = [System.IO.File]::Open($filePath, [System.IO.FileMode]::Create)
try {
$encoder.Save($stream)
} finally {
$stream.Dispose()
}
$metadata.frames += [ordered]@{
file = $fileName
seconds = [Math]::Round($seconds, 3)
actualSeconds = [Math]::Round($player.Position.TotalSeconds, 3)
}
}
$player.Close()
$metadata | ConvertTo-Json -Depth 4

View File

@@ -0,0 +1,125 @@
from __future__ import annotations
from collections import deque
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
SOURCE_ROOT = Path("D:/")
OUT_ROOT = ROOT / "public" / "assets" / "brain"
LANCZOS = getattr(getattr(Image, "Resampling", Image), "LANCZOS")
ASSETS = {
"memory/bread.png": "ChatGPT Image 2026年7月23日 21_31_30 (1).png",
"memory/carrot.png": "ChatGPT Image 2026年7月23日 21_31_32 (2).png",
"memory/coffee.png": "ChatGPT Image 2026年7月23日 21_31_34 (4).png",
"memory/cookie.png": "ChatGPT Image 2026年7月23日 21_31_35 (5).png",
"memory/egg.png": "ChatGPT Image 2026年7月23日 21_31_36 (6).png",
"memory/fish.png": "ChatGPT Image 2026年7月23日 21_31_38 (7).png",
"memory/orange.png": "ChatGPT Image 2026年7月23日 21_31_39 (8).png",
"memory/cheese.png": "ChatGPT Image 2026年7月23日 21_31_40.png",
"planes/teal.png": "ChatGPT Image 2026年7月23日 21_31_44 (9).png",
"planes/coral.png": "ChatGPT Image 2026年7月23日 21_31_45 (10).png",
"symbols/camera.png": "ChatGPT Image 2026年7月23日 21_40_52 (1).png",
"symbols/sun.png": "ChatGPT Image 2026年7月23日 21_40_52 (2).png",
"symbols/squares.png": "ChatGPT Image 2026年7月23日 21_40_53 (3).png",
"symbols/lock.png": "ChatGPT Image 2026年7月23日 21_40_53 (4).png",
"symbols/lightning.png": "ChatGPT Image 2026年7月23日 21_40_53 (5).png",
"symbols/moon.png": "ChatGPT Image 2026年7月23日 21_40_53 (6).png",
"symbols/gear.png": "ChatGPT Image 2026年7月23日 21_40_53 (7).png",
"symbols/star.png": "ChatGPT Image 2026年7月23日 21_40_53 (8).png",
"symbols/puzzle.png": "ChatGPT Image 2026年7月23日 21_40_53 (9).png",
"symbols/coffee.png": "ChatGPT Image 2026年7月23日 21_40_53 (10).png",
}
def has_real_alpha(image: Image.Image) -> bool:
if "A" not in image.getbands():
return False
alpha = image.getchannel("A")
lo, hi = alpha.getextrema()
return lo < 255 and hi == 255
def remove_generated_checkerboard(image: Image.Image) -> Image.Image:
rgba = image.convert("RGBA")
rgba.thumbnail((720, 720), LANCZOS)
pixels = rgba.load()
width, height = rgba.size
def is_background(x: int, y: int) -> bool:
red, green, blue, _ = pixels[x, y]
high = max(red, green, blue)
low = min(red, green, blue)
return low >= 218 and high - low <= 18
# Flood only from the canvas edge. This removes the connected checkerboard
# without punching holes into pale objects such as the teal plane or egg.
queue: deque[tuple[int, int]] = deque()
visited = bytearray(width * height)
for x in range(width):
queue.append((x, 0))
queue.append((x, height - 1))
for y in range(height):
queue.append((0, y))
queue.append((width - 1, y))
while queue:
x, y = queue.popleft()
offset = y * width + x
if visited[offset] or not is_background(x, y):
continue
visited[offset] = 1
if x:
queue.append((x - 1, y))
if x + 1 < width:
queue.append((x + 1, y))
if y:
queue.append((x, y - 1))
if y + 1 < height:
queue.append((x, y + 1))
for y in range(height):
for x in range(width):
red, green, blue, _ = pixels[x, y]
alpha = 0 if visited[y * width + x] else 255
pixels[x, y] = (red, green, blue, alpha)
bounds = rgba.getbbox()
if bounds:
rgba = rgba.crop(bounds)
return rgba
def copy_or_convert(source: Path, destination: Path) -> None:
destination.parent.mkdir(parents=True, exist_ok=True)
with Image.open(source) as image:
if has_real_alpha(image):
prepared = image.convert("RGBA")
bounds = prepared.getbbox()
if bounds:
prepared = prepared.crop(bounds)
prepared.thumbnail((720, 720), LANCZOS)
else:
prepared = remove_generated_checkerboard(image)
prepared.save(destination, optimize=True)
def main() -> None:
missing: list[str] = []
for output, source_name in ASSETS.items():
source = SOURCE_ROOT / source_name
destination = OUT_ROOT / output
if not source.exists():
missing.append(source_name)
continue
copy_or_convert(source, destination)
if missing:
raise SystemExit("Missing assets:\n" + "\n".join(missing))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env node
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const dist = path.join(root, "dist");
const index = path.join(dist, "client", "index.html");
const worker = path.join(root, "worker", "index.js");
const hosting = path.join(root, ".openai", "hosting.json");
for (const file of [index, worker, hosting]) {
if (!existsSync(file)) throw new Error("Missing Sites build input: " + file);
}
mkdirSync(path.join(dist, "server"), { recursive: true });
mkdirSync(path.join(dist, ".openai"), { recursive: true });
copyFileSync(worker, path.join(dist, "server", "index.js"));
copyFileSync(hosting, path.join(dist, ".openai", "hosting.json"));
console.log("Prepared Sites build: dist/server/index.js and dist/.openai/hosting.json");

View File

@@ -0,0 +1,58 @@
@echo off
setlocal
cd /d "%~dp0.."
set "BUNDLED_NODE=%USERPROFILE%\.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin"
set "BUNDLED_PNPM=%USERPROFILE%\.cache\codex-runtimes\codex-primary-runtime\dependencies\bin\fallback\pnpm.cmd"
if exist "%BUNDLED_PNPM%" goto bundled_check
where npm.cmd >nul 2>nul
if errorlevel 1 goto missing
if not exist "node_modules" goto npm_install
node -e "import('vite')" >nul 2>nul
if errorlevel 1 goto npm_repair
goto npm_run
:npm_repair
echo Existing dependencies are damaged. Reinstalling...
rmdir /s /q "%CD%\node_modules"
:npm_install
call npm.cmd install
if errorlevel 1 goto install_failed
:npm_run
call npm.cmd run dev -- --host 0.0.0.0 --port 4173
exit /b %errorlevel%
:bundled_check
set "PATH=%BUNDLED_NODE%;%PATH%"
if not exist "node_modules" goto bundled_install
"%BUNDLED_NODE%\node.exe" -e "import('vite')" >nul 2>nul
if errorlevel 1 goto bundled_repair
goto bundled_run
:bundled_repair
echo Existing dependencies are damaged. Reinstalling...
rmdir /s /q "%CD%\node_modules"
:bundled_install
call "%BUNDLED_PNPM%" install --frozen-lockfile
if errorlevel 1 goto install_failed
:bundled_run
call "%BUNDLED_PNPM%" dev --host 0.0.0.0 --port 4173
exit /b %errorlevel%
:install_failed
echo.
echo Dependency installation failed. Check the network, then run this launcher again.
pause
exit /b 1
:missing
echo No local web runtime was found.
echo Install Node.js or reopen this project in Codex, then retry.
pause
exit /b 1

View File

@@ -0,0 +1,48 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const lockPath = path.join(root, "mobile-runtime.lock.json");
const protectedFiles = [
"scripts/check-mobile-runtime.mjs",
"scripts/prepare-sites-build.mjs",
"scripts/update-mobile-runtime-lock.mjs",
"vite.config.ts",
"src/App.tsx",
"src/main.tsx",
"src/styles.css",
"src/mobile/BottomSheet.tsx",
"src/mobile/Carousel.tsx",
"src/mobile/Device.tsx",
"src/mobile/FlowStack.tsx",
"src/mobile/Keyboard.tsx",
"src/mobile/MobileCursor.tsx",
"src/mobile/MobileRuntime.tsx",
"src/mobile/MobileScroll.tsx",
"src/mobile/PhoneFrame.tsx",
"src/mobile/assets.ts",
"src/mobile/components.tsx",
"src/mobile/geometry.ts",
"src/mobile/index.ts",
"public/assets/iphone/Bezel.png",
"public/assets/iphone/Keyboard.png",
"public/assets/android/Pixel10.png",
"public/assets/android/Keyboard.png",
"public/assets/android/navigation-bar.svg",
"public/assets/status/status-icons.svg",
"public/assets/status/ios-status-icons.svg",
"worker/index.js",
];
const hashes = {};
for (const relativePath of protectedFiles) {
const filePath = path.join(root, relativePath);
if (!existsSync(filePath)) throw new Error(`Protected runtime file is missing: ${relativePath}`);
hashes[relativePath] = createHash("sha256").update(readFileSync(filePath)).digest("hex");
}
writeFileSync(lockPath, `${JSON.stringify(hashes, null, 2)}\n`);
console.log(`Updated mobile-runtime.lock.json (${protectedFiles.length} protected files).`);