首次提交:脑晴测 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:
33
brain-h5/scripts/check-mobile-runtime.mjs
Normal file
33
brain-h5/scripts/check-mobile-runtime.mjs
Normal 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).`);
|
||||
122
brain-h5/scripts/extract-video-frames.ps1
Normal file
122
brain-h5/scripts/extract-video-frames.ps1
Normal 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
|
||||
125
brain-h5/scripts/prepare-brain-assets.py
Normal file
125
brain-h5/scripts/prepare-brain-assets.py
Normal 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()
|
||||
21
brain-h5/scripts/prepare-sites-build.mjs
Normal file
21
brain-h5/scripts/prepare-sites-build.mjs
Normal 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");
|
||||
58
brain-h5/scripts/start-local.cmd
Normal file
58
brain-h5/scripts/start-local.cmd
Normal 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
|
||||
48
brain-h5/scripts/update-mobile-runtime-lock.mjs
Normal file
48
brain-h5/scripts/update-mobile-runtime-lock.mjs
Normal 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).`);
|
||||
Reference in New Issue
Block a user