diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..d824c6a2bc809812c92d3a95c962f6a2c91d74ef --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +--- +title: 腱鞘炎 3D 医学科普动画 +emoji: 🖐️ +colorFrom: indigo +colorTo: red +sdk: static +app_file: index.html +pinned: false +--- + +# 腱鞘炎 · 3D 人体解剖科普动画(网页实时渲染版) + +> 类型:医学科普 + 3D 人体解剖动画 | 时长:约 80–90 秒 | 画幅:9:16 竖屏 | 风格:真实医学 3D、清晰、干净、专业、易懂 +> 所有解剖结构、人物手部、器械均为**原创程序化 3D 医学示意模型**,不使用任何医院、品牌、教材或受版权保护的现成素材。 + +--- + +## 一、项目目标 + +1. 在网页中直接播放一部完整的 3D 动画视频(不是预渲染 mp4),含 **旁白配音 + 背景音乐 + 音效 + 字幕**。 +2. 网页内 **浏览器本地渲染导出 MP4**(WebCodecs 编码,H.264 + AAC/Opus),可选 **24 / 30 / 60 fps**,输出 1080×1920。 +3. **适配手机竖屏观看**:播放器自动铺满竖屏,支持全屏、点击暂停、拖动进度。 +4. 提供四大平台(小红书 / 抖音 / 哔哩哔哩 / 微信视频号)可一键复制的 **标题、描述、话题标签**,并按各平台规格生成 **封面图**(PNG 下载)。 + +## 二、技术方案 + +| 模块 | 技术 | +|---|---| +| 3D 渲染 | Three.js(WebGL),程序化建模:手部皮肤、骨骼、肌腱、腱鞘、器械、生活道具 | +| 分层透视 | 皮肤「深度预写 + 半透明」双通道渲染,实现透明皮肤无接缝;骨骼 / 肌腱 / 腱鞘三层独立淡入淡出 | +| 疼痛表达 | 统一红色发光(加色混合 Sprite + 肌腱着色器中的摩擦热区 黄→红) | +| 肌腱滑动 | 每帧按关节世界坐标重建 Tube 曲线,纹理 UV 随拇指屈伸偏移,表现肌腱在腱鞘内滑动 | +| 时间轴 | 纯函数 `timeline(t) → 场景状态`,同一时刻永远得到同一画面(保证导出逐帧一致) | +| 2D 叠加层 | Canvas 2D 绘制字幕、关键词、标注、超声画面、四宫格、分屏标签 | +| 旁白 | 中文神经网络 TTS 预生成逐句音频(构建期生成,存于 `public/audio`),句级精确字幕对齐 | +| 音乐/音效 | Web Audio `OfflineAudioContext` 程序化合成:氛围铺底和弦、低频脉冲、转场 whoosh、疼痛刺痛音、扫描提示音、松解释放音;旁白时自动压低音乐(ducking) | +| 导出 | mediabunny(MP4 封装)+ WebCodecs `VideoEncoder/AudioEncoder`,逐帧离线渲染,不依赖实时速度 | +| 封面 | 同一 3D 场景按 4:3 / 3:4 / 16:9 重新取景渲染 + 排版标题 | + +## 三、分镜大纲(11 个镜头 + 结尾互动) + +| # | 镜头 | 关键画面 | +|---|---|---| +| 1 | 疼痛开始 | 手部特写,拇指活动、抓握 → 疼痛抖动 → 推镜至腕部拇指侧 → 红色发光 → 皮肤半透明 → 骨骼 / 肌腱 / 腱鞘逐层出现 | +| 2 | 腱鞘炎发生了什么 | 肌腱在腱鞘中顺畅滑动 → 腱鞘增厚、空间变窄 → 摩擦区 黄→红;横截面插图;关键词「腱鞘增厚 / 空间变窄 / 活动疼痛」 | +| 3 | 为什么一动就疼 | 抓、捏、拧瓶盖三个动作,内部肌腱同步运动,红区随动作增强,镜头跟随疼痛点 | +| 4 | 哪些人更容易出现 | 键盘鼠标 / 工具抓握 / 家务拧提 / 抱孩子 四场景快切,红点闪现 + 关键词 | +| 5 | 不要一上来就治疗 | 3D 手旋转 → 锁定疼痛区 → 肌腱轨迹 → 超声画面(肌腱 / 腱鞘 / 狭窄位置) | +| 6 | 常见治疗方法 | 四宫格:减少抓握 / 拇指腕部支具 / 局部治疗示意 / 手术松解示意 | +| 7 | 进入小针刀 | 腱鞘放大,红区标记狭窄,原创器械从皮肤外缓慢进入 | +| 8 | 治疗过程 | 超声定位白点 → 消毒 → 局部麻醉 → 器械到达目标区域松解(3D 剖面,无血腥) | +| 9 | 松解前后对比 | 左右分屏:治疗前(狭窄、受限、红)vs 治疗后示意(变宽、顺滑、红区减弱) | +| 10 | 从疼痛到恢复 | 拇指困难 → 轻微活动 → 抓握 → 拿杯子 → 用手机 → 自然活动,红区渐淡 | +| 11 | 记忆点 | 再次进入 3D → 拉远恢复完整手部 → 红区消失 → 金句「手越疼,越不能只靠硬扛。」 | +| 尾 | 评论区互动 | 「评论区告诉我,是抓、捏、拧,还是抱孩子?」 | + +## 四、实施步骤(每完成一大步推送一次到本 Space) + +1. ✅ 编写本计划 README,初始化 Space(static)。 +2. ⏳ 生成中文旁白逐句音频 + 时间轴清单(`timeline.json`)。 +3. ⏳ 程序化 3D 手部解剖模型:皮肤 / 骨骼 / 肌腱 / 腱鞘 / 关节骨架绑定 / 疼痛发光 / 器械与道具。 +4. ⏳ 分镜时间轴:11 镜头 + 结尾的相机运动、姿态、图层、字幕、HUD。 +5. ⏳ 程序化音乐与音效、旁白混音、ducking。 +6. ⏳ 播放器 UI(手机竖屏适配、全屏、进度条)+ MP4 本地导出(24/30/60fps)。 +7. ⏳ 四平台文案(标题 / 话题 / 正文,严格字数校验)+ 封面生成与下载。 +8. ⏳ 生产构建、检查,推送最终静态站点。 + +## 五、目录结构(推送到 Space 的内容) + +``` +index.html, assets/ ← 构建产物,Space 直接可运行 +audio/ ← 旁白音频 + timeline.json +src/ ← 源码(engine / scenes / audio / export / covers / copy) +README.md ← 本文件(计划 + 进度) +``` + +## 六、进度日志 + +- [x] Step 1:计划与大纲完成 diff --git a/index.html b/index.html new file mode 100644 index 0000000000000000000000000000000000000000..b3b8a8f1528e2456759f9c880a4e8f91aca94b08 --- /dev/null +++ b/index.html @@ -0,0 +1 @@ +腱鞘炎 3D 科普动画 · 制作中

腱鞘炎 3D 医学科普动画

项目制作中,进度见 README。

diff --git a/scripts/placeholder.html b/scripts/placeholder.html new file mode 100644 index 0000000000000000000000000000000000000000..b3b8a8f1528e2456759f9c880a4e8f91aca94b08 --- /dev/null +++ b/scripts/placeholder.html @@ -0,0 +1 @@ +腱鞘炎 3D 科普动画 · 制作中

腱鞘炎 3D 医学科普动画

项目制作中,进度见 README。

diff --git a/scripts/push_hf.py b/scripts/push_hf.py new file mode 100644 index 0000000000000000000000000000000000000000..9fa767c5cec98830fb180e649f9c27d179e7dd2b --- /dev/null +++ b/scripts/push_hf.py @@ -0,0 +1,36 @@ +"""Push project progress to HF Space Ethscriptions/mx. +Usage: HF_TOKEN=... python3 scripts/push_hf.py "commit message" +Uploads README.md, frontend/src -> src/, frontend/public/audio -> audio/, and built static site (dist_static) at root. +""" +import os, sys +from huggingface_hub import HfApi, CommitOperationAdd + +REPO = "Ethscriptions/mx" +api = HfApi(token=os.environ["HF_TOKEN"]) +root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +msg = sys.argv[1] if len(sys.argv) > 1 else "update" +ops = [] + +def add_dir(local, remote_prefix): + if not os.path.isdir(local): + return + for dp, _, files in os.walk(local): + for f in files: + lp = os.path.join(dp, f) + rel = os.path.relpath(lp, local).replace(os.sep, "/") + ops.append(CommitOperationAdd(path_in_repo=f"{remote_prefix}{rel}", path_or_fileobj=lp)) + +ops.append(CommitOperationAdd(path_in_repo="README.md", path_or_fileobj=os.path.join(root, "README.md"))) +add_dir(os.path.join(root, "frontend", "src"), "src/") +add_dir(os.path.join(root, "scripts"), "scripts/") +static = os.path.join(root, "dist_static") +if os.path.isdir(static): + add_dir(static, "") # includes index.html, assets/, audio/ +else: + add_dir(os.path.join(root, "frontend", "public", "audio"), "audio/") + placeholder = os.path.join(root, "scripts", "placeholder.html") + if os.path.exists(placeholder): + ops.append(CommitOperationAdd(path_in_repo="index.html", path_or_fileobj=placeholder)) + +api.create_commit(repo_id=REPO, repo_type="space", operations=ops, commit_message=msg) +print("pushed", len(ops), "files:", msg) diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9f12606de706e7d27baae53e89f18955d586f3be --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,24 @@ +const SURF_LOGO_PATH = "M14.6875 13.333C15.0977 13.333 15.4859 13.5196 15.7422 13.8398L17.8721 16.502C17.9009 16.538 17.9308 16.5721 17.9619 16.6035C18.3091 16.9542 18.7529 17.3405 18.7529 17.834C18.7528 18.3399 18.3428 18.75 17.8369 18.75H17.5713C17.2123 18.75 16.8727 18.5869 16.6484 18.3066L15.4678 16.8311C14.761 15.9479 13.3369 16.4478 13.3369 17.5791V18.6494C13.3369 18.7062 13.2882 18.75 13.2314 18.75H11.3584C10.7127 18.75 10.1016 18.4564 9.69824 17.9521L8.80176 16.8311C8.09497 15.9477 6.66993 16.4478 6.66992 17.5791V18.6494C6.66991 18.7062 6.62124 18.75 6.56445 18.75H4.69141C4.04589 18.75 3.43548 18.4562 3.03223 17.9521L2.13477 16.8311C1.77885 16.3862 1.25293 15.931 1.25293 15.3613V13.4395C1.253 13.3822 1.29729 13.3331 1.35449 13.333C1.7646 13.3332 2.15295 13.5196 2.40918 13.8398L4.53809 16.502C5.24478 17.3853 6.66962 16.886 6.66992 15.7549V14.6836C6.67018 13.9379 7.27483 13.3332 8.02051 13.333C8.43065 13.333 8.81884 13.5197 9.0752 13.8398L11.2051 16.502C11.9097 17.3827 13.3282 16.8887 13.3369 15.7646V14.6836C13.3372 13.9378 13.9417 13.3331 14.6875 13.333ZM14.6875 6.66699C15.0978 6.66702 15.4859 6.85347 15.7422 7.17383L17.8721 9.83594C18.2276 10.2801 18.7526 10.7339 18.7529 11.3027V13.2266C18.7528 13.2833 18.7091 13.333 18.6523 13.333C18.2421 13.3329 17.8539 13.1465 17.5977 12.8262L15.4678 10.1641C14.7609 9.28113 13.3369 9.78089 13.3369 10.9121V11.9824C13.3369 12.7282 12.7321 13.3328 11.9863 13.333C11.5761 13.3329 11.187 13.1465 10.9307 12.8262L8.80176 10.1641C8.09497 9.28072 6.66993 9.78076 6.66992 10.9121V11.9824C6.66992 12.7284 6.06525 13.3329 5.31934 13.333C4.90908 13.3329 4.52094 13.1465 4.26465 12.8262L2.13477 10.1641C1.7788 9.71925 1.25293 9.26403 1.25293 8.69434V6.77344C1.253 6.71621 1.29728 6.6671 1.35449 6.66699C1.76461 6.66714 2.15295 6.85359 2.40918 7.17383L4.53809 9.83594C5.24487 10.7193 6.66989 10.2192 6.66992 9.08789V8.01758C6.66992 7.27168 7.27467 6.66722 8.02051 6.66699C8.43082 6.66699 8.81885 6.85345 9.0752 7.17383L11.2051 9.83594C11.9097 10.7163 13.3279 10.2223 13.3369 9.09863V8.01758C13.3369 7.27161 13.9416 6.6671 14.6875 6.66699ZM2.43555 1.25C2.79488 1.25 3.13499 1.41367 3.35938 1.69434L4.53809 3.16895C5.24485 4.05241 6.66991 3.55228 6.66992 2.4209V1.35059C6.66993 1.29391 6.71874 1.25017 6.77539 1.25H8.64844C9.29394 1.25 9.90438 1.5438 10.3076 2.04785L11.2051 3.16895C11.9097 4.04967 13.3282 3.55564 13.3369 2.43164V1.35059C13.3369 1.29391 13.3857 1.25017 13.4424 1.25H15.3154C15.9609 1.25 16.5714 1.5438 16.9746 2.04785L17.8721 3.16895C18.2275 3.61314 18.7526 4.06689 18.7529 4.63574V6.56055C18.7528 6.61728 18.7091 6.66699 18.6523 6.66699C18.2421 6.66688 17.8539 6.4795 17.5977 6.15918L15.4678 3.49805C14.761 2.61474 13.3371 3.11388 13.3369 4.24512V5.31543C13.3369 6.06126 12.7321 6.66673 11.9863 6.66699C11.5761 6.66693 11.187 6.47955 10.9307 6.15918L8.80176 3.49805C8.095 2.61474 6.67006 3.11388 6.66992 4.24512V5.31543C6.66992 6.06137 6.06525 6.66691 5.31934 6.66699C4.90908 6.66693 4.52094 6.47955 4.26465 6.15918L2.13477 3.49805C2.10558 3.46158 2.07543 3.42725 2.04395 3.39551C1.6966 3.04528 1.25293 2.6583 1.25293 2.16504C1.25317 1.65963 1.66348 1.25 2.16895 1.25H2.43555Z" + +function SurfLogo() { + return ( + + + + ) +} + +// Frontend API calls should use `src/lib/api.ts`, not absolute `/api/...` URLs. +export default function App() { + return ( +
+ +

+ GM, Builder. +

+

+ Start your crypto project here! +

+
+ ) +} diff --git a/src/ErrorBoundary.tsx b/src/ErrorBoundary.tsx new file mode 100644 index 0000000000000000000000000000000000000000..46b25fd85f8f22e4ff300deaa13e6f88cd82bd53 --- /dev/null +++ b/src/ErrorBoundary.tsx @@ -0,0 +1,106 @@ +import { Component, type ReactNode } from 'react' + +interface Props { children: ReactNode; fallback?: ReactNode } +interface State { error: Error | null } + +// Auto-reload config for transient dep-optimization errors. +// Shares the same key as entry-client's cold-start guard so reload +// attempts are counted together, preventing double reload loops. +const RELOAD_KEY = '__dep_reload' +const MAX_RELOADS = 6 +// If the last reload was more than this many ms ago, reset the counter. +// This prevents stale counters from blocking legitimate retries on a +// later visit, while still capping rapid reload loops. +const RELOAD_WINDOW_MS = 60_000 + +// Patterns that indicate React modules loaded as stubs (dep optimization in progress) +const DEP_OPT_PATTERNS = [ + "reading 'useState'", + "reading 'useEffect'", + "reading 'useRef'", + "reading 'useCallback'", + "reading 'useMemo'", + "reading 'useContext'", + "reading 'useReducer'", +] + +function isDepOptError(msg: string): boolean { + return DEP_OPT_PATTERNS.some((p) => msg.includes(p)) +} + +// Shared format with entry-client: { c: count, t: timestamp } +function getReloadState(): { c: number; t: number } { + try { + const raw = sessionStorage.getItem(RELOAD_KEY) + if (raw) return JSON.parse(raw) + } catch { /* ignore parse errors */ } + return { c: 0, t: 0 } +} + +export default class ErrorBoundary extends Component { + state: State = { error: null } + + static getDerivedStateFromError(error: Error) { + return { error } + } + + componentDidCatch(error: Error) { + if (!isDepOptError(error.message)) return + // Vite dep optimization may serve React stubs during cold start. + // Auto-reload so the browser fetches the real modules once ready. + const prev = getReloadState() + // Reset counter if outside the rapid-reload window (stale from earlier visit) + const count = (Date.now() - prev.t > RELOAD_WINDOW_MS) ? 0 : prev.c + if (count < MAX_RELOADS) { + sessionStorage.setItem(RELOAD_KEY, JSON.stringify({ c: count + 1, t: Date.now() })) + setTimeout(() => location.reload(), 3000) + } + } + + render() { + if (this.state.error) { + if (this.props.fallback) return this.props.fallback + + // Show a friendlier message for dep optimization errors that will auto-reload + if (isDepOptError(this.state.error.message)) { + const { c, t } = getReloadState() + const fresh = (Date.now() - t <= RELOAD_WINDOW_MS) + if (!fresh || c < MAX_RELOADS) { + return ( +
+

Loading dependencies...

+

Reloading automatically

+
+ ) + } + } + + return ( +
+

Component Error

+

{this.state.error.message}

+
+ ) + } + return this.props.children + } +} diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e1797c93458f3751a2e0f1189ff50d77f20ae3ed --- /dev/null +++ b/src/components/ui/accordion.tsx @@ -0,0 +1,55 @@ +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5afd41d142c95c74069ebb5460117e74ead3b9df --- /dev/null +++ b/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/src/components/ui/aspect-ratio.tsx b/src/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c4abbf37f217c715a0eaade7f45ac78600df419f --- /dev/null +++ b/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,5 @@ +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +const AspectRatio = AspectRatioPrimitive.Root + +export { AspectRatio } diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..991f56ecb117e96284bf0f6cad3b14ea2fdf5264 --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e87d62bf1a2b2a0e1cf0b0dc94c2836525f40b67 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/breadcrumb.tsx b/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000000000000000000000000000000000000..60e6c96f72f0350d08b47e4730cab8f3975dc853 --- /dev/null +++ b/src/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) =>