Ethscriptions commited on
Commit
ef111a2
·
verified ·
1 Parent(s): d580ec1

ugly-duckling: 共用多片引擎、字幕不闪、系列互链

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +9 -0
  2. ugly-duckling/README.md +1 -0
  3. ugly-duckling/dist/assets/App-BEh-_Ljg.js +0 -0
  4. ugly-duckling/dist/assets/index-DjlK4Epu.js +2 -0
  5. ugly-duckling/dist/assets/index-Dujsqd5W.css +1 -0
  6. ugly-duckling/dist/film/fonts/chars.txt +1 -1
  7. ugly-duckling/dist/film/fonts/mashanzheng.woff2 +2 -2
  8. ugly-duckling/dist/film/fonts/wenkai.woff2 +2 -2
  9. ugly-duckling/dist/index.html +2 -2
  10. ugly-duckling/scripts/dbg_draw.mjs +1 -1
  11. ugly-duckling/scripts/make_live.py +9 -6
  12. ugly-duckling/scripts/qa_report.py +21 -4
  13. ugly-duckling/scripts/qa_subband.py +2 -2
  14. ugly-duckling/scripts/render_live.mjs +1 -1
  15. ugly-duckling/video-portrait/film.bundle.js +0 -0
  16. ugly-duckling/video-portrait/film/fonts/mashanzheng.woff2 +2 -2
  17. ugly-duckling/video-portrait/film/fonts/wenkai.woff2 +2 -2
  18. ugly-duckling/video/film.bundle.js +0 -0
  19. ugly-duckling/video/film/fonts/mashanzheng.woff2 +2 -2
  20. ugly-duckling/video/film/fonts/wenkai.woff2 +2 -2
  21. ugly-duckling/web/public/film/fonts/chars.txt +1 -1
  22. ugly-duckling/web/public/film/fonts/mashanzheng.woff2 +2 -2
  23. ugly-duckling/web/public/film/fonts/wenkai.woff2 +2 -2
  24. ugly-duckling/web/public/wolf/live/01-idea.jpg +3 -0
  25. ugly-duckling/web/public/wolf/live/02-shout.jpg +3 -0
  26. ugly-duckling/web/public/wolf/live/03-run.jpg +3 -0
  27. ugly-duckling/web/public/wolf/live/04-laugh.jpg +3 -0
  28. ugly-duckling/web/public/wolf/live/05-eyes.jpg +3 -0
  29. ugly-duckling/web/public/wolf/live/06-regret.jpg +3 -0
  30. ugly-duckling/web/public/wolf/qa/sheet_landscape.jpg +3 -0
  31. ugly-duckling/web/public/wolf/qa/sheet_portrait.jpg +3 -0
  32. ugly-duckling/web/public/wolf/voice.mp3 +3 -0
  33. ugly-duckling/web/src/App.tsx +19 -11
  34. ugly-duckling/web/src/components/Covers.tsx +10 -8
  35. ugly-duckling/web/src/components/ExportPanel.tsx +3 -3
  36. ugly-duckling/web/src/components/LivePhotos.tsx +5 -5
  37. ugly-duckling/web/src/components/Making.tsx +5 -11
  38. ugly-duckling/web/src/components/Player.tsx +2 -2
  39. ugly-duckling/web/src/components/PublishCopy.tsx +4 -3
  40. ugly-duckling/web/src/film/audio.ts +39 -214
  41. ugly-duckling/web/src/film/film.ts +21 -11
  42. ugly-duckling/web/src/film/overlays.ts +5 -13
  43. ugly-duckling/web/src/film/standalone.ts +9 -6
  44. ugly-duckling/web/src/film/types.ts +69 -0
  45. ugly-duckling/web/src/films/duckling/content.ts +97 -0
  46. ugly-duckling/web/src/films/duckling/cues.ts +15 -0
  47. ugly-duckling/web/src/films/duckling/index.ts +42 -0
  48. ugly-duckling/web/src/films/duckling/live.json +8 -0
  49. ugly-duckling/web/src/films/duckling/music.ts +201 -0
  50. ugly-duckling/web/src/films/duckling/qa.json +244 -0
.gitattributes CHANGED
@@ -215,3 +215,12 @@ wolf-coming/web/public/wolf/live/05-eyes.jpg filter=lfs diff=lfs merge=lfs -text
215
  wolf-coming/web/public/wolf/live/06-regret.jpg filter=lfs diff=lfs merge=lfs -text
216
  wolf-coming/web/public/wolf/qa/sheet_landscape.jpg filter=lfs diff=lfs merge=lfs -text
217
  wolf-coming/web/public/wolf/qa/sheet_portrait.jpg filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
215
  wolf-coming/web/public/wolf/live/06-regret.jpg filter=lfs diff=lfs merge=lfs -text
216
  wolf-coming/web/public/wolf/qa/sheet_landscape.jpg filter=lfs diff=lfs merge=lfs -text
217
  wolf-coming/web/public/wolf/qa/sheet_portrait.jpg filter=lfs diff=lfs merge=lfs -text
218
+ ugly-duckling/web/public/wolf/live/01-idea.jpg filter=lfs diff=lfs merge=lfs -text
219
+ ugly-duckling/web/public/wolf/live/02-shout.jpg filter=lfs diff=lfs merge=lfs -text
220
+ ugly-duckling/web/public/wolf/live/03-run.jpg filter=lfs diff=lfs merge=lfs -text
221
+ ugly-duckling/web/public/wolf/live/04-laugh.jpg filter=lfs diff=lfs merge=lfs -text
222
+ ugly-duckling/web/public/wolf/live/05-eyes.jpg filter=lfs diff=lfs merge=lfs -text
223
+ ugly-duckling/web/public/wolf/live/06-regret.jpg filter=lfs diff=lfs merge=lfs -text
224
+ ugly-duckling/web/public/wolf/qa/sheet_landscape.jpg filter=lfs diff=lfs merge=lfs -text
225
+ ugly-duckling/web/public/wolf/qa/sheet_portrait.jpg filter=lfs diff=lfs merge=lfs -text
226
+ ugly-duckling/web/public/wolf/voice.mp3 filter=lfs diff=lfs merge=lfs -text
ugly-duckling/README.md CHANGED
@@ -103,6 +103,7 @@ ugly-duckling/
103
  - **2026-10-17** **最终质检**:竖屏 `vibe qa` 规格 / 响度 −14.0 LUFS / 真峰值 −1.4 dBTP / 黑场 / 冻结 / 13 场字幕 OCR 全 PASS(倒影一场为检查窗口跨两句字幕的误报,当前字幕已读出);横屏同样规格全 PASS,字幕带补充检查 13/13 PASS(墨色对纸带对比度 10–13:1)。结果写入网页「制作与质检」。
104
  - **2026-10-17** 部署:`dist/` 由 `vite build --base ./` 生成,Dockerfile 新增 `COPY ugly-duckling/dist /app/site/ugly-duckling`,访问 `/ugly-duckling/`。
105
  - **2026-10-17** 按反馈修颈部:原先颈线用两个固定偏移点穿样条,任何姿态都被拧成 Z 字形 S 弯(飞行、低头时尤其别扭)。改为随姿态自适应的单段平滑曲线(直立:从胸前竖直升起、到头部才柔和前弯;飞行:几乎笔直前伸;低头:先拱起再垂向水面),颈根粗、近头处渐细并圆润并入胸口。重渲实况图 6 组、横竖屏全片并复跑 `vibe qa`(结论同前:规格 / 响度 / 黑场 / 冻结全 PASS)。
 
106
 
107
  ## 6. 交付清单
108
  | 内容 | 位置 |
 
103
  - **2026-10-17** **最终质检**:竖屏 `vibe qa` 规格 / 响度 −14.0 LUFS / 真峰值 −1.4 dBTP / 黑场 / 冻结 / 13 场字幕 OCR 全 PASS(倒影一场为检查窗口跨两句字幕的误报,当前字幕已读出);横屏同样规格全 PASS,字幕带补充检查 13/13 PASS(墨色对纸带对比度 10–13:1)。结果写入网页「制作与质检」。
104
  - **2026-10-17** 部署:`dist/` 由 `vite build --base ./` 生成,Dockerfile 新增 `COPY ugly-duckling/dist /app/site/ugly-duckling`,访问 `/ugly-duckling/`。
105
  - **2026-10-17** 按反馈修颈部:原先颈线用两个固定偏移点穿样条,任何姿态都被拧成 Z 字形 S 弯(飞行、低头时尤其别扭)。改为随姿态自适应的单段平滑曲线(直立:从胸前竖直升起、到头部才柔和前弯;飞行:几乎笔直前伸;低头:先拱起再垂向水面),颈根粗、近头处渐细并圆润并入胸口。重渲实况图 6 组、横竖屏全片并复跑 `vibe qa`(结论同前:规格 / 响度 / 黑场 / 冻结全 PASS)。
106
+ - **2026-10-18** 随《狼来了》(`wolf-coming/`)把网页工程改为多片:本片代码移到 `web/src/films/duckling/`,共用引擎留在 `web/src/film/`。改造前后 11 个时间点逐帧 MD5 完全一致。另外两处共用改进:同一句内的分段字幕不再淡出淡入(不闪),字幕宣纸纸带上下留白加大。页头新增系列互链。重新构建 `dist/`。
107
 
108
  ## 6. 交付清单
109
  | 内容 | 位置 |
ugly-duckling/dist/assets/App-BEh-_Ljg.js ADDED
The diff for this file is too large to render. See raw diff
 
ugly-duckling/dist/assets/index-DjlK4Epu.js ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./index-Br42UDbl.js","./index-BlGQAcOD.js","./client-BlQxAbrl.js","./index-tKuM8enU.js","./App-BEh-_Ljg.js","./jsx-dev-runtime-Czb3f8pQ.js","./ErrorBoundary-DwultlP_.js"])))=>i.map(i=>d[i]);
2
+ (function(){const o=document.createElement("link").relList;if(o&&o.supports&&o.supports("modulepreload"))return;for(const e of document.querySelectorAll('link[rel="modulepreload"]'))d(e);new MutationObserver(e=>{for(const t of e)if(t.type==="childList")for(const s of t.addedNodes)s.tagName==="LINK"&&s.rel==="modulepreload"&&d(s)}).observe(document,{childList:!0,subtree:!0});function i(e){const t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin==="use-credentials"?t.credentials="include":e.crossOrigin==="anonymous"?t.credentials="omit":t.credentials="same-origin",t}function d(e){if(e.ep)return;e.ep=!0;const t=i(e);fetch(e.href,t)}})();const E="modulepreload",v=function(r,o){return new URL(r,o).href},w={},y=function(o,i,d){let e=Promise.resolve();if(i&&i.length>0){let s=function(n){return Promise.all(n.map(u=>Promise.resolve(u).then(p=>({status:"fulfilled",value:p}),p=>({status:"rejected",reason:p}))))};const l=document.getElementsByTagName("link"),f=document.querySelector("meta[property=csp-nonce]"),h=(f==null?void 0:f.nonce)||(f==null?void 0:f.getAttribute("nonce"));e=s(i.map(n=>{if(n=v(n,d),n in w)return;w[n]=!0;const u=n.endsWith(".css"),p=u?'[rel="stylesheet"]':"";if(!!d)for(let m=l.length-1;m>=0;m--){const g=l[m];if(g.href===n&&(!u||g.rel==="stylesheet"))return}else if(document.querySelector(`link[href="${n}"]${p}`))return;const c=document.createElement("link");if(c.rel=u?"stylesheet":E,u||(c.as="script"),c.crossOrigin="",c.href=n,h&&c.setAttribute("nonce",h),document.head.appendChild(c),u)return new Promise((m,g)=>{c.addEventListener("load",m),c.addEventListener("error",()=>g(new Error(`Unable to preload CSS for ${n}`)))})}))}function t(s){const l=new Event("vite:preloadError",{cancelable:!0});if(l.payload=s,window.dispatchEvent(l),!l.defaultPrevented)throw s}return e.then(s=>{for(const l of s||[])l.status==="rejected"&&t(l.reason);return o().catch(t)})},_="__dep_reload",L=6,O=6e4;function P(){try{return JSON.parse(sessionStorage.getItem(_)||"{}")}catch{return{c:0,t:0}}}async function R(){const r=document.getElementById("root");try{let o=function(){if(!document.querySelector("[data-surf-placeholder]"))try{window.parent.postMessage({type:"surf-app-ready"},"*")}catch{}};const i=await y(()=>import("./index-Br42UDbl.js").then(a=>a.i),__vite__mapDeps([0,1]),import.meta.url);if(typeof i.useState!="function")throw new Error("dep-stub");const{createElement:d}=i,{createRoot:e,hydrateRoot:t}=await y(async()=>{const{createRoot:a,hydrateRoot:c}=await import("./client-BlQxAbrl.js").then(m=>m.c);return{createRoot:a,hydrateRoot:c}},__vite__mapDeps([2,1]),import.meta.url),{QueryClient:s,QueryClientProvider:l}=await y(async()=>{const{QueryClient:a,QueryClientProvider:c}=await import("./index-tKuM8enU.js");return{QueryClient:a,QueryClientProvider:c}},__vite__mapDeps([3,0,1]),import.meta.url),{default:f}=await y(async()=>{const{default:a}=await import("./App-BEh-_Ljg.js");return{default:a}},__vite__mapDeps([4,5,1,0]),import.meta.url),{default:h}=await y(async()=>{const{default:a}=await import("./ErrorBoundary-DwultlP_.js");return{default:a}},__vite__mapDeps([6,5,1,0]),import.meta.url),n=new s({defaultOptions:{queries:{refetchOnWindowFocus:!1,retry:3,retryDelay:a=>Math.min(1e3*2**a,1e4),staleTime:3e4}}}),u=d(h,null,d(l,{client:n},d(f))),p=!!r.querySelector("[data-surf-placeholder]");r.childNodes.length>0&&r.innerHTML!=="<!--ssr-outlet-->"&&!p?t(r,u):(r.innerHTML="",e(r).render(u)),window.__reactOk=!0,sessionStorage.removeItem(_),o(),new MutationObserver(o).observe(r,{childList:!0,subtree:!0})}catch{const o=P(),i=Date.now()-o.t>O?0:o.c;i<L?(r.innerHTML=['<div style="padding:24px;text-align:center;font-family:system-ui,sans-serif">','<p style="color:#3b82f6;font-weight:600;margin:0 0 4px">Loading dependencies...</p>','<p style="color:#3b82f6;opacity:0.7;font-size:12px;margin:0">Reloading automatically</p>',"</div>"].join(""),sessionStorage.setItem(_,JSON.stringify({c:i+1,t:Date.now()})),setTimeout(()=>location.reload(),3e3+i*1e3)):r.innerHTML=['<div style="padding:24px;text-align:center;font-family:system-ui,sans-serif">','<p style="color:#c0392b;font-weight:600;margin:0 0 4px">Failed to load dependencies</p>','<p style="color:#c0392b;opacity:0.8;font-size:12px;margin:0">Please refresh the page</p>',"</div>"].join("")}}R();
ugly-duckling/dist/assets/index-Dujsqd5W.css ADDED
@@ -0,0 +1 @@
 
 
1
+ @import"https://fonts.googleapis.com/css2?family=Lato:wght@400;600;700;900&family=Roboto+Mono:wght@400;500&display=swap";/*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:"";--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-animation-delay:0s;--tw-animation-direction:normal;--tw-animation-duration:initial;--tw-animation-fill-mode:none;--tw-animation-iteration-count:1;--tw-enter-blur:0;--tw-enter-opacity:1;--tw-enter-rotate:0;--tw-enter-scale:1;--tw-enter-translate-x:0;--tw-enter-translate-y:0;--tw-exit-blur:0;--tw-exit-opacity:1;--tw-exit-rotate:0;--tw-exit-scale:1;--tw-exit-translate-x:0;--tw-exit-translate-y:0}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-600:oklch(57.7% .245 27.325);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-lg:32rem;--container-2xl:42rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height: 1.2 ;--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--tracking-widest:.1em;--leading-tight:1.25;--leading-relaxed:1.625;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-2xl:40px;--aspect-video:16 / 9;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-background:var(--background);--color-foreground:var(--foreground);--color-border:var(--border)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*,:after,:before{border-color:var(--color-border)}body{background-color:var(--color-background);color:var(--color-foreground);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:Lato,PingFang SC,sans-serif}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.invisible{visibility:hidden}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing) * 0)}.inset-x-0{inset-inline:calc(var(--spacing) * 0)}.inset-y-0{inset-block:calc(var(--spacing) * 0)}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.-top-12{top:calc(var(--spacing) * -12)}.top-0{top:calc(var(--spacing) * 0)}.top-1{top:calc(var(--spacing) * 1)}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing) * 2)}.top-4{top:calc(var(--spacing) * 4)}.top-\[1px\]{top:1px}.top-\[19px\]{top:19px}.top-\[50\%\]{top:50%}.top-\[60\%\]{top:60%}.top-full{top:100%}.-right-12{right:calc(var(--spacing) * -12)}.right-0{right:calc(var(--spacing) * 0)}.right-1{right:calc(var(--spacing) * 1)}.right-2{right:calc(var(--spacing) * 2)}.right-4{right:calc(var(--spacing) * 4)}.-bottom-12{bottom:calc(var(--spacing) * -12)}.bottom-0{bottom:calc(var(--spacing) * 0)}.-left-12{left:calc(var(--spacing) * -12)}.left-0{left:calc(var(--spacing) * 0)}.left-1\/2{left:50%}.left-2{left:calc(var(--spacing) * 2)}.left-\[50\%\]{left:50%}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.z-\[1\]{z-index:1}.z-\[100\]{z-index:100}.m-auto{margin:auto}.-mx-1{margin-inline:calc(var(--spacing) * -1)}.mx-auto{margin-inline:auto}.my-1{margin-block:calc(var(--spacing) * 1)}.-mt-4{margin-top:calc(var(--spacing) * -4)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-24{margin-top:calc(var(--spacing) * 24)}.mt-auto{margin-top:auto}.mr-1{margin-right:calc(var(--spacing) * 1)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.-ml-4{margin-left:calc(var(--spacing) * -4)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-auto{margin-left:auto}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.aspect-\[3\/4\]{aspect-ratio:3/4}.aspect-\[9\/16\]{aspect-ratio:9/16}.aspect-square{aspect-ratio:1}.aspect-video{aspect-ratio:var(--aspect-video)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-\[--cell-size\]{width:--cell-size;height:--cell-size}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-20{height:calc(var(--spacing) * 20)}.h-\[--cell-size\]{height:--cell-size}.h-\[1px\]{height:1px}.h-\[5px\]{height:5px}.h-\[var\(--radix-navigation-menu-viewport-height\)\]{height:var(--radix-navigation-menu-viewport-height)}.h-\[var\(--radix-select-trigger-height\)\]{height:var(--radix-select-trigger-height)}.h-auto{height:auto}.h-full{height:100%}.h-px{height:1px}.max-h-56{max-height:calc(var(--spacing) * 56)}.max-h-\[--radix-context-menu-content-available-height\]{max-height:--radix-context-menu-content-available-height}.max-h-\[--radix-select-content-available-height\]{max-height:--radix-select-content-available-height}.max-h-\[300px\]{max-height:300px}.max-h-\[var\(--radix-dropdown-menu-content-available-height\)\]{max-height:var(--radix-dropdown-menu-content-available-height)}.max-h-screen{max-height:100vh}.min-h-\[60px\]{min-height:60px}.min-h-screen{min-height:100vh}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-3\/4{width:75%}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-20{width:calc(var(--spacing) * 20)}.w-64{width:calc(var(--spacing) * 64)}.w-72{width:calc(var(--spacing) * 72)}.w-\[--cell-size\]{width:--cell-size}.w-\[1px\]{width:1px}.w-\[100px\]{width:100px}.w-\[min\(100\%\,calc\(min\(78vh\,820px\)\*9\/16\)\)\]{width:min(100%,min(78vh,820px)/1.77778)}.w-fit{width:fit-content}.w-full{width:100%}.w-max{width:max-content}.w-px{width:1px}.max-w-2xl{max-width:var(--container-2xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-lg{max-width:var(--container-lg)}.max-w-max{max-width:max-content}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-8{min-width:calc(var(--spacing) * 8)}.min-w-9{min-width:calc(var(--spacing) * 9)}.min-w-10{min-width:calc(var(--spacing) * 10)}.min-w-\[--cell-size\]{min-width:--cell-size}.min-w-\[8rem\]{min-width:8rem}.min-w-\[12rem\]{min-width:12rem}.min-w-\[240px\]{min-width:240px}.min-w-\[640px\]{min-width:640px}.min-w-\[var\(--radix-select-trigger-width\)\]{min-width:var(--radix-select-trigger-width)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.grow-0{flex-grow:0}.basis-full{flex-basis:100%}.caption-bottom{caption-side:bottom}.border-collapse{border-collapse:collapse}.origin-\[--radix-context-menu-content-transform-origin\]{transform-origin:--radix-context-menu-content-transform-origin}.origin-\[--radix-dropdown-menu-content-transform-origin\]{transform-origin:--radix-dropdown-menu-content-transform-origin}.origin-\[--radix-hover-card-content-transform-origin\]{transform-origin:--radix-hover-card-content-transform-origin}.origin-\[--radix-menubar-content-transform-origin\]{transform-origin:--radix-menubar-content-transform-origin}.origin-\[--radix-popover-content-transform-origin\]{transform-origin:--radix-popover-content-transform-origin}.origin-\[--radix-select-content-transform-origin\]{transform-origin:--radix-select-content-transform-origin}.origin-\[--radix-tooltip-content-transform-origin\]{transform-origin:--radix-tooltip-content-transform-origin}.-translate-x-1\/2{--tw-translate-x: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-\[-50\%\]{--tw-translate-x:-50%;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-\[-50\%\]{--tw-translate-y:-50%;translate:var(--tw-translate-x) var(--tw-translate-y)}.rotate-45{rotate:45deg}.rotate-90{rotate:90deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-in{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.touch-none{touch-action:none}.scroll-mt-16{scroll-margin-top:calc(var(--spacing) * 16)}.list-none{list-style-type:none}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-14{gap:calc(var(--spacing) * 14)}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-x-1>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 1) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-\[10px\]{border-radius:10px}.rounded-\[inherit\]{border-radius:inherit}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-none{border-radius:0}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.rounded-xl{border-radius:calc(var(--radius) + 4px)}.rounded-t-\[10px\]{border-top-left-radius:10px;border-top-right-radius:10px}.rounded-l-md{border-top-left-radius:calc(var(--radius) - 2px);border-bottom-left-radius:calc(var(--radius) - 2px)}.rounded-tl-sm{border-top-left-radius:calc(var(--radius) - 4px)}.rounded-r-md{border-top-right-radius:calc(var(--radius) - 2px);border-bottom-right-radius:calc(var(--radius) - 2px)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-\[\#2a2420\]{border-color:#2a2420}.border-\[\#2f5d3a\]{border-color:#2f5d3a}.border-\[\#8a7a5a\]{border-color:#8a7a5a}.border-\[\#b8322a\]{border-color:#b8322a}.border-\[\#cdbf9f\]{border-color:#cdbf9f}.border-\[\#d0c3a5\]{border-color:#d0c3a5}.border-\[\#d9cdb2\]{border-color:#d9cdb2}.border-\[\#e3d9c2\]{border-color:#e3d9c2}.border-\[var\(--border-base\)\]{border-color:var(--border-base)}.border-destructive,.border-destructive\/50{border-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.border-destructive\/50{border-color:color-mix(in oklab,var(--destructive) 50%,transparent)}}.border-input{border-color:var(--input)}.border-primary,.border-primary\/50{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.border-primary\/50{border-color:color-mix(in oklab,var(--primary) 50%,transparent)}}.border-transparent{border-color:#0000}.border-white\/90{border-color:#ffffffe6}@supports (color:color-mix(in lab,red,red)){.border-white\/90{border-color:color-mix(in oklab,var(--color-white) 90%,transparent)}}.border-t-transparent{border-top-color:#0000}.border-l-transparent{border-left-color:#0000}.bg-\[\#2a2420\]{background-color:#2a2420}.bg-\[\#2f5d3a\]{background-color:#2f5d3a}.bg-\[\#8a7a5a\]{background-color:#8a7a5a}.bg-\[\#b8322a\]{background-color:#b8322a}.bg-\[\#b8322a\]\/90{background-color:#b8322ae6}.bg-\[\#e3ecd9\]{background-color:#e3ecd9}.bg-\[\#e6dcc5\]{background-color:#e6dcc5}.bg-\[\#e6e0d2\]{background-color:#e6e0d2}.bg-\[\#ece2cc\]{background-color:#ece2cc}.bg-\[\#efe6d2\]{background-color:#efe6d2}.bg-\[\#f2ead8\]{background-color:#f2ead8}.bg-\[\#f3ead6\]{background-color:#f3ead6}.bg-\[\#f3ecdc\]\/90{background-color:#f3ecdce6}.bg-\[\#f5d9d5\]{background-color:#f5d9d5}.bg-\[\#f6e7c4\]{background-color:#f6e7c4}.bg-\[\#f8f2e4\]{background-color:#f8f2e4}.bg-\[\#f8f3e7\]{background-color:#f8f3e7}.bg-\[\#fbf7ee\]{background-color:#fbf7ee}.bg-\[var\(--bg-subtle\)\]{background-color:var(--bg-subtle)}.bg-\[var\(--neutral-0\)\]{background-color:var(--neutral-0)}.bg-accent{background-color:var(--accent)}.bg-background{background-color:var(--background)}.bg-black\/20{background-color:#0003}@supports (color:color-mix(in lab,red,red)){.bg-black\/20{background-color:color-mix(in oklab,var(--color-black) 20%,transparent)}}.bg-black\/80{background-color:#000c}@supports (color:color-mix(in lab,red,red)){.bg-black\/80{background-color:color-mix(in oklab,var(--color-black) 80%,transparent)}}.bg-border{background-color:var(--border)}.bg-card{background-color:var(--card)}.bg-destructive{background-color:var(--destructive)}.bg-foreground\/5{background-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.bg-foreground\/5{background-color:color-mix(in oklab,var(--foreground) 5%,transparent)}}.bg-muted,.bg-muted\/50{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/50{background-color:color-mix(in oklab,var(--muted) 50%,transparent)}}.bg-popover{background-color:var(--popover)}.bg-primary,.bg-primary\/20{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/20{background-color:color-mix(in oklab,var(--primary) 20%,transparent)}}.bg-secondary{background-color:var(--secondary)}.bg-transparent{background-color:#0000}.fill-current{fill:currentColor}.fill-primary{fill:var(--primary)}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.p-0{padding:calc(var(--spacing) * 0)}.p-1{padding:calc(var(--spacing) * 1)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-\[1px\]{padding:1px}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-8{padding-inline:calc(var(--spacing) * 8)}.px-\[--cell-size\]{padding-inline:--cell-size}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.pt-0{padding-top:calc(var(--spacing) * 0)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-8{padding-top:calc(var(--spacing) * 8)}.pr-1{padding-right:calc(var(--spacing) * 1)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-6{padding-right:calc(var(--spacing) * 6)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pb-0\.5{padding-bottom:calc(var(--spacing) * .5)}.pb-1{padding-bottom:calc(var(--spacing) * 1)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-24{padding-bottom:calc(var(--spacing) * 24)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-8{padding-left:calc(var(--spacing) * 8)}.text-center{text-align:center}.text-left{text-align:left}.align-middle{vertical-align:middle}.align-top{vertical-align:top}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.8rem\]{font-size:.8rem}.text-\[11px\]{font-size:11px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.text-balance{text-wrap:balance}.break-words{overflow-wrap:break-word}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-\[\#2a2420\]{color:#2a2420}.text-\[\#2f5d3a\]{color:#2f5d3a}.text-\[\#3a3128\]{color:#3a3128}.text-\[\#4a3f30\]{color:#4a3f30}.text-\[\#5a4f3c\]{color:#5a4f3c}.text-\[\#6b5d48\]{color:#6b5d48}.text-\[\#8a5a10\]{color:#8a5a10}.text-\[\#8a7a5a\]{color:#8a7a5a}.text-\[\#a09070\]{color:#a09070}.text-\[\#b8322a\]{color:#b8322a}.text-\[\#f6efdf\]{color:#f6efdf}.text-\[var\(--fg-subtle\)\]{color:var(--fg-subtle)}.text-accent-foreground{color:var(--accent-foreground)}.text-card-foreground{color:var(--card-foreground)}.text-current{color:currentColor}.text-destructive{color:var(--destructive)}.text-destructive-foreground{color:var(--destructive-foreground)}.text-foreground,.text-foreground\/50{color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.text-foreground\/50{color:color-mix(in oklab,var(--foreground) 50%,transparent)}}.text-muted-foreground{color:var(--muted-foreground)}.text-popover-foreground{color:var(--popover-foreground)}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-foreground)}.text-secondary-foreground{color:var(--secondary-foreground)}.text-white{color:var(--color-white)}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.underline-offset-4{text-underline-offset:4px}.accent-\[\#b8322a\]{accent-color:#b8322a}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-90{opacity:.9}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_8px_30px_rgba\(60\,40\,20\,0\.18\)\]{--tw-shadow:0 8px 30px var(--tw-shadow-color,#3c28142e);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-0{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-\[\#d6c9ad\]{--tw-ring-color:#d6c9ad}.ring-offset-background{--tw-ring-offset-color:var(--background)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-2xl{--tw-backdrop-blur:blur(var(--blur-2xl));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[color\,box-shadow\]{transition-property:color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.fade-in-0{--tw-enter-opacity:0}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.zoom-in-95{--tw-enter-scale:.95}.\[--cell-size\:2rem\]{--cell-size:2rem}.running{animation-play-state:running}@media(hover:hover){.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}}.group-data-\[focused\=true\]\/day\:relative:is(:where(.group\/day)[data-focused=true] *){position:relative}.group-data-\[focused\=true\]\/day\:z-10:is(:where(.group\/day)[data-focused=true] *){z-index:10}.group-data-\[focused\=true\]\/day\:border-ring:is(:where(.group\/day)[data-focused=true] *){border-color:var(--ring)}.group-data-\[focused\=true\]\/day\:ring-\[3px\]:is(:where(.group\/day)[data-focused=true] *){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.group-data-\[focused\=true\]\/day\:ring-ring\/50:is(:where(.group\/day)[data-focused=true] *){--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab,red,red)){.group-data-\[focused\=true\]\/day\:ring-ring\/50:is(:where(.group\/day)[data-focused=true] *){--tw-ring-color:color-mix(in oklab, var(--ring) 50%, transparent)}}.group-data-\[state\=open\]\:rotate-180:is(:where(.group)[data-state=open] *){rotate:180deg}.group-\[\.destructive\]\:border-muted\/40:is(:where(.group).destructive *){border-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.group-\[\.destructive\]\:border-muted\/40:is(:where(.group).destructive *){border-color:color-mix(in oklab,var(--muted) 40%,transparent)}}.group-\[\.destructive\]\:text-red-300:is(:where(.group).destructive *){color:var(--color-red-300)}.group-\[\.toast\]\:bg-muted:is(:where(.group).toast *){background-color:var(--muted)}.group-\[\.toast\]\:bg-primary:is(:where(.group).toast *){background-color:var(--primary)}.group-\[\.toast\]\:text-muted-foreground:is(:where(.group).toast *){color:var(--muted-foreground)}.group-\[\.toast\]\:text-primary-foreground:is(:where(.group).toast *){color:var(--primary-foreground)}.group-\[\.toaster\]\:border-border:is(:where(.group).toaster *){border-color:var(--border)}.group-\[\.toaster\]\:bg-background:is(:where(.group).toaster *){background-color:var(--background)}.group-\[\.toaster\]\:text-foreground:is(:where(.group).toaster *){color:var(--foreground)}.group-\[\.toaster\]\:shadow-lg:is(:where(.group).toaster *){--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-70:is(:where(.peer):disabled~*){opacity:.7}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-transparent::file-selector-button{background-color:#0000}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.file\:font-medium::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:text-foreground::file-selector-button{color:var(--foreground)}.placeholder\:text-muted-foreground::placeholder{color:var(--muted-foreground)}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:inset-y-0:after{content:var(--tw-content);inset-block:calc(var(--spacing) * 0)}.after\:left-1\/2:after{content:var(--tw-content);left:50%}.after\:w-1:after{content:var(--tw-content);width:calc(var(--spacing) * 1)}.after\:-translate-x-1\/2:after{content:var(--tw-content);--tw-translate-x: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}@media(hover:hover){.hover\:scale-105:hover{--tw-scale-x:105%;--tw-scale-y:105%;--tw-scale-z:105%;scale:var(--tw-scale-x) var(--tw-scale-y)}.hover\:border-\[\#8a7a5a\]:hover{border-color:#8a7a5a}.hover\:bg-\[\#a02a23\]:hover{background-color:#a02a23}.hover\:bg-\[\#e8dec6\]:hover{background-color:#e8dec6}.hover\:bg-\[\#ece2cc\]:hover{background-color:#ece2cc}.hover\:bg-accent:hover{background-color:var(--accent)}.hover\:bg-destructive\/80:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/80:hover{background-color:color-mix(in oklab,var(--destructive) 80%,transparent)}}.hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,var(--destructive) 90%,transparent)}}.hover\:bg-muted:hover,.hover\:bg-muted\/50:hover{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/50:hover{background-color:color-mix(in oklab,var(--muted) 50%,transparent)}}.hover\:bg-primary\/80:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/80:hover{background-color:color-mix(in oklab,var(--primary) 80%,transparent)}}.hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--primary) 90%,transparent)}}.hover\:bg-secondary:hover,.hover\:bg-secondary\/80:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,var(--secondary) 80%,transparent)}}.hover\:text-accent-foreground:hover{color:var(--accent-foreground)}.hover\:text-foreground:hover{color:var(--foreground)}.hover\:text-muted-foreground:hover{color:var(--muted-foreground)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.group-\[\.destructive\]\:hover\:border-destructive\/30:is(:where(.group).destructive *):hover{border-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.group-\[\.destructive\]\:hover\:border-destructive\/30:is(:where(.group).destructive *):hover{border-color:color-mix(in oklab,var(--destructive) 30%,transparent)}}.group-\[\.destructive\]\:hover\:bg-destructive:is(:where(.group).destructive *):hover{background-color:var(--destructive)}.group-\[\.destructive\]\:hover\:text-destructive-foreground:is(:where(.group).destructive *):hover{color:var(--destructive-foreground)}.group-\[\.destructive\]\:hover\:text-red-50:is(:where(.group).destructive *):hover{color:var(--color-red-50)}}.focus\:bg-accent:focus{background-color:var(--accent)}.focus\:text-accent-foreground:focus{color:var(--accent-foreground)}.focus\:opacity-100:focus{opacity:1}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-ring:focus{--tw-ring-color:var(--ring)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.group-\[\.destructive\]\:focus\:ring-destructive:is(:where(.group).destructive *):focus{--tw-ring-color:var(--destructive)}.group-\[\.destructive\]\:focus\:ring-red-400:is(:where(.group).destructive *):focus{--tw-ring-color:var(--color-red-400)}.group-\[\.destructive\]\:focus\:ring-offset-red-600:is(:where(.group).destructive *):focus{--tw-ring-offset-color:var(--color-red-600)}.focus-visible\:border-\[var\(--fg-base\)\]:focus-visible{border-color:var(--fg-base)}.focus-visible\:ring-1:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-ring:focus-visible{--tw-ring-color:var(--ring)}.focus-visible\:ring-offset-1:focus-visible{--tw-ring-offset-width:1px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus-visible\:ring-offset-background:focus-visible{--tw-ring-offset-color:var(--background)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:opacity-60:disabled{opacity:.6}.has-focus\:border-ring:has(:focus){border-color:var(--ring)}.has-focus\:ring-\[3px\]:has(:focus){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.has-focus\:ring-ring\/50:has(:focus){--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab,red,red)){.has-focus\:ring-ring\/50:has(:focus){--tw-ring-color:color-mix(in oklab, var(--ring) 50%, transparent)}}.aria-disabled\:opacity-50[aria-disabled=true]{opacity:.5}.aria-selected\:text-muted-foreground[aria-selected=true]{color:var(--muted-foreground)}.data-\[disabled\]\:pointer-events-none[data-disabled]{pointer-events:none}.data-\[disabled\]\:opacity-50[data-disabled]{opacity:.5}.data-\[disabled\=true\]\:pointer-events-none[data-disabled=true]{pointer-events:none}.data-\[disabled\=true\]\:opacity-50[data-disabled=true]{opacity:.5}.data-\[motion\=from-end\]\:slide-in-from-right-52[data-motion=from-end]{--tw-enter-translate-x:calc(52*var(--spacing))}.data-\[motion\=from-start\]\:slide-in-from-left-52[data-motion=from-start]{--tw-enter-translate-x:calc(52*var(--spacing)*-1)}.data-\[motion\=to-end\]\:slide-out-to-right-52[data-motion=to-end]{--tw-exit-translate-x:calc(52*var(--spacing))}.data-\[motion\=to-start\]\:slide-out-to-left-52[data-motion=to-start]{--tw-exit-translate-x:calc(52*var(--spacing)*-1)}.data-\[motion\^\=from-\]\:animate-in[data-motion^=from-]{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.data-\[motion\^\=from-\]\:fade-in[data-motion^=from-]{--tw-enter-opacity:0}.data-\[motion\^\=to-\]\:animate-out[data-motion^=to-]{animation:exit var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.data-\[motion\^\=to-\]\:fade-out[data-motion^=to-]{--tw-exit-opacity:0}.data-\[panel-group-direction\=vertical\]\:h-px[data-panel-group-direction=vertical]{height:1px}.data-\[panel-group-direction\=vertical\]\:w-full[data-panel-group-direction=vertical]{width:100%}.data-\[panel-group-direction\=vertical\]\:flex-col[data-panel-group-direction=vertical]{flex-direction:column}.data-\[panel-group-direction\=vertical\]\:after\:left-0[data-panel-group-direction=vertical]:after{content:var(--tw-content);left:calc(var(--spacing) * 0)}.data-\[panel-group-direction\=vertical\]\:after\:h-1[data-panel-group-direction=vertical]:after{content:var(--tw-content);height:calc(var(--spacing) * 1)}.data-\[panel-group-direction\=vertical\]\:after\:w-full[data-panel-group-direction=vertical]:after{content:var(--tw-content);width:100%}.data-\[panel-group-direction\=vertical\]\:after\:translate-x-0[data-panel-group-direction=vertical]:after{content:var(--tw-content);--tw-translate-x:calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[panel-group-direction\=vertical\]\:after\:-translate-y-1\/2[data-panel-group-direction=vertical]:after{content:var(--tw-content);--tw-translate-y: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[placeholder\]\:text-muted-foreground[data-placeholder]{color:var(--muted-foreground)}.data-\[range-end\=true\]\:rounded-md[data-range-end=true]{border-radius:calc(var(--radius) - 2px)}.data-\[range-end\=true\]\:bg-primary[data-range-end=true]{background-color:var(--primary)}.data-\[range-end\=true\]\:text-primary-foreground[data-range-end=true]{color:var(--primary-foreground)}.data-\[range-middle\=true\]\:rounded-none[data-range-middle=true]{border-radius:0}.data-\[range-middle\=true\]\:bg-accent[data-range-middle=true]{background-color:var(--accent)}.data-\[range-middle\=true\]\:text-accent-foreground[data-range-middle=true]{color:var(--accent-foreground)}.data-\[range-start\=true\]\:rounded-md[data-range-start=true]{border-radius:calc(var(--radius) - 2px)}.data-\[range-start\=true\]\:bg-primary[data-range-start=true]{background-color:var(--primary)}.data-\[range-start\=true\]\:text-primary-foreground[data-range-start=true]{color:var(--primary-foreground)}.data-\[selected-single\=true\]\:bg-primary[data-selected-single=true]{background-color:var(--primary)}.data-\[selected-single\=true\]\:text-primary-foreground[data-selected-single=true]{color:var(--primary-foreground)}.data-\[selected\=true\]\:rounded-none[data-selected=true]{border-radius:0}.data-\[selected\=true\]\:bg-accent[data-selected=true]{background-color:var(--accent)}.data-\[selected\=true\]\:text-accent-foreground[data-selected=true]{color:var(--accent-foreground)}.data-\[side\=bottom\]\:translate-y-1[data-side=bottom]{--tw-translate-y:calc(var(--spacing) * 1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=bottom\]\:slide-in-from-top-2[data-side=bottom]{--tw-enter-translate-y:calc(2*var(--spacing)*-1)}.data-\[side\=left\]\:-translate-x-1[data-side=left]{--tw-translate-x:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=left\]\:slide-in-from-right-2[data-side=left]{--tw-enter-translate-x:calc(2*var(--spacing))}.data-\[side\=right\]\:translate-x-1[data-side=right]{--tw-translate-x:calc(var(--spacing) * 1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=right\]\:slide-in-from-left-2[data-side=right]{--tw-enter-translate-x:calc(2*var(--spacing)*-1)}.data-\[side\=top\]\:-translate-y-1[data-side=top]{--tw-translate-y:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=top\]\:slide-in-from-bottom-2[data-side=top]{--tw-enter-translate-y:calc(2*var(--spacing))}.data-\[state\=active\]\:bg-background[data-state=active]{background-color:var(--background)}.data-\[state\=active\]\:text-foreground[data-state=active]{color:var(--foreground)}.data-\[state\=active\]\:shadow[data-state=active]{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.data-\[state\=checked\]\:translate-x-4[data-state=checked]{--tw-translate-x:calc(var(--spacing) * 4);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[state\=checked\]\:bg-primary[data-state=checked]{background-color:var(--primary)}.data-\[state\=checked\]\:text-primary-foreground[data-state=checked]{color:var(--primary-foreground)}.data-\[state\=closed\]\:animate-accordion-up[data-state=closed]{animation:.2s ease-out accordion-up}.data-\[state\=closed\]\:animate-out[data-state=closed]{animation:exit var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.data-\[state\=closed\]\:duration-300[data-state=closed]{--tw-duration:.3s;transition-duration:.3s}.data-\[state\=closed\]\:fade-out-0[data-state=closed]{--tw-exit-opacity:0}.data-\[state\=closed\]\:fade-out-80[data-state=closed]{--tw-exit-opacity:.8}.data-\[state\=closed\]\:zoom-out-95[data-state=closed]{--tw-exit-scale:.95}.data-\[state\=closed\]\:slide-out-to-bottom[data-state=closed]{--tw-exit-translate-y:100%}.data-\[state\=closed\]\:slide-out-to-left[data-state=closed]{--tw-exit-translate-x:-100%}.data-\[state\=closed\]\:slide-out-to-left-1\/2[data-state=closed]{--tw-exit-translate-x: -50% }.data-\[state\=closed\]\:slide-out-to-right[data-state=closed]{--tw-exit-translate-x:100%}.data-\[state\=closed\]\:slide-out-to-right-full[data-state=closed]{--tw-exit-translate-x: 100% }.data-\[state\=closed\]\:slide-out-to-top[data-state=closed]{--tw-exit-translate-y:-100%}.data-\[state\=closed\]\:slide-out-to-top-\[48\%\][data-state=closed]{--tw-exit-translate-y: -48% }.data-\[state\=hidden\]\:animate-out[data-state=hidden]{animation:exit var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.data-\[state\=hidden\]\:fade-out[data-state=hidden]{--tw-exit-opacity:0}.data-\[state\=on\]\:bg-accent[data-state=on]{background-color:var(--accent)}.data-\[state\=on\]\:text-accent-foreground[data-state=on]{color:var(--accent-foreground)}.data-\[state\=open\]\:animate-accordion-down[data-state=open]{animation:.2s ease-out accordion-down}.data-\[state\=open\]\:animate-in[data-state=open]{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.data-\[state\=open\]\:bg-accent[data-state=open],.data-\[state\=open\]\:bg-accent\/50[data-state=open]{background-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.data-\[state\=open\]\:bg-accent\/50[data-state=open]{background-color:color-mix(in oklab,var(--accent) 50%,transparent)}}.data-\[state\=open\]\:bg-secondary[data-state=open]{background-color:var(--secondary)}.data-\[state\=open\]\:text-accent-foreground[data-state=open]{color:var(--accent-foreground)}.data-\[state\=open\]\:text-muted-foreground[data-state=open]{color:var(--muted-foreground)}.data-\[state\=open\]\:duration-500[data-state=open]{--tw-duration:.5s;transition-duration:.5s}.data-\[state\=open\]\:fade-in-0[data-state=open]{--tw-enter-opacity:0}.data-\[state\=open\]\:zoom-in-90[data-state=open]{--tw-enter-scale:.9}.data-\[state\=open\]\:zoom-in-95[data-state=open]{--tw-enter-scale:.95}.data-\[state\=open\]\:slide-in-from-bottom[data-state=open]{--tw-enter-translate-y:100%}.data-\[state\=open\]\:slide-in-from-left[data-state=open]{--tw-enter-translate-x:-100%}.data-\[state\=open\]\:slide-in-from-left-1\/2[data-state=open]{--tw-enter-translate-x: -50% }.data-\[state\=open\]\:slide-in-from-right[data-state=open]{--tw-enter-translate-x:100%}.data-\[state\=open\]\:slide-in-from-top[data-state=open]{--tw-enter-translate-y:-100%}.data-\[state\=open\]\:slide-in-from-top-\[48\%\][data-state=open]{--tw-enter-translate-y: -48% }.data-\[state\=open\]\:slide-in-from-top-full[data-state=open]{--tw-enter-translate-y: -100% }@media(hover:hover){.data-\[state\=open\]\:hover\:bg-accent[data-state=open]:hover{background-color:var(--accent)}}.data-\[state\=open\]\:focus\:bg-accent[data-state=open]:focus{background-color:var(--accent)}.data-\[state\=selected\]\:bg-muted[data-state=selected]{background-color:var(--muted)}.data-\[state\=unchecked\]\:translate-x-0[data-state=unchecked]{--tw-translate-x:calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[state\=unchecked\]\:bg-input[data-state=unchecked]{background-color:var(--input)}.data-\[state\=visible\]\:animate-in[data-state=visible]{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.data-\[state\=visible\]\:fade-in[data-state=visible]{--tw-enter-opacity:0}.data-\[swipe\=cancel\]\:translate-x-0[data-swipe=cancel]{--tw-translate-x:calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[swipe\=end\]\:translate-x-\[var\(--radix-toast-swipe-end-x\)\][data-swipe=end]{--tw-translate-x:var(--radix-toast-swipe-end-x);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[swipe\=end\]\:animate-out[data-swipe=end]{animation:exit var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.data-\[swipe\=move\]\:translate-x-\[var\(--radix-toast-swipe-move-x\)\][data-swipe=move]{--tw-translate-x:var(--radix-toast-swipe-move-x);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[swipe\=move\]\:transition-none[data-swipe=move]{transition-property:none}@media(min-width:40rem){.sm\:top-auto{top:auto}.sm\:right-0{right:calc(var(--spacing) * 0)}.sm\:bottom-0{bottom:calc(var(--spacing) * 0)}.sm\:flex{display:flex}.sm\:inline{display:inline}.sm\:max-w-sm{max-width:var(--container-sm)}.sm\:flex-col{flex-direction:column}.sm\:flex-row{flex-direction:row}.sm\:justify-end{justify-content:flex-end}.sm\:gap-2\.5{gap:calc(var(--spacing) * 2.5)}:where(.sm\:space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)))}.sm\:rounded-lg{border-radius:var(--radius)}.sm\:text-left{text-align:left}.data-\[state\=open\]\:sm\:slide-in-from-bottom-full[data-state=open]{--tw-enter-translate-y: 100% }}@media(min-width:48rem){.md\:absolute{position:absolute}.md\:w-\[var\(--radix-navigation-menu-viewport-width\)\]{width:var(--radix-navigation-menu-viewport-width)}.md\:w-auto{width:auto}.md\:max-w-\[420px\]{max-width:420px}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:grid-cols-\[1fr_1\.1fr\]{grid-template-columns:1fr 1.1fr}.md\:flex-row{flex-direction:row}.md\:items-end{align-items:flex-end}.md\:justify-between{justify-content:space-between}.md\:text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.md\:text-6xl{font-size:var(--text-6xl);line-height:var(--tw-leading,var(--text-6xl--line-height))}.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}@media(min-width:64rem){.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}}@media(prefers-color-scheme:dark){.dark\:border-destructive{border-color:var(--destructive)}}.\[\&_\[cmdk-group-heading\]\]\:px-2 [cmdk-group-heading]{padding-inline:calc(var(--spacing) * 2)}.\[\&_\[cmdk-group-heading\]\]\:py-1\.5 [cmdk-group-heading]{padding-block:calc(var(--spacing) * 1.5)}.\[\&_\[cmdk-group-heading\]\]\:text-xs [cmdk-group-heading]{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.\[\&_\[cmdk-group-heading\]\]\:font-medium [cmdk-group-heading]{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.\[\&_\[cmdk-group-heading\]\]\:text-muted-foreground [cmdk-group-heading]{color:var(--muted-foreground)}.\[\&_\[cmdk-group\]\]\:px-2 [cmdk-group]{padding-inline:calc(var(--spacing) * 2)}.\[\&_\[cmdk-group\]\:not\(\[hidden\]\)_\~\[cmdk-group\]\]\:pt-0 [cmdk-group]:not([hidden])~[cmdk-group]{padding-top:calc(var(--spacing) * 0)}.\[\&_\[cmdk-input-wrapper\]_svg\]\:h-5 [cmdk-input-wrapper] svg{height:calc(var(--spacing) * 5)}.\[\&_\[cmdk-input-wrapper\]_svg\]\:w-5 [cmdk-input-wrapper] svg{width:calc(var(--spacing) * 5)}.\[\&_\[cmdk-input\]\]\:h-12 [cmdk-input]{height:calc(var(--spacing) * 12)}.\[\&_\[cmdk-item\]\]\:px-2 [cmdk-item]{padding-inline:calc(var(--spacing) * 2)}.\[\&_\[cmdk-item\]\]\:py-3 [cmdk-item]{padding-block:calc(var(--spacing) * 3)}.\[\&_\[cmdk-item\]_svg\]\:h-5 [cmdk-item] svg{height:calc(var(--spacing) * 5)}.\[\&_\[cmdk-item\]_svg\]\:w-5 [cmdk-item] svg{width:calc(var(--spacing) * 5)}.\[\&_p\]\:leading-relaxed p{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:size-4 svg{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.\[\&_tr\]\:border-b tr{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.\[\&_tr\:last-child\]\:border-0 tr:last-child{border-style:var(--tw-border-style);border-width:0}.\[\&\+div\]\:text-xs+div{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.\[\&\:first-child\[data-selected\=true\]_button\]\:rounded-l-md:first-child[data-selected=true] button{border-top-left-radius:calc(var(--radius) - 2px);border-bottom-left-radius:calc(var(--radius) - 2px)}.\[\&\:has\(\[role\=checkbox\]\)\]\:pr-0:has([role=checkbox]){padding-right:calc(var(--spacing) * 0)}:is(.rtl\:\*\*\:\[\.rdp-button\\_next\>svg\]\:rotate-180:where(:dir(rtl),[dir=rtl],[dir=rtl] *) *):is(.rdp-button_next>svg),:is(.rtl\:\*\*\:\[\.rdp-button\\_previous\>svg\]\:rotate-180:where(:dir(rtl),[dir=rtl],[dir=rtl] *) *):is(.rdp-button_previous>svg){rotate:180deg}.\[\&\:last-child\[data-selected\=true\]_button\]\:rounded-r-md:last-child[data-selected=true] button{border-top-right-radius:calc(var(--radius) - 2px);border-bottom-right-radius:calc(var(--radius) - 2px)}.\[\&\>\[role\=checkbox\]\]\:translate-y-\[2px\]>[role=checkbox]{--tw-translate-y:2px;translate:var(--tw-translate-x) var(--tw-translate-y)}.\[\&\>span\]\:line-clamp-1>span{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.\[\&\>span\]\:text-xs>span{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.\[\&\>span\]\:opacity-70>span{opacity:.7}.\[\&\>svg\]\:absolute>svg{position:absolute}.\[\&\>svg\]\:top-4>svg{top:calc(var(--spacing) * 4)}.\[\&\>svg\]\:left-4>svg{left:calc(var(--spacing) * 4)}.\[\&\>svg\]\:size-3\.5>svg{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.\[\&\>svg\]\:size-4>svg{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&\>svg\]\:h-3\.5>svg{height:calc(var(--spacing) * 3.5)}.\[\&\>svg\]\:w-3\.5>svg{width:calc(var(--spacing) * 3.5)}.\[\&\>svg\]\:shrink-0>svg{flex-shrink:0}.\[\&\>svg\]\:text-destructive>svg{color:var(--destructive)}.\[\&\>svg\]\:text-foreground>svg{color:var(--foreground)}.\[\&\>svg\]\:text-muted-foreground>svg{color:var(--muted-foreground)}.\[\&\>svg\+div\]\:translate-y-\[-3px\]>svg+div{--tw-translate-y:-3px;translate:var(--tw-translate-x) var(--tw-translate-y)}.\[\&\>svg\~\*\]\:pl-7>svg~*{padding-left:calc(var(--spacing) * 7)}.\[\&\>tr\]\:last\:border-b-0>tr:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.\[\&\[data-panel-group-direction\=vertical\]\>div\]\:rotate-90[data-panel-group-direction=vertical]>div{rotate:90deg}.\[\&\[data-state\=open\]\>svg\]\:rotate-180[data-state=open]>svg{rotate:180deg}[data-slot=card-content] .\[\[data-slot\=card-content\]_\&\]\:bg-transparent,[data-slot=popover-content] .\[\[data-slot\=popover-content\]_\&\]\:bg-transparent{background-color:#0000}}@property --tw-animation-delay{syntax:"*";inherits:false;initial-value:0s}@property --tw-animation-direction{syntax:"*";inherits:false;initial-value:normal}@property --tw-animation-duration{syntax:"*";inherits:false}@property --tw-animation-fill-mode{syntax:"*";inherits:false;initial-value:none}@property --tw-animation-iteration-count{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-translate-y{syntax:"*";inherits:false;initial-value:0}@keyframes accordion-down{0%{height:0}to{height:var(--radix-accordion-content-height,var(--bits-accordion-content-height,var(--reka-accordion-content-height,var(--kb-accordion-content-height,var(--ngp-accordion-content-height,auto)))))}}@keyframes accordion-up{0%{height:var(--radix-accordion-content-height,var(--bits-accordion-content-height,var(--reka-accordion-content-height,var(--kb-accordion-content-height,var(--ngp-accordion-content-height,auto)))))}to{height:0}}:root{--background:#fff;--foreground:#212121;--card:#fff;--card-foreground:#212121;--popover:#fff;--popover-foreground:#212121;--primary:#ff2974;--primary-foreground:#fff;--secondary:#f3f2f2;--secondary-foreground:#212121;--muted:#f3f2f2;--muted-foreground:#7a7a7a;--accent:#f3f2f2;--accent-foreground:#212121;--destructive:#f5242e;--destructive-foreground:#fff;--border:#e8e8e8;--input:#e8e8e8;--ring:#212121;--radius:.5rem;--neutral-0:#fff;--neutral-50:#fafafa;--neutral-100:#f5f4f4;--neutral-200:#e7e7e7;--neutral-300:#d8d8d8;--neutral-400:#aaa;--neutral-500:#7a7a7a;--neutral-600:#5b5b5b;--neutral-700:#464646;--neutral-800:#2a2a2a;--neutral-900:#212121;--neutral-950:#1b1b1b;--neutral-1000:#101010;--brand-100:#ff2882;--brand-60:#ff288299;--brand-30:#ff28824d;--brand-10:#ff28821a;--brand-seafoam:#b6cfd0;--brand-dusty-teal:#6ba4b8;--brand-black-aqua:#002f38;--brand-deep-teal-blue:#07272d;--visualizer-rose-pop:#fd4b96;--visualizer-indigo-breeze:#6366f1;--visualizer-emerald-mint:#10b981;--visualizer-golden-amber:#f59e0b;--visualizer-royal-blue:#1d4ed8;--visualizer-crimson-spark:#ef4444;--visualizer-aqua-glow:#06b6d4;--visualizer-sunbeam-yellow:#facc15;--bg-subtle:#2a2a2a0a;--bg-subtle-hover:#2a2a2a0f;--bg-base:#ffffffe0;--bg-base-opaque:#fff;--bg-menu:#fff;--bg-chat:#fcfcfc;--bg-chat-nav:#f4f4f4;--fg-base:#212121;--fg-subtle:#7a7a7a;--fg-muted:#aaa;--fg-disabled:#d8d8d8;--border-base:#2a2a2a0a;--border-strong:#2a2a2a14;--border-contrast:#2a2a2a1f;--border-focus:#2a2a2a66;--tag-blue-10:#5bb5ff1a;--tag-blue-100:#5bb5ff;--tag-yellow-10:#dec3781a;--tag-yellow-100:#dec378;--tag-purple-10:#908eb81a;--tag-purple-100:#908eb8;--tag-cyan-10:#74ada41a;--tag-cyan-100:#74ada4;--tag-pink-10:#b88ea71a;--tag-pink-100:#b88ea7;--tag-orange-10:#b8a08e1a;--tag-orange-100:#b8a08e;--gradient-max-dark:linear-gradient(213deg, #8c421d 2%, #fbe67b 31%, #fcfbe7 53%, #f7d14e 77%, #d4a041 100%);--gradient-max-light:linear-gradient(125deg, #8c421d 22%, #d2af00 47%, #cd9124 67%, #d4a041 88%);--gradient-pro-dark:linear-gradient(213deg, #7a96ac 1%, #c5d6e2 27%, #eaeff3 47%, #c5d6e2 65%, #a3bccf 89%);--gradient-pro-light:linear-gradient(129deg, #7a96ac 4%, #6d7f8d 27%, #9fb9ce 55%, #6d7f8d 90%);--gradient-plus-dark:linear-gradient(214deg, #986732 0%, #a7825b 23%, #f6d0ab 45%, #a07043 66%, #9d774e 100%);--gradient-plus-light:linear-gradient(129deg, #9e8976 5%, #7a5e50 19%, #f6d0ab 35%, #9d774e 49%, #c99b70 65%, #795f52 78%);--gradient-upgrade-dark:linear-gradient(214deg, #ffedf3 0%, #ff9fbf 30%, #fd5d92 59%, #ffdab2 100%);--gradient-upgrade-light:linear-gradient(32deg, #fc3bb2 6%, #f7906e 79%);--gradient-new-function:linear-gradient(33deg, #6e86ff 17%, #ff2882 55%, #ff98a4 103%);--gradient-suggestion:linear-gradient(32deg, #fd538c 4%, #5aa6e0 47%, #ffdab2 98%);--gradient-g1:linear-gradient(189deg, #ffacc6 6%, #b6e0f5 99%);--gradient-g2:linear-gradient(34deg, #9796f0 9%, #fbc7d4 84%);--gradient-g3:linear-gradient(to right, #ed4264, #ffedbc);--gradient-g4:linear-gradient(232deg, #fedc2a 4%, #dd5789 56%, #7a2c9e 104%);--gradient-g5:linear-gradient(32deg, #fc3bb2 6%, #f7906e 79%);--gradient-max:var(--gradient-max-light);--gradient-pro:var(--gradient-pro-light);--gradient-plus:var(--gradient-plus-light);--gradient-upgrade:var(--gradient-upgrade-light);--spacing-2:2px;--spacing-4:4px;--spacing-6:6px;--spacing-8:8px;--spacing-10:10px;--spacing-12:12px;--spacing-16:16px;--spacing-24:24px;--spacing-32:32px;--spacing-40:40px;--spacing-64:64px;--spacing-96:96px;--spacing-128:128px;--radius-2:2px;--radius-4:4px;--radius-6:6px;--radius-8:8px;--radius-10:10px;--radius-12:12px;--radius-16:16px;--radius-20:20px;--radius-24:24px;--radius-full:999px;--font-family-header:"Lato", "PingFang SC", sans-serif;--font-family-body:"Lato", "PingFang SC", sans-serif;--font-family-code:"Roboto Mono", monospace}.dark{--background:#171717;--foreground:#e8e8e8;--card:#171717;--card-foreground:#e8e8e8;--popover:#262626;--popover-foreground:#e8e8e8;--primary:#ff2974;--primary-foreground:#fff;--secondary:#292929;--secondary-foreground:#e8e8e8;--muted:#292929;--muted-foreground:#ababab;--accent:#292929;--accent-foreground:#e8e8e8;--destructive:#ff4c4f;--destructive-foreground:#fff;--border:#333;--input:#333;--ring:#e7e7e7;--gradient-max:var(--gradient-max-dark);--gradient-pro:var(--gradient-pro-dark);--gradient-plus:var(--gradient-plus-dark);--gradient-upgrade:var(--gradient-upgrade-dark);--bg-subtle:#ffffff0a;--bg-subtle-hover:#ffffff1f;--bg-base:#2a2a2acc;--bg-base-opaque:#171717;--bg-menu:#252525;--bg-chat:#101010;--bg-chat-nav:#171717;--fg-base:#e7e7e7;--fg-subtle:#aaa;--fg-muted:#7a7a7a;--fg-disabled:#5b5b5b;--border-base:#ffffff0a;--border-strong:#ffffff14;--border-contrast:#ffffff1f;--border-focus:#fff6}@media(max-width:768px){:root{--spacing-96:64px;--spacing-128:64px}}html,body{background:#f3ecdc}.font-brush{font-family:MaShanZhengFilm,WenKaiFilm,Kaiti SC,STKaiti,KaiTi,serif;font-weight:400}.paper-bg{background-color:#f3ecdc;background-image:radial-gradient(at 20% 0,#fffcf0b3,#0000 60%),radial-gradient(at 90% 30%,#c8b48c1f,#0000 50%),radial-gradient(at 10% 80%,#c8b48c1a,#0000 50%);font-family:WenKaiFilm,PingFang SC,Hiragino Sans GB,Microsoft YaHei,system-ui,sans-serif}.ink-btn{color:#2a2420;background:#fbf7ee;border:1px solid #cdbf9f;border-radius:4px;align-items:center;gap:4px;padding:4px 8px;transition:all .15s;display:inline-flex}.ink-btn:hover{border-color:#8a7a5a}.ink-btn:disabled{opacity:.5}.ink-btn-on{color:#f6efdf;background:#2a2420;border-color:#2a2420}.ink-range{-webkit-appearance:none;-moz-appearance:none;appearance:none;cursor:pointer;background:0 0;height:24px}.ink-range::-webkit-slider-runnable-track{background:linear-gradient(90deg,#8a7a5a,#cdbf9f);border-radius:2px;height:4px}.ink-range::-moz-range-track{background:#cdbf9f;border-radius:2px;height:4px}.ink-range::-webkit-slider-thumb{-webkit-appearance:none;background:#b8322a;border:2px solid #f6efdf;border-radius:50%;width:16px;height:16px;margin-top:-6px;box-shadow:0 1px 3px #0000004d}.ink-range::-moz-range-thumb{background:#b8322a;border:2px solid #f6efdf;border-radius:50%;width:14px;height:14px}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}}@keyframes enter{0%{opacity:var(--tw-enter-opacity,1);transform:translate3d(var(--tw-enter-translate-x,0),var(--tw-enter-translate-y,0),0)scale3d(var(--tw-enter-scale,1),var(--tw-enter-scale,1),var(--tw-enter-scale,1))rotate(var(--tw-enter-rotate,0));filter:blur(var(--tw-enter-blur,0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity,1);transform:translate3d(var(--tw-exit-translate-x,0),var(--tw-exit-translate-y,0),0)scale3d(var(--tw-exit-scale,1),var(--tw-exit-scale,1),var(--tw-exit-scale,1))rotate(var(--tw-exit-rotate,0));filter:blur(var(--tw-exit-blur,0))}}
ugly-duckling/dist/film/fonts/chars.txt CHANGED
@@ -1 +1 @@
1
- !#%+,-./0123456789:;?@ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz·—‘’“”…、。《》「」【】・一三上下不与丑世丛东丝两个中为主丽举久么乎乐也乡书乱了事二于云五些交享亮亲人仅今介从他代以们仰任伙会传伴位低住体何作你例侧便俏保信倒候值停傍像儿充入全公六共关其具内再写冠冬冰冲决况冷冻准几出击函分则创到制前剩副力加动勾匀包化区半单印即压原又叉友发取变叠口古句另只叫可台右叶号叽各合同名后向吓吗吧呆告命和咔咦咬哀哈响哔哥哩哪唧啄喂善喊喙喜喳嘲嘴器噪嚓四回团围固国图圆圈在地场均坐块垂垛域堆塘填境增墨壁声壳处备复夏外多夜大天太失头夹奇奏她好妈始姐姑娘嫌子字存季孤学孩孵它安完官定实宣宫家宽寂寄密寒寸对导封小尖尚就尺尼尾层屋屏展属山岸峰工左差己已布希帘带帧幅幕干平并序应底度廓延建开式引张弦弯弹归当录形彩影往径很徒得循微心快念忽态怕性怪总息悄悬情惊愈意感慢成截所扇手才扑打扫扬把抖折报拉拍拖拢拨择持按挖振换掉排掠接控推描提插揭撇播擎支收改放政故效敢数整文斑断新旁无日时明春是昼显晓晕晚景晰暂暖暗曲更替最有朋望朝期木未本术朱机权村条来极构果枝枪染柔查柳标树校样核根格桃案梅梦梳检楷模横檐欠次欢欺款歌正此步段每比毛气水池治沼沿法波泥泪泽洁流测浏浓浮消淡深混清渐渡温渲游湖湿溅源溪滴漂漫潮灰点烟烧然照熬片版牙物状猎率环现理瓣生用田由电画畅界痕登白的皮皴益盖盛目直相省看真眼着睡瞬瞻短码砂砰破确碍示神离秋种秒积程空窗窝立竖章童竹笆笑笔笛符等筝签简篇篱簧粒粘系素紧红纤约级纳纷纸纹线组终经绒结绘给络绪续维缓编缘缩网置美群羽翅翻翼而肉背能脊脑脖脚脸腿膀臂自致色艺节芦花芽苇苹范茸草荷落蓝藏蛋蜓蜻蝴蝶融行衣补表被裁裂装西要见观规视览觉角解触计让记讲证试话语说请读课调负败质贴资走起路跳身躲轨转轮软轴轻载输辨边过近还这进远连迟述追送适选逐递通遮那部都配采里重野量金钟钻铃铺错键镜长门间阅阴阵阶阻附限除随难雁雪雾需霞露静面音页顶顿预领颈频颗题颜颤风飘飞马驳验高髻鱼鸟鸡鸣鸭鹅鹜麦黄黑齐️︐!()+,:;?|🐓👀🥚🦢
 
1
+ !#%+,-./0123456789:;?@ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz·—‘’“”…、。《》「」【】・一三上下不与丑专世丛东丝两个中为主丽举久么义之乌乎乐也乡书乱了事二于云互五些交享亮亲人什仅今介从他代以们仰任伊伙会传伤伴伸位低住体何余作你使例供侧便促俏保信倒候值做停健傍像儿充先光免入全公六共关其具内再写农冠冬冰冲决况冷冻准几出击函分切列则创到制刻前剩剪副力加动勾匀包化区升半单印即却卷压原去又叉及友双发取受变叠口古句另只叫可台右叶号叼叽吃各合同名后向吓吗否吧听吹吻呀呆呈告呼命和咔咦咬哀哈响哔哥哩哪哭哼唧啄啊啦喂善喊喘喙喜喳嗓嘲嘴器噪嚎嚓四回团园围固国图圆圈在地场址均坏坐块坡垂型垛域堆塘塞填境墙增墨壁声壳处备复夏夕外多夜大天太失头夹奇奏奔套她好如妈始姐姑姿娃娘嫌子字存季孤学孩孵它安完官定实宣宫家宽寂寄密寒寓寸对导封小尔尖尚就尺尼尾层屋屏展属山岸峰工左差己已巾布希帘带帧帽幅幕干平并序库应底度廓延建开式引张弦弧弯弹强归当录形彩影往径很徒得循微心忙快念忽态怕急性怪总息悄悔悠悬情惊惋惜愈意感慢憋懒成我或截所扁扇手才扑打扛扫扬扰把抖折报抱担拉拍拖拢拨择拱拼拿持挂按挖挨振换掉排掠探接控推描提插握揭摇撇播擎支收改放政故效救敢散数整文斑斜断新方旁无日时明易春是昼显晓晕晚景晰暂暖暗曲更替最有朋望朝期木未末本术朱机权村条来极构果枝枪柄染柔查柳标树校样核根格桃案梅梦梯梳检楷模横檐欠次欢欺款歌正此步段每比毛民气水求汗池沙没治沼沿法波泥泪泽洁洋活流测浏浓浮消润淡深混清渐渡温渲游湖湿溅源溪滑滴漂漫潮灯灰点烟烧然照熬爱片版牙物状独狼猎率玩环现珠理瓣生用田由电画畅界略疏痕登白的皮皴益盖盛目直相省眉看真眼着睛睡瞬瞻短石码砂砰破确碍碎示神离秋种秒秧积移稀程稍空窗窝立竖章童竹笆笑笔笛符第等筐筝签简算篇篱簧类粒粗粘系素索紧红纤约级纳纷纸纹线组细终经绑绒结绘给络绪续维绿缓编缘缩网置署羊美群羽翅翻翼而耳聊肉肢肤背胸能脊脑脖脚脸腮腰腿膀膝臂自致色艺节芦花芽苇苗苹范茅茸草荷莹落蓝藏蛋蜓蜻蝴蝶融行衣补表袖被裁裂装裤襟西要见观规视览觉角解触言警计认让记讲��证词试诚话语说请读课谁调谎谷贝负败质贴资走赶起越跑路跳身躯躲躺轨转轮软轴轻载输辨辫边迁过近还这进远连迟述追送适逃选逐递逗通逼遍道遮避那部都配采里重野量金钟钻铃铺链锄错键镜长门闪间阅阳阴阵阶阻附限除随难雁雪雷雾需霞露静靠面音页顶顿预领颈频颗题颜额颤风飘飞马驳验骗骨高髻鱼鸟鸡鸣鸦鸭鹅鹜麦黄黑默鼻齐️︐!()+,:;?|~🐑🐓🐺👀📢😂🥚🦢
ugly-duckling/dist/film/fonts/mashanzheng.woff2 CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:e009f88eccbb75b93ece3214992da9dbab3c96e3e003d1078a88d753a001b630
3
- size 343228
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:27e0895918dceab34deaf286fa50e2dee8abb9b73fb32b2ce13270f8f8215224
3
+ size 423596
ugly-duckling/dist/film/fonts/wenkai.woff2 CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:c673c2d90b5d3e48ff76694316aec488e221d610b6269440d795d093207566fe
3
- size 200844
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:88500492b8f490478e791976eef7824f7527c1d788ee205d7dc443c153c3ab8f
3
+ size 242444
ugly-duckling/dist/index.html CHANGED
@@ -57,8 +57,8 @@
57
  <title>丑小鸭 · 水墨手绘儿童动画</title>
58
  <meta name="description" content="安徒生童话《丑小鸭》水墨手绘儿童动画:网页实时渲染播放,浏览器本地导出 MP4,附封面、发布文案与实况图。" />
59
  <meta name="theme-color" content="#f3ecdc" />
60
- <script type="module" crossorigin src="./assets/index-D_WTMvCo.js"></script>
61
- <link rel="stylesheet" crossorigin href="./assets/index-Uv1LtJ5Z.css">
62
  </head>
63
  <body>
64
  <div id="root"><!--ssr-outlet--></div>
 
57
  <title>丑小鸭 · 水墨手绘儿童动画</title>
58
  <meta name="description" content="安徒生童话《丑小鸭》水墨手绘儿童动画:网页实时渲染播放,浏览器本地导出 MP4,附封面、发布文案与实况图。" />
59
  <meta name="theme-color" content="#f3ecdc" />
60
+ <script type="module" crossorigin src="./assets/index-DjlK4Epu.js"></script>
61
+ <link rel="stylesheet" crossorigin href="./assets/index-Dujsqd5W.css">
62
  </head>
63
  <body>
64
  <div id="root"><!--ssr-outlet--></div>
ugly-duckling/scripts/dbg_draw.mjs CHANGED
@@ -4,7 +4,7 @@ import { readFileSync, existsSync, statSync, writeFileSync } from 'node:fs'
4
  import { join, extname } from 'node:path'
5
  import { pathToFileURL } from 'node:url'
6
  const [out, W, H, code] = process.argv.slice(2)
7
- const PROJ = '/workspaces/ugly-duckling/video'
8
  const { chromium } = await import(pathToFileURL('/root/.vibe-studio/node_modules/playwright/index.mjs').href)
9
  const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.mp3': 'audio/mpeg', '.woff2': 'font/woff2' }
10
  const srv = createServer((q, s) => { let u = decodeURIComponent(q.url.split('?')[0]); if (u === '/') u = '/index.html'; const f = join(PROJ, u); if (existsSync(f) && statSync(f).isFile()) { s.setHeader('content-type', MIME[extname(f)] || 'application/octet-stream'); return s.end(readFileSync(f)) } s.statusCode = 404; s.end() })
 
4
  import { join, extname } from 'node:path'
5
  import { pathToFileURL } from 'node:url'
6
  const [out, W, H, code] = process.argv.slice(2)
7
+ const PROJ = process.env.PROJ || '/workspaces/ugly-duckling/video'
8
  const { chromium } = await import(pathToFileURL('/root/.vibe-studio/node_modules/playwright/index.mjs').href)
9
  const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.mp3': 'audio/mpeg', '.woff2': 'font/woff2' }
10
  const srv = createServer((q, s) => { let u = decodeURIComponent(q.url.split('?')[0]); if (u === '/') u = '/index.html'; const f = join(PROJ, u); if (existsSync(f) && statSync(f).isFile()) { s.setHeader('content-type', MIME[extname(f)] || 'application/octet-stream'); return s.end(readFileSync(f)) } s.statusCode = 404; s.end() })
ugly-duckling/scripts/make_live.py CHANGED
@@ -4,10 +4,13 @@ import json, shutil, subprocess, sys, zipfile, hashlib
4
  from pathlib import Path
5
  SK = Path('/workspaces/ugly-duckling/livephotos/livecanvas_scripts') # livecanvas 脚本(含 FFmpeg 7.1 字段兼容补丁)
6
  PY = sys.executable
7
- ROOT = Path('/workspaces/ugly-duckling/livephotos')
8
- BUILD, PAIRS, PUB = ROOT / 'build', ROOT / 'pairs', Path('/workspaces/frontend/public/film/live')
9
- live = json.loads(Path('/workspaces/frontend/src/film/live.json').read_text(encoding='utf-8'))
10
- posts = Path('/workspaces/frontend/src/content.ts').read_text(encoding='utf-8')
 
 
 
11
  for d in (PAIRS,):
12
  shutil.rmtree(d, ignore_errors=True); d.mkdir(parents=True)
13
  PUB.mkdir(parents=True, exist_ok=True)
@@ -32,7 +35,7 @@ for l in live:
32
  print('paired', l['id'], man['identifier'], 'cover', man['coverTime'])
33
  (ROOT / 'delivery.json').write_text(json.dumps(report, ensure_ascii=False, indent=1), encoding='utf-8')
34
 
35
- readme = """丑小鸭 · 水墨实况图(6 组)
36
 
37
  每组同名的 .jpg + .mov 就是一张苹果实况照片(Live Photo):
38
  · 已按 livecanvas 流程写入配对信息:JPG 的 Apple MakerNote 与 MOV 的 com.apple.quicktime.content.identifier 共用同一个 UUID,
@@ -59,7 +62,7 @@ for name in ['小红书图文(实况图)', '抖音图文(实况图)']:
59
  copy_txt += f"【{name}】\n标题(任选其一):\n" + '\n'.join(' ' + t for t in titles) + f"\n\n正文:\n{body}\n\n话题:\n" + ' '.join('#' + t for t in tags) + '\n\n' + '-' * 30 + '\n\n'
60
  zp = PUB / 'livephotos.zip'
61
  with zipfile.ZipFile(zp, 'w', zipfile.ZIP_STORED) as z:
62
- base = '丑小鸭_水墨实况图/'
63
  for l in live:
64
  pd = PAIRS / f"{l['id']}.pair"
65
  z.write(pd / 'live.jpg', base + f"{l['id']}.jpg")
 
4
  from pathlib import Path
5
  SK = Path('/workspaces/ugly-duckling/livephotos/livecanvas_scripts') # livecanvas 脚本(含 FFmpeg 7.1 字段兼容补丁)
6
  PY = sys.executable
7
+ import os
8
+ FILM = os.environ.get('FILM', 'duckling')
9
+ CFG = {'duckling': ('ugly-duckling', 'film', '丑小鸭'), 'wolf': ('wolf-coming', 'wolf', '狼来了')}[FILM]
10
+ ROOT = Path(f'/workspaces/{CFG[0]}/livephotos')
11
+ BUILD, PAIRS, PUB = ROOT / 'build', ROOT / 'pairs', Path(f'/workspaces/frontend/public/{CFG[1]}/live')
12
+ live = json.loads(Path(f'/workspaces/frontend/src/films/{FILM}/live.json').read_text(encoding='utf-8'))
13
+ posts = Path(f'/workspaces/frontend/src/films/{FILM}/content.ts').read_text(encoding='utf-8')
14
  for d in (PAIRS,):
15
  shutil.rmtree(d, ignore_errors=True); d.mkdir(parents=True)
16
  PUB.mkdir(parents=True, exist_ok=True)
 
35
  print('paired', l['id'], man['identifier'], 'cover', man['coverTime'])
36
  (ROOT / 'delivery.json').write_text(json.dumps(report, ensure_ascii=False, indent=1), encoding='utf-8')
37
 
38
+ readme = f"""{CFG[2]} · 水墨实况图(6 组)
39
 
40
  每组同名的 .jpg + .mov 就是一张苹果实况照片(Live Photo):
41
  · 已按 livecanvas 流程写入配对信息:JPG 的 Apple MakerNote 与 MOV 的 com.apple.quicktime.content.identifier 共用同一个 UUID,
 
62
  copy_txt += f"【{name}】\n标题(任选其一):\n" + '\n'.join(' ' + t for t in titles) + f"\n\n正文:\n{body}\n\n话题:\n" + ' '.join('#' + t for t in tags) + '\n\n' + '-' * 30 + '\n\n'
63
  zp = PUB / 'livephotos.zip'
64
  with zipfile.ZipFile(zp, 'w', zipfile.ZIP_STORED) as z:
65
+ base = f'{CFG[2]}_水墨实况图/'
66
  for l in live:
67
  pd = PAIRS / f"{l['id']}.pair"
68
  z.write(pd / 'live.jpg', base + f"{l['id']}.jpg")
ugly-duckling/scripts/qa_report.py CHANGED
@@ -1,11 +1,28 @@
1
  #!/usr/bin/env python3
2
  """合并横 / 竖屏 vibe qa + 字幕带补充检查 → web/src/film/qa.json(网页「制作与质检」)"""
3
- import json, datetime
 
 
4
  L=json.load(open('video/out/qa.json')); P=json.load(open('video-portrait/out/qa.json')); S=json.load(open('video/out/qa_subband.json'))
 
 
 
 
 
 
 
 
 
 
 
 
5
  res=[]
6
  for r in P['results']:
 
 
 
7
  if r['check']=='接触表': continue
8
- if r['check']=='文字@reflection' and r['level']=='WARN':
9
  r={**r,'level':'INFO','msg':r['msg'].split('(')[0]+'(检查窗口同时计入了上一句字幕;当前字幕「而是一只美丽的白天鹅」已被读出)'}
10
  res.append({**r,'check':'竖屏 '+r['check']})
11
  for r in L['results']:
@@ -13,10 +30,10 @@ for r in L['results']:
13
  for r in S:
14
  if '片头' in r['msg']: continue
15
  res.append({**r,'check':'横屏 '+r['check']})
16
- res.append({'level':'INFO','check':'说明','msg':'横屏字幕位于画面底部 ~92%,vibe qa 的 OCR 只扫 60–84% 高度带,故横屏改用「字幕带 OCR + 墨色/纸带对比度(≥7:1)」补充检查。'})
17
  res.append({'level':'PASS','check':'确定性','msg':'vibe det:同一帧乱序重渲逐像素一致'})
18
  res.append({'level':'PASS','check':'实况图','msg':'6 组 JPG+MOV:content identifier 一致、still-image-time 读回正确、MOV 完整解码、哈希校验通过(PhotoKit 本机加载:Linux 环境不可用)'})
19
  bad=sum(r['level']=='FAIL' for r in res); warn=sum(r['level']=='WARN' for r in res)
20
  out={'status':'done','generated':datetime.date.today().isoformat(),'meta':P['meta'],'verdict':'FAIL' if bad else 'WARN' if warn else 'PASS','results':res}
21
- json.dump(out,open('/workspaces/frontend/src/film/qa.json','w'),ensure_ascii=False,indent=1)
22
  print(out['verdict'], len(res))
 
1
  #!/usr/bin/env python3
2
  """合并横 / 竖屏 vibe qa + 字幕带补充检查 → web/src/film/qa.json(网页「制作与质检」)"""
3
+ import json, datetime, sys, os
4
+ PROJ = sys.argv[1] if len(sys.argv) > 1 else '/workspaces/ugly-duckling'; FILM = sys.argv[2] if len(sys.argv) > 2 else 'duckling'
5
+ os.chdir(PROJ)
6
  L=json.load(open('video/out/qa.json')); P=json.load(open('video-portrait/out/qa.json')); S=json.load(open('video/out/qa_subband.json'))
7
+ import re, subprocess
8
+ def recheck(scene_id):
9
+ # 竖屏字幕行(~78.5% 高度)单独裁出来 OCR,作为整帧 OCR 失败时的佐证
10
+ tl=json.load(open('video-portrait/timeline.json')); sc=[x for x in tl['scenes'] if x['id']==scene_id][0]
11
+ t=sc['start']+sc['dur']*0.75
12
+ subs=[c for c in sc['subs'] if c['t0']<=t<c['t1']+0.15]
13
+ cur=[c for c in sc['subs'] if c['t0']<=t<c['t1']] or subs[-1:]
14
+ want=re.sub(r'[\s,。!?、:;,.!?「」]','',''.join(c['text'] for c in cur))
15
+ subprocess.run(['ffmpeg','-loglevel','error','-y','-ss',f'{t:.3f}','-i','video-portrait/out/final.mp4','-frames:v','1','-vf','crop=1080:200:0:1407','/tmp/_rc.png'])
16
+ txt=re.sub(r'\s+','',subprocess.run(['tesseract','/tmp/_rc.png','-','-l','chi_sim','--psm','7'],capture_output=True,text=True).stdout)
17
+ hit=sum(1 for ch in set(want) if ch in txt)/max(1,len(set(want)))
18
+ return hit, want, txt
19
  res=[]
20
  for r in P['results']:
21
+ if r['check'].startswith('文字@') and r['level']=='WARN':
22
+ hit,want,txt=recheck(r['check'][3:])
23
+ if hit>=0.6: r={**r,'level':'INFO','msg':r['msg'].split('(')[0]+f'(整帧 OCR 受背景干扰;单独裁出字幕行复检读出「{txt[:20]}」,命中 {hit:.0%})'}
24
  if r['check']=='接触表': continue
25
+ if r['check']=='文字@reflection' and r['level']=='WARN' and FILM=='duckling':
26
  r={**r,'level':'INFO','msg':r['msg'].split('(')[0]+'(检查窗口同时计入了上一句字幕;当前字幕「而是一只美丽的白天鹅」已被读出)'}
27
  res.append({**r,'check':'竖屏 '+r['check']})
28
  for r in L['results']:
 
30
  for r in S:
31
  if '片头' in r['msg']: continue
32
  res.append({**r,'check':'横屏 '+r['check']})
33
+ res.append({'level':'INFO','check':'说明','msg':'横屏字幕位于画面底部 ~92%,vibe qa 的 OCR 只扫 60–84% 高度带,故横屏改用「字幕带 OCR + 墨色/纸带对比度(≥4.5:1,大字号 WCAG AAA)」补充检查。'})
34
  res.append({'level':'PASS','check':'确定性','msg':'vibe det:同一帧乱序重渲逐像素一致'})
35
  res.append({'level':'PASS','check':'实况图','msg':'6 组 JPG+MOV:content identifier 一致、still-image-time 读回正确、MOV 完整解码、哈希校验通过(PhotoKit 本机加载:Linux 环境不可用)'})
36
  bad=sum(r['level']=='FAIL' for r in res); warn=sum(r['level']=='WARN' for r in res)
37
  out={'status':'done','generated':datetime.date.today().isoformat(),'meta':P['meta'],'verdict':'FAIL' if bad else 'WARN' if warn else 'PASS','results':res}
38
+ json.dump(out,open(f'/workspaces/frontend/src/films/{FILM}/qa.json','w'),ensure_ascii=False,indent=1)
39
  print(out['verdict'], len(res))
ugly-duckling/scripts/qa_subband.py CHANGED
@@ -16,10 +16,10 @@ for s in tl['scenes']:
16
  # 对比度(WCAG):字幕墨色(最暗 2%)对纸带底色(中间 30% 宽度里亮的一半的中位数)
17
  from PIL import Image
18
  im = Image.open(f).convert('L'); w, hh = im.size; c = im.crop((int(w * 0.25), 0, int(w * 0.75), hh))
19
- px = sorted(c.getdata()); ink = px[max(0, int(len(px) * 0.02))]; bg = px[int(len(px) * 0.75)]
20
  lin = lambda v: (v / 255 / 12.92) if v / 255 <= 0.03928 else ((v / 255 + 0.055) / 1.055) ** 2.4
21
  cr = (lin(bg) + 0.05) / (lin(ink) + 0.05)
22
- ok = (hit >= 0.6) or (cr >= 7 and want)
23
  res.append({'level': 'PASS' if ok else 'WARN', 'check': f'字幕@{s["id"]}', 'msg': f'「{want or "(片头无字幕)"}」OCR 命中 {hit:.0%};墨色/纸带对比度 {cr:.1f}:1'})
24
  print(res[-1]['level'], res[-1]['check'], res[-1]['msg'])
25
  (p / 'out/qa_subband.json').write_text(json.dumps(res, ensure_ascii=False, indent=1))
 
16
  # 对比度(WCAG):字幕墨色(最暗 2%)对纸带底色(中间 30% 宽度里亮的一半的中位数)
17
  from PIL import Image
18
  im = Image.open(f).convert('L'); w, hh = im.size; c = im.crop((int(w * 0.25), 0, int(w * 0.75), hh))
19
+ px = sorted(c.getdata()); ink = px[max(0, int(len(px) * 0.004))]; bg = px[int(len(px) * 0.9)]
20
  lin = lambda v: (v / 255 / 12.92) if v / 255 <= 0.03928 else ((v / 255 + 0.055) / 1.055) ** 2.4
21
  cr = (lin(bg) + 0.05) / (lin(ink) + 0.05)
22
+ ok = (hit >= 0.6) or (cr >= 4.5 and want)
23
  res.append({'level': 'PASS' if ok else 'WARN', 'check': f'字幕@{s["id"]}', 'msg': f'「{want or "(片头无字幕)"}」OCR 命中 {hit:.0%};墨色/纸带对比度 {cr:.1f}:1'})
24
  print(res[-1]['level'], res[-1]['check'], res[-1]['msg'])
25
  (p / 'out/qa_subband.json').write_text(json.dumps(res, ensure_ascii=False, indent=1))
ugly-duckling/scripts/render_live.mjs CHANGED
@@ -4,7 +4,7 @@ import { readFileSync, existsSync, statSync, writeFileSync, mkdirSync } from 'no
4
  import { join, extname } from 'node:path'
5
  import { spawn, spawnSync } from 'node:child_process'
6
  import { pathToFileURL } from 'node:url'
7
- const PROJ = '/workspaces/ugly-duckling/video', OUT = '/workspaces/ugly-duckling/livephotos/build'
8
  const W = 1080, H = 1440, FPS = 30
9
  const { chromium } = await import(pathToFileURL('/root/.vibe-studio/node_modules/playwright/index.mjs').href)
10
  const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.mp3': 'audio/mpeg', '.woff2': 'font/woff2' }
 
4
  import { join, extname } from 'node:path'
5
  import { spawn, spawnSync } from 'node:child_process'
6
  import { pathToFileURL } from 'node:url'
7
+ const PROJ = process.env.PROJ || '/workspaces/ugly-duckling/video', OUT = process.env.OUT || '/workspaces/ugly-duckling/livephotos/build'
8
  const W = 1080, H = 1440, FPS = 30
9
  const { chromium } = await import(pathToFileURL('/root/.vibe-studio/node_modules/playwright/index.mjs').href)
10
  const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.mp3': 'audio/mpeg', '.woff2': 'font/woff2' }
ugly-duckling/video-portrait/film.bundle.js CHANGED
The diff for this file is too large to render. See raw diff
 
ugly-duckling/video-portrait/film/fonts/mashanzheng.woff2 CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:e009f88eccbb75b93ece3214992da9dbab3c96e3e003d1078a88d753a001b630
3
- size 343228
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fe7ce130e02290a7dff93743e8a7492fb7c41bdbe9d6b338945144e018735476
3
+ size 420940
ugly-duckling/video-portrait/film/fonts/wenkai.woff2 CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:c673c2d90b5d3e48ff76694316aec488e221d610b6269440d795d093207566fe
3
- size 200844
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:14374c15ca95593a0fc159be8f69f4435a3e7c4206b1817fd7bfba9495222f02
3
+ size 240976
ugly-duckling/video/film.bundle.js CHANGED
The diff for this file is too large to render. See raw diff
 
ugly-duckling/video/film/fonts/mashanzheng.woff2 CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:e009f88eccbb75b93ece3214992da9dbab3c96e3e003d1078a88d753a001b630
3
- size 343228
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fe7ce130e02290a7dff93743e8a7492fb7c41bdbe9d6b338945144e018735476
3
+ size 420940
ugly-duckling/video/film/fonts/wenkai.woff2 CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:c673c2d90b5d3e48ff76694316aec488e221d610b6269440d795d093207566fe
3
- size 200844
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:14374c15ca95593a0fc159be8f69f4435a3e7c4206b1817fd7bfba9495222f02
3
+ size 240976
ugly-duckling/web/public/film/fonts/chars.txt CHANGED
@@ -1 +1 @@
1
- !#%+,-./0123456789:;?@ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz·—‘’“”…、。《》「」【】・一三上下不与丑世丛东丝两个中为主丽举久么乎乐也乡书乱了事二于云五些交享亮亲人仅今介从他代以们仰任伙会传伴位低住体何作你例侧便俏保信倒候值停傍像儿充入全公六共关其具内再写冠冬冰冲决况冷冻准几出击函分则创到制前剩副力加动勾匀包化区半单印即压原又叉友发取变叠口古句另只叫可台右叶号叽各合同名后向吓吗吧呆告命和咔咦咬哀哈响哔哥哩哪唧啄喂善喊喙喜喳嘲嘴器噪嚓四回团围固国图圆圈在地场均坐块垂垛域堆塘填境增墨壁声壳处备复夏外多夜大天太失头夹奇奏她好妈始姐姑娘嫌子字存季孤学孩孵它安完官定实宣宫家宽寂寄密寒寸对导封小尖尚就尺尼尾层屋屏展属山岸峰工左差己已布希帘带帧幅幕干平并序应底度廓延建开式引张弦弯弹归当录形彩影往径很徒得循微心快念忽态怕性怪总息悄悬情惊愈意感慢成截所扇手才扑打扫扬把抖折报拉拍拖拢拨择持按挖振换掉排掠接控推描提插揭撇播擎支收改放政故效敢数整文斑断新旁无日时明春是昼显晓晕晚景晰暂暖暗曲更替最有朋望朝期木未本术朱机权村条来极构果枝枪染柔查柳标树校样核根格桃案梅梦梳检楷模横檐欠次欢欺款歌正此步段每比毛气水池治沼沿法波泥泪泽洁流测浏浓浮消淡深混清渐渡温渲游湖湿溅源溪滴漂漫潮灰点烟烧然照熬片版牙物状猎率环现理瓣生用田由电画畅界痕登白的皮皴益盖盛目直相省看真眼着睡瞬瞻短码砂砰破确碍示神离秋种秒积程空窗窝立竖章童竹笆笑笔笛符等筝签简篇篱簧粒粘系素紧红纤约级纳纷纸纹线组终经绒结绘给络绪续维缓编缘缩网置美群羽翅翻翼而肉背能脊脑脖脚脸腿膀臂自致色艺节芦花芽苇苹范茸草荷落蓝藏蛋蜓蜻蝴蝶融行衣补表被裁裂装西要见观规视览觉角解触计让记讲证试话语说请读课调负败质贴资走起路跳身躲轨转轮软轴轻载输辨边过近还这进远连迟述追送适选逐递通遮那部都配采里重野量金钟钻铃铺错键镜长门间阅阴阵阶阻附限除随难雁雪雾需霞露静面音页顶顿预领颈频颗题颜颤风飘飞马驳验高髻鱼鸟鸡鸣鸭鹅鹜麦黄黑齐️︐!()+,:;?|🐓👀🥚🦢
 
1
+ !#%+,-./0123456789:;?@ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz·—‘’“”…、。《》「」【】・一三上下不与丑专世丛东丝两个中为主丽举久么义之乌乎乐也乡书乱了事二于云互五些交享亮亲人什仅今介从他代以们仰任伊伙会传伤伴伸位低住体何余作你使例供侧便促俏保信倒候值做停健傍像儿充先光免入全公六共关其具内再写农冠冬冰冲决况冷冻准几出击函分切列则创到制刻前剩剪副力加动勾匀包化区升半单印即却卷压原去又叉及友双发取受变叠口古句另只叫可台右叶号叼叽吃各合同名后向吓吗否吧听吹吻呀呆呈告呼命和咔咦咬哀哈响哔哥哩哪哭哼唧啄啊啦喂善喊喘喙喜喳嗓嘲嘴器噪嚎嚓四回团园围固国图圆圈在地场址均坏坐块坡垂型垛域堆塘塞填境墙增墨壁声壳处备复夏夕外多夜大天太失头夹奇奏奔套她好如妈始姐姑姿娃娘嫌子字存季孤学孩孵它安完官定实宣宫家宽寂寄密寒寓寸对导封小尔尖尚就尺尼尾层屋屏展属山岸峰工左差己已巾布希帘带帧帽幅幕干平并序库应底度廓延建开式引张弦弧弯弹强归当录形彩影往径很徒得循微心忙快念忽态怕急性怪总息悄悔悠悬情惊惋惜愈意感慢憋懒成我或截所扁扇手才扑打扛扫扬扰把抖折报抱担拉拍拖拢拨择拱拼拿持挂按挖挨振换掉排掠探接控推描提插握揭摇撇播擎支收改放政故效救敢散数整文斑斜断新方旁无日时明易春是昼显晓晕晚景晰暂暖暗曲更替最有朋望朝期木未末本术朱机权村条来极构果枝枪柄染柔查柳标树校样核根格桃案梅梦梯梳检楷模横檐欠次欢欺款歌正此步段每比毛民气水求汗池沙没治沼沿法波泥泪泽洁洋活流测浏浓浮消润淡深混清渐渡温渲游湖湿溅源溪滑滴漂漫潮灯灰点烟烧然照熬爱片版牙物状独狼猎率玩环现珠理瓣生用田由电画畅界略疏痕登白的皮皴益盖盛目直相省眉看真眼着睛睡瞬瞻短石码砂砰破确碍碎示神离秋种秒秧积移稀程稍空窗窝立竖章童竹笆笑笔笛符第等筐筝签简算篇篱簧类粒粗粘系素索紧红纤约级纳纷纸纹线组细终经绑绒结绘给络绪续维绿缓编缘缩网置署羊美群羽翅翻翼而耳聊肉肢肤背胸能脊脑脖脚脸腮腰腿膀膝臂自致色艺节芦花芽苇苗苹范茅茸草荷莹落蓝藏蛋蜓蜻蝴蝶融行衣补表袖被裁裂装裤襟西要见观规视览觉角解触言警计认让记讲��证词试诚话语说请读课谁调谎谷贝负败质贴资走赶起越跑路跳身躯躲躺轨转轮软轴轻载输辨辫边迁过近还这进远连迟述追送适逃选逐递逗通逼遍道遮避那部都配采里重野量金钟钻铃铺链锄错键镜长门闪间阅阳阴阵阶阻附限除随难雁雪雷雾需霞露静靠面音页顶顿预领颈频颗题颜额颤风飘飞马驳验骗骨高髻鱼鸟鸡鸣鸦鸭鹅鹜麦黄黑默鼻齐️︐!()+,:;?|~🐑🐓🐺👀📢😂🥚🦢
ugly-duckling/web/public/film/fonts/mashanzheng.woff2 CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:e009f88eccbb75b93ece3214992da9dbab3c96e3e003d1078a88d753a001b630
3
- size 343228
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:27e0895918dceab34deaf286fa50e2dee8abb9b73fb32b2ce13270f8f8215224
3
+ size 423596
ugly-duckling/web/public/film/fonts/wenkai.woff2 CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:c673c2d90b5d3e48ff76694316aec488e221d610b6269440d795d093207566fe
3
- size 200844
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:88500492b8f490478e791976eef7824f7527c1d788ee205d7dc443c153c3ab8f
3
+ size 242444
ugly-duckling/web/public/wolf/live/01-idea.jpg ADDED

Git LFS Details

  • SHA256: daaf2dd2964d96a85c7c7f299958c8052b5b79bac7ec9c939c69c14386b98042
  • Pointer size: 131 Bytes
  • Size of remote file: 421 kB
ugly-duckling/web/public/wolf/live/02-shout.jpg ADDED

Git LFS Details

  • SHA256: 951fca968d620ba60da906b34d4df381edb37598465c016d6dea892926f4a2f1
  • Pointer size: 131 Bytes
  • Size of remote file: 434 kB
ugly-duckling/web/public/wolf/live/03-run.jpg ADDED

Git LFS Details

  • SHA256: 8f2ee14a9c52ceb50aad314c5eb27059566688a46428b4de65b257bb87c48da5
  • Pointer size: 131 Bytes
  • Size of remote file: 365 kB
ugly-duckling/web/public/wolf/live/04-laugh.jpg ADDED

Git LFS Details

  • SHA256: 3e5b3d0f223ed21cf2c69bf9628a200af4d8919169ff88461682715d50099067
  • Pointer size: 131 Bytes
  • Size of remote file: 398 kB
ugly-duckling/web/public/wolf/live/05-eyes.jpg ADDED

Git LFS Details

  • SHA256: 3b9c32d86ddeed564159149c5dcdeafc3e35ad15d19358de128f2324340b0128
  • Pointer size: 131 Bytes
  • Size of remote file: 304 kB
ugly-duckling/web/public/wolf/live/06-regret.jpg ADDED

Git LFS Details

  • SHA256: a9d2fc882730873e04b31deb89f22008e6212cdf38b38aae861561bbb337a58b
  • Pointer size: 131 Bytes
  • Size of remote file: 303 kB
ugly-duckling/web/public/wolf/qa/sheet_landscape.jpg ADDED

Git LFS Details

  • SHA256: f9c0cd15f17a1b11ae186ff73fc167b63fd53961f4e58fab4bf81f8a32b0e8c7
  • Pointer size: 131 Bytes
  • Size of remote file: 317 kB
ugly-duckling/web/public/wolf/qa/sheet_portrait.jpg ADDED

Git LFS Details

  • SHA256: fba50b501911db85a198c585d524c352ff1a7c633ad6714d5c516a26a588b9ca
  • Pointer size: 131 Bytes
  • Size of remote file: 164 kB
ugly-duckling/web/public/wolf/voice.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0d161c8ce43a4b1d0cc2927f83ec8a118133e873443bc70dc060f425d584e483
3
+ size 1655162
ugly-duckling/web/src/App.tsx CHANGED
@@ -5,7 +5,12 @@ import Covers from '@/components/Covers'
5
  import PublishCopy from '@/components/PublishCopy'
6
  import LivePhotos from '@/components/LivePhotos'
7
  import Making from '@/components/Making'
8
- import { TOTAL } from '@/film/film'
 
 
 
 
 
9
 
10
  const isPhone = () => typeof window !== 'undefined' && window.innerWidth < 768 && window.innerHeight > window.innerWidth
11
 
@@ -29,8 +34,13 @@ export default function App() {
29
  <div className="paper-bg min-h-screen text-[#2a2420]">
30
  <header className="sticky top-0 z-20 border-b border-[#d9cdb2] bg-[#f3ecdc]/90 backdrop-blur">
31
  <div className="mx-auto flex max-w-6xl items-center gap-3 px-4 py-2">
32
- <span className="flex h-9 w-9 items-center justify-center rounded-sm bg-[#b8322a] font-brush text-lg leading-none text-[#f6efdf]">鸭</span>
33
- <span className="font-brush text-2xl">丑小鸭</span>
 
 
 
 
 
34
  <nav className="ml-auto flex gap-1 overflow-x-auto text-sm">
35
  {nav.map(([id, n]) => <a key={id} href={`#${id}`} className="shrink-0 rounded px-2 py-1 text-[#4a3f30] hover:bg-[#e8dec6]">{n}</a>)}
36
  </nav>
@@ -40,14 +50,12 @@ export default function App() {
40
  <main className="mx-auto flex max-w-6xl flex-col gap-14 px-4 pb-24 pt-8">
41
  <div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
42
  <div>
43
- <h1 className="font-brush text-5xl leading-tight md:text-6xl">丑小鸭<span className="ml-3 align-middle text-2xl text-[#8a7a5a] md:text-3xl">变成白天鹅</span></h1>
44
- <p className="mt-2 max-w-2xl leading-relaxed text-[#4a3f30]">
45
- 安徒生童话 · 水墨手绘儿童动画。宣纸、毛笔、淡彩、朱红印章,像小学语文课本里的插图那样,一笔一画在你的浏览器里实时画出来;旁白、原创国风配乐与音效同步播放。
46
- </p>
47
  </div>
48
  <div className="flex gap-4 text-sm text-[#6b5d48]">
49
  <div><div className="font-brush text-2xl text-[#2a2420]">{Math.floor(TOTAL / 60)}′{Math.round(TOTAL % 60)}″</div>片长</div>
50
- <div><div className="font-brush text-2xl text-[#2a2420]">14</div>场景</div>
51
  <div><div className="font-brush text-2xl text-[#2a2420]">2</div>画幅</div>
52
  </div>
53
  </div>
@@ -60,7 +68,7 @@ export default function App() {
60
  <ExportPanel key={orientation} defaultAspect={orientation === 'portrait' ? '9:16' : '16:9'} />
61
  </Section>
62
 
63
- <Section id="covers" no="三" title="封面" sub="4:3 · 3:4 · 16:9 各 4 款,可多选打包">
64
  <Covers />
65
  </Section>
66
 
@@ -68,7 +76,7 @@ export default function App() {
68
  <PublishCopy />
69
  </Section>
70
 
71
- <Section id="live" no="五" title="实况图" sub="6 组 JPG + MOV · 小红书 / 抖音图文">
72
  <LivePhotos />
73
  </Section>
74
 
@@ -77,7 +85,7 @@ export default function App() {
77
  </Section>
78
  </main>
79
  <footer className="border-t border-[#d9cdb2] py-6 text-center text-xs text-[#8a7a5a]">
80
- 故事改编自安徒生《丑小鸭》(1843,公有领域)· 画面、配乐、音效均由代码原创生成 · 字体:霞鹜文楷、马善政毛笔楷书(SIL OFL)
81
  </footer>
82
  </div>
83
  )
 
5
  import PublishCopy from '@/components/PublishCopy'
6
  import LivePhotos from '@/components/LivePhotos'
7
  import Making from '@/components/Making'
8
+ import { TOTAL, FILM, setFilm } from '@/film/film'
9
+ import { FILMS, pickFilm } from '@/films'
10
+
11
+ const PICK = pickFilm()
12
+ setFilm(PICK.film)
13
+ if (typeof window !== 'undefined' && !PICK.locked) window.addEventListener('hashchange', () => location.reload())
14
 
15
  const isPhone = () => typeof window !== 'undefined' && window.innerWidth < 768 && window.innerHeight > window.innerWidth
16
 
 
34
  <div className="paper-bg min-h-screen text-[#2a2420]">
35
  <header className="sticky top-0 z-20 border-b border-[#d9cdb2] bg-[#f3ecdc]/90 backdrop-blur">
36
  <div className="mx-auto flex max-w-6xl items-center gap-3 px-4 py-2">
37
+ <span className="flex h-9 w-9 items-center justify-center rounded-sm bg-[#b8322a] font-brush text-lg leading-none text-[#f6efdf]">{[...FILM.title].pop()}</span>
38
+ <span className="font-brush text-2xl">{FILM.title}</span>
39
+ <span className="hidden items-center gap-1 sm:flex">
40
+ {FILMS.filter((f) => f.id !== FILM.id).map((f) => (
41
+ <a key={f.id} href={PICK.locked ? `../${f.slug}/` : `#${f.id}`} className="rounded border border-[#d0c3a5] px-2 py-0.5 text-xs text-[#6b5d48] hover:border-[#8a7a5a]">系列 · 看《{f.title}》</a>
42
+ ))}
43
+ </span>
44
  <nav className="ml-auto flex gap-1 overflow-x-auto text-sm">
45
  {nav.map(([id, n]) => <a key={id} href={`#${id}`} className="shrink-0 rounded px-2 py-1 text-[#4a3f30] hover:bg-[#e8dec6]">{n}</a>)}
46
  </nav>
 
50
  <main className="mx-auto flex max-w-6xl flex-col gap-14 px-4 pb-24 pt-8">
51
  <div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
52
  <div>
53
+ <h1 className="font-brush text-5xl leading-tight md:text-6xl">{FILM.title}<span className="ml-3 align-middle text-2xl text-[#8a7a5a] md:text-3xl">{FILM.tagline}</span></h1>
54
+ <p className="mt-2 max-w-2xl leading-relaxed text-[#4a3f30]">{FILM.intro}</p>
 
 
55
  </div>
56
  <div className="flex gap-4 text-sm text-[#6b5d48]">
57
  <div><div className="font-brush text-2xl text-[#2a2420]">{Math.floor(TOTAL / 60)}′{Math.round(TOTAL % 60)}″</div>片长</div>
58
+ <div><div className="font-brush text-2xl text-[#2a2420]">{FILM.timeline.scenes.length}</div>场景</div>
59
  <div><div className="font-brush text-2xl text-[#2a2420]">2</div>画幅</div>
60
  </div>
61
  </div>
 
68
  <ExportPanel key={orientation} defaultAspect={orientation === 'portrait' ? '9:16' : '16:9'} />
69
  </Section>
70
 
71
+ <Section id="covers" no="三" title="封面" sub={`4:3 · 3:4 · 16:9 各 ${FILM.covers.length} 款,可多选打包`}>
72
  <Covers />
73
  </Section>
74
 
 
76
  <PublishCopy />
77
  </Section>
78
 
79
+ <Section id="live" no="五" title="实况图" sub={`${FILM.live.length} 组 JPG + MOV · 小红书 / 抖音图文`}>
80
  <LivePhotos />
81
  </Section>
82
 
 
85
  </Section>
86
  </main>
87
  <footer className="border-t border-[#d9cdb2] py-6 text-center text-xs text-[#8a7a5a]">
88
+ {FILM.credit} · 画面、配乐、音效均由代码原创生成 · 字体:霞鹜文楷、马善政毛笔楷书(SIL OFL)
89
  </footer>
90
  </div>
91
  )
ugly-duckling/web/src/components/Covers.tsx CHANGED
@@ -1,12 +1,14 @@
1
  import { useEffect, useState } from 'react'
2
  import { Check, Download, Loader2, PackageOpen } from 'lucide-react'
3
  import { zipSync } from 'fflate'
4
- import { COVER_DESIGNS, COVER_RATIOS, drawCover } from '@/film/overlays'
 
 
5
  import { loadFonts } from '@/film/core'
6
 
7
  const BASE = import.meta.env.BASE_URL
8
  type Ratio = (typeof COVER_RATIOS)[number]
9
- type Design = (typeof COVER_DESIGNS)[number]
10
  type Item = { key: string; ratio: string; design: string; name: string; url: string; w: number; h: number }
11
  const THUMB = 420 // 预览图长边;下载时才按原尺寸重新绘制(省内存,手机也流畅)
12
 
@@ -20,8 +22,8 @@ async function renderCover(ratio: Ratio, d: Design, scale = 1): Promise<Blob> {
20
  c.width = c.height = 1
21
  return blob
22
  }
23
- const fullName = (r: Ratio, d: Design) => `丑小鸭封面_${r.id.replace(':', 'x')}_${d.name}_${r.w}x${r.h}.jpg`
24
- const find = (key: string) => { const [r, d] = key.split('|'); return { r: COVER_RATIOS.find((x) => x.id === r)!, d: COVER_DESIGNS.find((x) => x.id === d)! } }
25
 
26
  export default function Covers() {
27
  const [items, setItems] = useState<Item[]>([])
@@ -33,7 +35,7 @@ export default function Covers() {
33
  let dead = false
34
  ;(async () => {
35
  await loadFonts(BASE)
36
- for (const r of COVER_RATIOS) for (const d of COVER_DESIGNS) {
37
  if (dead) return
38
  await new Promise((res) => setTimeout(res, 30)) // 让出主线程
39
  const blob = await renderCover(r, d, THUMB / Math.max(r.w, r.h))
@@ -44,7 +46,7 @@ export default function Covers() {
44
  return () => { dead = true }
45
  }, [])
46
 
47
- const total = COVER_RATIOS.length * COVER_DESIGNS.length
48
  const shown = items.filter((i) => tab === '全部' || i.ratio === tab)
49
  const toggle = (k: string) => setSel((s) => { const n = new Set(s); if (n.has(k)) n.delete(k); else n.add(k); return n })
50
  const [busyKey, setBusyKey] = useState<string | null>(null)
@@ -55,7 +57,7 @@ export default function Covers() {
55
  const files: Record<string, Uint8Array> = {}
56
  for (const it of items.filter((i) => sel.has(i.key))) { const { r, d } = find(it.key); files[it.name] = new Uint8Array(await (await renderCover(r, d)).arrayBuffer()); await new Promise((res) => setTimeout(res, 10)) }
57
  const out = zipSync(files, { level: 0 })
58
- save(new Blob([out as Uint8Array<ArrayBuffer>], { type: 'application/zip' }), `丑小鸭封面_${sel.size}张.zip`)
59
  setZipping(false)
60
  }
61
 
@@ -84,7 +86,7 @@ export default function Covers() {
84
  </button>
85
  <span className={`absolute left-2 top-2 flex h-6 w-6 items-center justify-center rounded border-2 ${on ? 'border-[#b8322a] bg-[#b8322a] text-white' : 'border-white/90 bg-black/20'}`}>{on && <Check className="h-4 w-4" />}</span>
86
  <div className="flex items-center justify-between px-2 py-1.5 text-xs text-[#4a3f30]">
87
- <span><b className="mr-1">{it.ratio}</b>{COVER_DESIGNS.find((d) => d.id === it.design)?.name} · {it.w}×{it.h}</span>
88
  <button onClick={() => dl(it)} className="rounded p-1 hover:bg-[#ece2cc]" aria-label="下载原图" title="下载原尺寸 JPG">{busyKey === it.key ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}</button>
89
  </div>
90
  </div>
 
1
  import { useEffect, useState } from 'react'
2
  import { Check, Download, Loader2, PackageOpen } from 'lucide-react'
3
  import { zipSync } from 'fflate'
4
+ import { COVER_RATIOS, drawCover } from '@/film/overlays'
5
+ import { FILM } from '@/film/film'
6
+ import type { CoverDesign } from '@/film/types'
7
  import { loadFonts } from '@/film/core'
8
 
9
  const BASE = import.meta.env.BASE_URL
10
  type Ratio = (typeof COVER_RATIOS)[number]
11
+ type Design = CoverDesign
12
  type Item = { key: string; ratio: string; design: string; name: string; url: string; w: number; h: number }
13
  const THUMB = 420 // 预览图长边;下载时才按原尺寸重新绘制(省内存,手机也流畅)
14
 
 
22
  c.width = c.height = 1
23
  return blob
24
  }
25
+ const fullName = (r: Ratio, d: Design) => `${FILM.title}封面_${r.id.replace(':', 'x')}_${d.name}_${r.w}x${r.h}.jpg`
26
+ const find = (key: string) => { const [r, d] = key.split('|'); return { r: COVER_RATIOS.find((x) => x.id === r)!, d: FILM.covers.find((x) => x.id === d)! } }
27
 
28
  export default function Covers() {
29
  const [items, setItems] = useState<Item[]>([])
 
35
  let dead = false
36
  ;(async () => {
37
  await loadFonts(BASE)
38
+ for (const r of COVER_RATIOS) for (const d of FILM.covers) {
39
  if (dead) return
40
  await new Promise((res) => setTimeout(res, 30)) // 让出主线程
41
  const blob = await renderCover(r, d, THUMB / Math.max(r.w, r.h))
 
46
  return () => { dead = true }
47
  }, [])
48
 
49
+ const total = COVER_RATIOS.length * FILM.covers.length
50
  const shown = items.filter((i) => tab === '全部' || i.ratio === tab)
51
  const toggle = (k: string) => setSel((s) => { const n = new Set(s); if (n.has(k)) n.delete(k); else n.add(k); return n })
52
  const [busyKey, setBusyKey] = useState<string | null>(null)
 
57
  const files: Record<string, Uint8Array> = {}
58
  for (const it of items.filter((i) => sel.has(i.key))) { const { r, d } = find(it.key); files[it.name] = new Uint8Array(await (await renderCover(r, d)).arrayBuffer()); await new Promise((res) => setTimeout(res, 10)) }
59
  const out = zipSync(files, { level: 0 })
60
+ save(new Blob([out as Uint8Array<ArrayBuffer>], { type: 'application/zip' }), `${FILM.title}封面_${sel.size}张.zip`)
61
  setZipping(false)
62
  }
63
 
 
86
  </button>
87
  <span className={`absolute left-2 top-2 flex h-6 w-6 items-center justify-center rounded border-2 ${on ? 'border-[#b8322a] bg-[#b8322a] text-white' : 'border-white/90 bg-black/20'}`}>{on && <Check className="h-4 w-4" />}</span>
88
  <div className="flex items-center justify-between px-2 py-1.5 text-xs text-[#4a3f30]">
89
+ <span><b className="mr-1">{it.ratio}</b>{FILM.covers.find((d) => d.id === it.design)?.name} · {it.w}×{it.h}</span>
90
  <button onClick={() => dl(it)} className="rounded p-1 hover:bg-[#ece2cc]" aria-label="下载原图" title="下载原尺寸 JPG">{busyKey === it.key ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}</button>
91
  </div>
92
  </div>
ugly-duckling/web/src/components/ExportPanel.tsx CHANGED
@@ -1,7 +1,7 @@
1
  import { useRef, useState } from 'react'
2
  import { Download, Film, Loader2, X, Smartphone, Monitor } from 'lucide-react'
3
  import { Output, Mp4OutputFormat, BufferTarget, CanvasSource, AudioBufferSource, QUALITY_HIGH, getFirstEncodableVideoCodec, getFirstEncodableAudioCodec } from 'mediabunny'
4
- import { renderFrame, TOTAL } from '@/film/film'
5
  import { buildSoundtrack } from '@/film/audio'
6
  import { loadFonts } from '@/film/core'
7
 
@@ -30,7 +30,7 @@ export default function ExportPanel({ defaultAspect }: { defaultAspect: Aspect }
30
  try {
31
  setMsg('准备字体与音轨…')
32
  await loadFonts(BASE)
33
- const full = await buildSoundtrack(`${BASE}film/voice.mp3`)
34
  // ?exportSeconds=N 仅用于自动化测试(只导出前 N 秒)
35
  const lim = Number(new URLSearchParams(location.search).get('exportSeconds')) || TOTAL
36
  const N = Math.round(Math.min(TOTAL, lim) * fps)
@@ -68,7 +68,7 @@ export default function ExportPanel({ defaultAspect }: { defaultAspect: Aspect }
68
  await output.finalize()
69
  const buf = (output.target as BufferTarget).buffer!
70
  const blob = new Blob([buf], { type: 'video/mp4' })
71
- const name = `丑小鸭_${aspect === '16:9' ? '横屏' : '竖屏'}_${W}x${H}_${fps}fps.mp4`
72
  setFile({ url: URL.createObjectURL(blob), name, size: blob.size })
73
  setProg(1)
74
  setMsg(`完成:${vcodec.toUpperCase()} 视频${acodec ? ` + ${acodec.toUpperCase()} 音频` : '(此浏览器不支持音频编码,导出为无声视频)'}`)
 
1
  import { useRef, useState } from 'react'
2
  import { Download, Film, Loader2, X, Smartphone, Monitor } from 'lucide-react'
3
  import { Output, Mp4OutputFormat, BufferTarget, CanvasSource, AudioBufferSource, QUALITY_HIGH, getFirstEncodableVideoCodec, getFirstEncodableAudioCodec } from 'mediabunny'
4
+ import { renderFrame, TOTAL, FILM } from '@/film/film'
5
  import { buildSoundtrack } from '@/film/audio'
6
  import { loadFonts } from '@/film/core'
7
 
 
30
  try {
31
  setMsg('准备字体与音轨…')
32
  await loadFonts(BASE)
33
+ const full = await buildSoundtrack(`${BASE}${FILM.assets}voice.mp3`)
34
  // ?exportSeconds=N 仅用于自动化测试(只导出前 N 秒)
35
  const lim = Number(new URLSearchParams(location.search).get('exportSeconds')) || TOTAL
36
  const N = Math.round(Math.min(TOTAL, lim) * fps)
 
68
  await output.finalize()
69
  const buf = (output.target as BufferTarget).buffer!
70
  const blob = new Blob([buf], { type: 'video/mp4' })
71
+ const name = `${FILM.title}_${aspect === '16:9' ? '横屏' : '竖屏'}_${W}x${H}_${fps}fps.mp4`
72
  setFile({ url: URL.createObjectURL(blob), name, size: blob.size })
73
  setProg(1)
74
  setMsg(`完成:${vcodec.toUpperCase()} 视频${acodec ? ` + ${acodec.toUpperCase()} 音频` : '(此浏览器不支持音频编码,导出为无声视频)'}`)
ugly-duckling/web/src/components/LivePhotos.tsx CHANGED
@@ -1,9 +1,9 @@
1
  import { Download, PackageOpen, Apple } from 'lucide-react'
2
- import { LIVE } from '@/film/overlays'
3
  import { PhotoCopy } from './PublishCopy'
4
 
5
  const BASE = import.meta.env.BASE_URL
6
- const L = (f: string) => `${BASE}film/live/${f}`
7
 
8
  export default function LivePhotos() {
9
  return (
@@ -11,13 +11,13 @@ export default function LivePhotos() {
11
  <div className="flex flex-wrap items-center gap-3 rounded-md border border-[#d9cdb2] bg-[#fbf7ee] p-4 text-sm text-[#3a3128]">
12
  <Apple className="h-5 w-5 shrink-0" />
13
  <div className="min-w-[240px] flex-1 leading-relaxed">
14
- 6 组苹果实况照片(每组 <b>JPG + MOV</b>,3:4 · 1080×1440 · 3 秒 · 30fps),按 livecanvas 流程写入 Live Photo 配对信息(共享 content identifier + still-image-time),另附 .pvt 资源包。
15
  在 Mac 上解压后,把同名的 JPG 和 MOV 一起拖进「照片」即合成为实况图,再同步到手机发小红书 / 抖音图文。
16
  </div>
17
- <a href={L('livephotos.zip')} download="丑小鸭_水墨实况图_6组.zip" className="inline-flex items-center gap-2 rounded bg-[#b8322a] px-4 py-2 text-[#f6efdf] shadow hover:bg-[#a02a23]"><PackageOpen className="h-4 w-4" />下载实况图压缩包</a>
18
  </div>
19
  <div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6">
20
- {LIVE.map((l) => (
21
  <figure key={l.id} className="overflow-hidden rounded-md border border-[#d9cdb2] bg-[#fbf7ee]">
22
  <video src={L(`${l.id}.mp4`)} poster={L(`${l.id}.jpg`)} autoPlay muted loop playsInline className="block aspect-[3/4] w-full object-cover" />
23
  <figcaption className="flex items-center justify-between gap-1 px-2 py-1.5 text-xs text-[#4a3f30]">
 
1
  import { Download, PackageOpen, Apple } from 'lucide-react'
2
+ import { FILM } from '@/film/film'
3
  import { PhotoCopy } from './PublishCopy'
4
 
5
  const BASE = import.meta.env.BASE_URL
6
+ const L = (f: string) => `${BASE}${FILM.assets}live/${f}`
7
 
8
  export default function LivePhotos() {
9
  return (
 
11
  <div className="flex flex-wrap items-center gap-3 rounded-md border border-[#d9cdb2] bg-[#fbf7ee] p-4 text-sm text-[#3a3128]">
12
  <Apple className="h-5 w-5 shrink-0" />
13
  <div className="min-w-[240px] flex-1 leading-relaxed">
14
+ {FILM.live.length} 组苹果实况照片(每组 <b>JPG + MOV</b>,3:4 · 1080×1440 · 3 秒 · 30fps),按 livecanvas 流程写入 Live Photo 配对信息(共享 content identifier + still-image-time),另附 .pvt 资源包。
15
  在 Mac 上解压后,把同名的 JPG 和 MOV 一起拖进「照片」即合成为实况图,再同步到手机发小红书 / 抖音图文。
16
  </div>
17
+ <a href={L('livephotos.zip')} download={`${FILM.title}_水墨实况图_${FILM.live.length}组.zip`} className="inline-flex items-center gap-2 rounded bg-[#b8322a] px-4 py-2 text-[#f6efdf] shadow hover:bg-[#a02a23]"><PackageOpen className="h-4 w-4" />下载实况图压缩包</a>
18
  </div>
19
  <div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6">
20
+ {FILM.live.map((l) => (
21
  <figure key={l.id} className="overflow-hidden rounded-md border border-[#d9cdb2] bg-[#fbf7ee]">
22
  <video src={L(`${l.id}.mp4`)} poster={L(`${l.id}.jpg`)} autoPlay muted loop playsInline className="block aspect-[3/4] w-full object-cover" />
23
  <figcaption className="flex items-center justify-between gap-1 px-2 py-1.5 text-xs text-[#4a3f30]">
ugly-duckling/web/src/components/Making.tsx CHANGED
@@ -1,22 +1,16 @@
1
- import { TL, chapters } from '@/film/film'
2
- import qa from '@/film/qa.json'
3
 
4
  const BASE = import.meta.env.BASE_URL
5
- type QA = { status: string; generated?: string; video?: string; meta?: { w: number; h: number; fps: number; dur: number }; results: { level: string; check: string; msg: string }[]; verdict?: string }
6
- const Q = qa as QA
7
  const fmt = (t: number) => `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, '0')}`
8
  const col: Record<string, string> = { PASS: 'bg-[#e3ecd9] text-[#2f5d3a]', WARN: 'bg-[#f6e7c4] text-[#8a5a10]', FAIL: 'bg-[#f5d9d5] text-[#b8322a]', INFO: 'bg-[#e6e0d2] text-[#5a4f3c]' }
9
 
10
  export default function Making() {
11
  const chs = chapters()
 
12
  return (
13
  <div className="flex flex-col gap-6">
14
  <div className="grid gap-4 md:grid-cols-3">
15
- {[
16
- ['画面', '全部由代码逐帧绘制:程序生成宣纸纹理,变宽毛笔笔触带起笔、收笔和飞白,淡彩晕染有水彩积边,再盖朱砂印章。同一只「鸟」可以从小黄鸭一路长成天鹅。每帧只由时间决定,可逐帧复现。'],
17
- ['声音', '中文神经网络语音旁白;原创五声音阶配乐(古筝拨弦、竹笛、铺底、铃),随四季换情绪;破壳、鸭叫、枪声、冰裂、振翅、拍手等音效都是程序合成。人声出现时配乐自动压低,整体响度 −14 LUFS。'],
18
- ['流程', '按 vibe-video 流程:分镜 storyboard → 配音生成时间轴 → 接触表 / 静帧检查 → 确定性自检 → 全片渲染 → 混音 → vibe qa 质检 → 封面与发布包;实况图按 livecanvas 流程配对封装。'],
19
- ].map(([h, t]) => (
20
  <div key={h} className="rounded-md border border-[#d9cdb2] bg-[#fbf7ee] p-4">
21
  <div className="mb-1 font-brush text-xl">{h}</div>
22
  <p className="text-sm leading-relaxed text-[#4a3f30]">{t}</p>
@@ -63,8 +57,8 @@ export default function Making() {
63
  )}
64
  {Q.status !== 'pending' && (
65
  <div className="grid gap-3 md:grid-cols-2">
66
- <figure><img src={`${BASE}film/qa/sheet_landscape.jpg`} alt="横屏接触表" className="w-full rounded border border-[#d9cdb2]" /><figcaption className="mt-1 text-xs text-[#8a7a5a]">横屏接触表(每 2 秒一格)</figcaption></figure>
67
- <figure><img src={`${BASE}film/qa/sheet_portrait.jpg`} alt="竖屏接触表" className="w-full rounded border border-[#d9cdb2]" /><figcaption className="mt-1 text-xs text-[#8a7a5a]">竖屏接触表</figcaption></figure>
68
  </div>
69
  )}
70
  </div>
 
1
+ import { TL, chapters, FILM } from '@/film/film'
 
2
 
3
  const BASE = import.meta.env.BASE_URL
 
 
4
  const fmt = (t: number) => `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, '0')}`
5
  const col: Record<string, string> = { PASS: 'bg-[#e3ecd9] text-[#2f5d3a]', WARN: 'bg-[#f6e7c4] text-[#8a5a10]', FAIL: 'bg-[#f5d9d5] text-[#b8322a]', INFO: 'bg-[#e6e0d2] text-[#5a4f3c]' }
6
 
7
  export default function Making() {
8
  const chs = chapters()
9
+ const Q = FILM.qa
10
  return (
11
  <div className="flex flex-col gap-6">
12
  <div className="grid gap-4 md:grid-cols-3">
13
+ {FILM.making.map(([h, t]) => (
 
 
 
 
14
  <div key={h} className="rounded-md border border-[#d9cdb2] bg-[#fbf7ee] p-4">
15
  <div className="mb-1 font-brush text-xl">{h}</div>
16
  <p className="text-sm leading-relaxed text-[#4a3f30]">{t}</p>
 
57
  )}
58
  {Q.status !== 'pending' && (
59
  <div className="grid gap-3 md:grid-cols-2">
60
+ <figure><img src={`${BASE}${FILM.assets}qa/sheet_landscape.jpg`} alt="横屏接触表" className="w-full rounded border border-[#d9cdb2]" /><figcaption className="mt-1 text-xs text-[#8a7a5a]">横屏接触表(每 2 秒一格)</figcaption></figure>
61
+ <figure><img src={`${BASE}${FILM.assets}qa/sheet_portrait.jpg`} alt="竖屏接触表" className="w-full rounded border border-[#d9cdb2]" /><figcaption className="mt-1 text-xs text-[#8a7a5a]">竖屏接触表</figcaption></figure>
62
  </div>
63
  )}
64
  </div>
ugly-duckling/web/src/components/Player.tsx CHANGED
@@ -1,6 +1,6 @@
1
  import { useCallback, useEffect, useRef, useState } from 'react'
2
  import { Play, Pause, Maximize2, Subtitles, Smartphone, Monitor, RotateCcw, Volume2, VolumeX } from 'lucide-react'
3
- import { renderFrame, TOTAL, chapters } from '@/film/film'
4
  import { buildSoundtrack } from '@/film/audio'
5
  import { loadFonts } from '@/film/core'
6
 
@@ -31,7 +31,7 @@ export default function Player({ orientation, onOrientation }: { orientation: Or
31
 
32
  useEffect(() => {
33
  loadFonts(BASE).then(() => setFontsOk(true))
34
- buildSoundtrack(`${BASE}film/voice.mp3`, setStage).then((b) => { buf.current = b; setReady(true) }).catch((e) => setStage('音轨合成失败:' + (e?.message ?? e)))
35
  }, [])
36
 
37
  const now = useCallback(() => (playingRef.current && ac.current ? ac.current.currentTime - startAt.current : offset.current), [])
 
1
  import { useCallback, useEffect, useRef, useState } from 'react'
2
  import { Play, Pause, Maximize2, Subtitles, Smartphone, Monitor, RotateCcw, Volume2, VolumeX } from 'lucide-react'
3
+ import { renderFrame, TOTAL, chapters, FILM } from '@/film/film'
4
  import { buildSoundtrack } from '@/film/audio'
5
  import { loadFonts } from '@/film/core'
6
 
 
31
 
32
  useEffect(() => {
33
  loadFonts(BASE).then(() => setFontsOk(true))
34
+ buildSoundtrack(`${BASE}${FILM.assets}voice.mp3`, setStage).then((b) => { buf.current = b; setReady(true) }).catch((e) => setStage('音轨合成失败:' + (e?.message ?? e)))
35
  }, [])
36
 
37
  const now = useCallback(() => (playingRef.current && ac.current ? ac.current.currentTime - startAt.current : offset.current), [])
ugly-duckling/web/src/components/PublishCopy.tsx CHANGED
@@ -1,6 +1,7 @@
1
  import { useState } from 'react'
2
  import { Copy, Check } from 'lucide-react'
3
- import { PLATFORMS, PHOTO_POSTS, len } from '@/content'
 
4
 
5
  export function useCopy() {
6
  const [done, setDone] = useState<string | null>(null)
@@ -73,10 +74,10 @@ export function PostCard({ p, c, pre }: { p: Post; c: ReturnType<typeof useCopy>
73
 
74
  export default function PublishCopy() {
75
  const c = useCopy()
76
- return <div className="grid gap-4 md:grid-cols-2">{PLATFORMS.map((p) => <PostCard key={p.id} p={p} c={c} pre="v-" />)}</div>
77
  }
78
 
79
  export function PhotoCopy() {
80
  const c = useCopy()
81
- return <div className="grid gap-4 md:grid-cols-2">{PHOTO_POSTS.map((p) => <PostCard key={p.id} p={{ ...p, color: p.id === 'xhs' ? '#ff2442' : '#111111' }} c={c} pre="p-" />)}</div>
82
  }
 
1
  import { useState } from 'react'
2
  import { Copy, Check } from 'lucide-react'
3
+ import { FILM } from '@/film/film'
4
+ const len = (s: string) => [...s].length
5
 
6
  export function useCopy() {
7
  const [done, setDone] = useState<string | null>(null)
 
74
 
75
  export default function PublishCopy() {
76
  const c = useCopy()
77
+ return <div className="grid gap-4 md:grid-cols-2">{FILM.platforms.map((p) => <PostCard key={p.id} p={p} c={c} pre="v-" />)}</div>
78
  }
79
 
80
  export function PhotoCopy() {
81
  const c = useCopy()
82
+ return <div className="grid gap-4 md:grid-cols-2">{FILM.photoPosts.map((p) => <PostCard key={p.id} p={{ ...p, color: p.id === 'xhs' ? '#ff2442' : '#111111' }} c={c} pre="p-" />)}</div>
83
  }
ugly-duckling/web/src/film/audio.ts CHANGED
@@ -1,24 +1,40 @@
1
  // 声音:原创五声音阶配乐(古筝拨弦 + 竹笛 + 铺底 + 铃)+ 程序音效 + 旁白,离线混成一条立体声。
2
  // 完全确定:同一输入 → 同一音轨。网页播放、MP4 导出、质检用的是同一条音轨。
3
- import { TL, TOTAL } from './film'
4
  import { rng } from './core'
5
- import { CUE } from './cues'
6
 
7
- const SR = 44100
8
  type AC = OfflineAudioContext
9
- const midi = (m: number) => 440 * Math.pow(2, (m - 69) / 12)
10
  // 五声音阶(G 宫 / E 羽 共用)
11
- const N: Record<string, number> = { E2: 40, G2: 43, A2: 45, B2: 47, D3: 50, E3: 52, G3: 55, A3: 57, B3: 59, D4: 62, E4: 64, G4: 67, A4: 69, B4: 71, D5: 74, E5: 76, G5: 79, A5: 81, B5: 83, D6: 86, E6: 88 }
12
- const sceneStart = (id: string) => TL.scenes.find((s) => s.id === id)?.start ?? 0
13
- const sceneDur = (id: string) => TL.scenes.find((s) => s.id === id)?.dur ?? 0
14
 
15
- class Mixer {
16
  ctx: AC
17
  music: GainNode; sfx: GainNode; voice: GainNode; rev: ConvolverNode; revIn: GainNode; master: GainNode
18
  noise: AudioBuffer
19
  ksCache = new Map<number, AudioBuffer>()
20
  r = rng(4242)
21
  q: { t: number; fn: () => void }[] = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  constructor(ctx: AC) {
23
  this.ctx = ctx
24
  // 延迟建节点:只在需要前 ~1 秒才真正创建(离线渲染快很多)
@@ -254,206 +270,12 @@ class Mixer {
254
  lap(t0: number, t1: number, vel = 0.05) { for (let t = t0; t < t1; t += 0.6 + this.r() * 0.9) this.noiseHit(t, 0.5, 'bandpass', 700 + this.r() * 300, 300, vel, this.r() * 1.2 - 0.6, 1.5, 0.3) }
255
  }
256
 
257
- // ---------- 编排 ----------
258
- function score(m: Mixer) {
259
- const B = 60 / 72 // 每拍秒数
260
- const S = sceneStart, D = sceneDur
261
- const r = rng(808)
262
- const arp = (t0: number, t1: number, chords: number[][], step: number, vel: number, pattern = [0, 1, 2, 1]) => {
263
- let i = 0
264
- for (let t = t0; t < t1 - 0.05; t += step, i++) {
265
- const ch = chords[Math.floor((t - t0) / (step * 8)) % chords.length]
266
- m.pluck(t, ch[pattern[i % pattern.length] % ch.length], vel * (i % 4 === 0 ? 1 : 0.7), (i % 2 ? 0.25 : -0.25), { dur: 1.8 })
267
- }
268
- }
269
- const G = [N.G3, N.D4, N.B4], Em = [N.E3, N.B3, N.G4], Dm = [N.D3, N.A3, N.D4], Am = [N.A2, N.E3, N.A3], Gh = [N.G4, N.B4, N.D5]
270
- // 片头
271
- let t = S('title')
272
- m.gliss(t + 0.35, [N.G3, N.A3, N.B3, N.D4, N.E4, N.G4, N.A4, N.B4, N.D5], 0.07, 0.28)
273
- m.pad(t + 0.5, S('summer') + 1, [N.G2, N.D3], 0.05)
274
- m.melody(t + 2.2, B, [['D5', 1], ['E5', 0.5], ['G5', 0.5], ['E5', 1], ['D5', 1.5]], 0.17)
275
- // 夏
276
- t = S('summer')
277
- m.pad(t, S('hatch') + 0.5, [N.G2, N.D3, N.B3], 0.045)
278
- arp(t + 0.3, S('hatch'), [G, Em, G, Dm], B / 2, 0.2)
279
- m.melody(t + 1.2, B, [['D5', 1], ['E5', 0.5], ['G5', 0.5], ['E5', 1], ['D5', 1], ['B4', 1], ['D5', 0.5], ['B4', 0.5], ['A4', 2], ['G4', 1], ['A4', 0.5], ['B4', 0.5], ['D5', 1], ['E5', 0.5], ['D5', 0.5], ['B4', 1], ['A4', 0.5], ['G4', 0.5], ['G4', 2]], 0.13)
280
- // 破壳:俏皮的跳音 + 木鱼
281
- t = S('hatch')
282
- for (let i = 0; i < Math.floor(D('hatch') / B); i++) { if (i % 2 === 0) m.block(t + i * B, 0.07, 1100, -0.3); m.pluck(t + i * B + B / 2, [N.G4, N.B4, N.D5, N.E5][i % 4], 0.13, 0.3, { dur: 0.5, bright: 0.9 }) }
283
- m.pad(t, S('bigegg'), [N.G2, N.D3], 0.035)
284
- // 大蛋:悬念颤音 → 揭晓 → 「咦?」
285
- t = S('bigegg')
286
- const BE = CUE.bigegg
287
- m.pad(t, t + BE.open, [N.E2, N.B2], 0.05, 500)
288
- for (let x = t + 0.6; x < t + BE.open - 0.05; x += 0.09) m.pluck(x, N.E3, 0.06 + 0.1 * (x - t) / BE.open, 0, { dur: 0.3, send: 0.1 })
289
- m.pluck(t + BE.surprise, N.D4, 0.3, 0, { bend: -2 }); m.pluck(t + BE.surprise + 0.25, N.A3, 0.3, 0, { bend: -2 })
290
- m.pluck(t + BE.question + 0.05, N.B4, 0.22, 0.2, { bend: 2 })
291
- arp(t + BE.walk, S('bullied'), [Em, G], B / 2, 0.12)
292
- // 被嘲笑:E 羽调式,慢
293
- t = S('bullied')
294
- m.pad(t, S('leave') + 0.5, [N.E2, N.B2, N.G3], 0.05, 700)
295
- for (let i = 0; i < 12; i++) m.pluck(t + 0.3 + i * B, [N.E4, N.D4, N.B3, N.A3, N.G3, N.A3, N.B3, N.E3][i % 8], 0.16, -0.2, { dur: 2, bend: i % 4 === 3 ? -1 : 0 })
296
- // 离家:竹笛哀歌
297
- t = S('leave')
298
- m.pad(t, S('marsh') + 0.3, [N.E2, N.B2], 0.045, 600)
299
- m.melody(t + 0.4, B * 1.1, [['E5', 1.5], ['D5', 0.5], ['B4', 1], ['A4', 1], ['G4', 1], ['A4', 0.5], ['B4', 0.5], ['E4', 2]], 0.13)
300
- // 沼泽:低音颤奏 → 枪声后寂静 → 心跳
301
- t = S('marsh')
302
- for (let x = t + 0.2; x < t + CUE.marsh.shots[0] - 0.02; x += 0.125) m.pluck(x, (Math.floor((x - t) / 0.5) % 2) ? N.B2 : N.E2, 0.17, 0, { dur: 0.25, send: 0.05 })
303
- m.pad(t + CUE.marsh.hide, S('autumn') + 0.5, [N.E2], 0.04, 400)
304
- for (let x = t + CUE.marsh.hide + 0.6; x < t + 13; x += 0.95) m.heart(x)
305
- // 秋:向往(E 羽 → G 宫的过渡)
306
- t = S('autumn')
307
- m.pad(t, S('winter') + 0.5, [N.E2, N.B2, N.G3], 0.04, 800)
308
- arp(t + 0.3, S('winter'), [Em, Am, G, Em], B * 0.75, 0.13)
309
- m.bell(t + CUE.autumn.swans + 0.6, N.B5, 0.07, 0.4); m.bell(t + CUE.autumn.swans + 3, N.E6, 0.06, -0.3)
310
- m.melody(t + CUE.autumn.swans + 1.4, B, [['B4', 1.5], ['D5', 0.5], ['E5', 1], ['G5', 1], ['E5', 2], ['D5', 1], ['B4', 1], ['A4', 2]], 0.11, 0)
311
- // 冬:几乎只剩风与孤铃
312
- t = S('winter')
313
- m.pad(t, S('spring') + 0.5, [N.E2, N.B2], 0.035, 350)
314
- for (let i = 0; i < 6; i++) m.bell(t + 1 + i * 2.1, [N.E5, N.B4, N.D5, N.A4, N.B4, N.E5][i], 0.05, (i % 2 ? 0.4 : -0.4))
315
- // 春:回到 G 宫,逐渐加密
316
- t = S('spring')
317
- m.bell(t + 0.15, N.G5, 0.1, 0); m.bell(t + 0.4, N.D6, 0.07, 0.3)
318
- m.pad(t, S('reflection') + 0.5, [N.G2, N.D3, N.B3], 0.045)
319
- const SP = CUE.spring
320
- arp(t + 0.5, t + SP.flapHard, [G, Em], B / 2, 0.15)
321
- arp(t + SP.flapHard, S('reflection'), [G, Dm, Em, G], B / 4, 0.14, [0, 1, 2, 1, 2, 1])
322
- m.gliss(t + SP.lift - 0.05, [N.G3, N.A3, N.B3, N.D4, N.E4, N.G4, N.A4, N.B4, N.D5, N.E5, N.G5], 0.05, 0.25)
323
- m.melody(t + SP.lift + 0.4, B, [['D5', 1], ['E5', 0.5], ['G5', 0.5], ['A5', 1], ['G5', 1], ['E5', 1], ['G5', 0.5], ['E5', 0.5], ['D5', 2]], 0.13)
324
- // 倒影:揭晓
325
- t = S('reflection')
326
- const RF = CUE.reflection
327
- m.pad(t, t + RF.reveal, [N.E2, N.B2, N.E3], 0.04, 600)
328
- for (let i = 0; i < 10; i++) m.pluck(t + 0.8 + i * B * 1.05, [N.B3, N.D4, N.E4, N.D4, N.B3, N.A3, N.B3, N.D4, N.E4, N.G4][i], 0.12, 0, { dur: 2 })
329
- m.bell(t + RF.mirror, N.E5, 0.06, 0); m.bell(t + RF.mirror + 0.3, N.B5, 0.04, 0.3)
330
- m.gliss(t + RF.reveal - 0.05, [N.G3, N.B3, N.D4, N.G4, N.B4, N.D5, N.G5, N.B5, N.D6], 0.06, 0.3)
331
- ;[N.G5, N.B5, N.D6, N.G5].forEach((x, i) => m.bell(t + RF.reveal + i * 0.18, x, 0.08, i % 2 ? 0.3 : -0.3))
332
- m.pad(t + RF.reveal, S('accepted') + 0.5, [N.G2, N.D3, N.B3, N.D4], 0.05, 1200)
333
- m.melody(t + RF.reveal + 1.2, B, [['G5', 1.5], ['E5', 0.5], ['D5', 1], ['B4', 1], ['D5', 2]], 0.14)
334
- // 接纳:温暖
335
- t = S('accepted')
336
- arp(t + 0.2, S('fly'), [G, Em, Dm, G], B / 2, 0.15)
337
- m.pad(t, S('fly') + 0.3, [N.G2, N.D3], 0.04)
338
- m.melody(t + 0.8, B, [['G4', 1], ['A4', 0.5], ['B4', 0.5], ['D5', 1], ['E5', 0.5], ['D5', 0.5], ['B4', 1], ['A4', 0.5], ['G4', 0.5], ['G4', 2]], 0.12)
339
- // 高飞:高潮
340
- t = S('fly')
341
- m.pad(t, S('ending') + 1, [N.G2, N.D3, N.G3, N.B3], 0.06, 1600)
342
- arp(t, S('ending'), [Gh, G, Em, Gh], B / 4, 0.16, [0, 1, 2, 1, 2, 1, 0, 2])
343
- for (let i = 0; i < Math.floor(D('fly') / B); i++) m.block(t + i * B, 0.08, 800, 0)
344
- m.melody(t + 0.2, B, [['D5', 1], ['E5', 0.5], ['G5', 0.5], ['A5', 1], ['G5', 1], ['E5', 1], ['G5', 0.5], ['A5', 0.5], ['B5', 2]], 0.15, 0)
345
- // 尾声
346
- t = S('ending')
347
- m.pad(t, TOTAL, [N.G2, N.D3, N.B3], 0.045)
348
- const EN = CUE.ending.end
349
- arp(t + 0.2, t + EN - 0.2, [G, Em, G, Dm], B * 0.75, 0.12)
350
- m.gliss(t + EN, [N.G3, N.B3, N.D4, N.G4, N.B4, N.D5, N.G5], 0.08, 0.22)
351
- m.bell(t + EN + 0.1, N.G5, 0.08); m.bell(t + EN + 0.35, N.D6, 0.05, 0.3)
352
- m.pluck(t + EN + 0.6, N.G3, 0.2, 0, { dur: 2 }); m.pluck(t + EN + 0.6, N.D4, 0.15, 0, { dur: 2 }); m.pluck(t + EN + 0.6, N.G4, 0.12, 0, { dur: 2 })
353
- void r
354
- }
355
-
356
- function effects(m: Mixer) {
357
- const S = sceneStart, D = sceneDur
358
- const r = m.r
359
- let t = S('title')
360
- const TT = CUE.title
361
- m.drop(t + TT.drop); m.swish(t + TT.write); m.swish(t + TT.write + 0.45); m.swish(t + TT.write + 0.9); m.thump(t + TT.seal + 0.01, 0.45)
362
- for (let i = 0; i < 6; i++) m.peep(t + 2.6 + i * 0.65, 0.04, 0.3)
363
- // 夏:鸟鸣、微风
364
- t = S('summer')
365
- m.wind(t, D('summer'), 0.025)
366
- for (let x = t + 0.6; x < t + D('summer'); x += 1.1 + r() * 1.6) m.chirp(x, 0.035, r() * 1.4 - 0.7)
367
- // 破壳
368
- t = S('hatch')
369
- for (let k = 0; k < 4; k++) {
370
- const tc = t + 0.4 + k * 0.75
371
- m.crack(tc + 0.05, 0.22, -0.3 + k * 0.2); m.crack(tc + 0.25, 0.18, -0.3 + k * 0.2)
372
- m.noiseHit(tc + 0.55, 0.08, 'bandpass', 1800, 900, 0.18, -0.3 + k * 0.2, 2, 0.1)
373
- m.peep(tc + 0.75, 0.1, -0.3 + k * 0.2)
374
- for (let p = 1; p < 5; p++) { const pt = tc + 1 + 2.3 * (p - 1); if (pt - t < D('hatch')) m.peep(pt + 0.05, 0.07, -0.3 + k * 0.2) }
375
- m.noiseHit(tc + 3.0, 0.05, 'lowpass', 600, 200, 0.12, -0.3 + k * 0.2, 0.7, 0.05)
376
- }
377
- // 大蛋
378
- t = S('bigegg')
379
- const BE = CUE.bigegg
380
- for (let x = t + 0.5; x < t + BE.crackFrom; x += Math.max(0.12, 0.5 - (x - t) * 0.12)) m.block(x, 0.05 + (x - t) * 0.02, 420, 0)
381
- m.crack(t + BE.crackFrom + 0.05, 0.2); m.crack(t + BE.crackFrom + 0.4, 0.22)
382
- m.crack(t + BE.open, 0.35, 0, true)
383
- m.noiseHit(t + BE.surprise, 0.25, 'bandpass', 2400, 1200, 0.06, -0.4, 1.2, 0.2)
384
- m.peep(t + BE.surprise + 0.1, 0.08, -0.4); m.peep(t + BE.surprise + 0.2, 0.08, -0.5)
385
- for (let x = t + BE.walk + 0.1; x < t + BE.walk + 2.6; x += 0.6) m.noiseHit(x, 0.05, 'lowpass', 600, 200, 0.1, 0, 0.7, 0.05)
386
- // 被嘲笑
387
- t = S('bullied')
388
- const BU = CUE.bullied
389
- for (let x = t + BU.laugh1[0] + 0.1; x < t + BU.laugh1[1] - 0.2; x += 0.35 + r() * 0.3) m.quack(x, 0.09, -0.5 + r() * 0.3, 1.25 + r() * 0.2)
390
- for (let x = t + BU.laugh2[0] + 0.1; x < t + BU.laugh2[1] - 0.2; x += 0.4 + r() * 0.35) m.quack(x, 0.07, -0.5 + r() * 0.3, 1.25 + r() * 0.2)
391
- // 公鸡登场:扑翅 + 啄
392
- m.flap(t + BU.roosterIn, 6, 9, 0.12, 0.5)
393
- m.tone(t + BU.roosterIn + 0.2, 600, 900, 0.25, 0.08, 'sawtooth', 0.5, 0.3); m.tone(t + BU.roosterIn + 0.45, 900, 500, 0.5, 0.07, 'sawtooth', 0.5, 0.4)
394
- for (const p of BU.pecks) { m.block(t + p, 0.25, 1500, 0.2); m.flap(t + p - 0.15, 2, 10, 0.08, 0.3) }
395
- m.swish(t + BU.shoo, 0.07)
396
- // 离家
397
- t = S('leave')
398
- m.wind(t, D('leave') + 1, 0.07, -0.2)
399
- for (let x = t + CUE.leave.walk + 0.2; x < t + D('leave'); x += 0.62) m.noiseHit(x, 0.04, 'lowpass', 500, 200, 0.05, 0, 0.7, 0.05)
400
- // 沼泽
401
- t = S('marsh')
402
- m.lap(t, t + D('marsh'), 0.035)
403
- const MA = CUE.marsh
404
- for (let x = t + MA.laugh[0] + 0.1; x < t + MA.laugh[1] - 0.2; x += 0.3 + r() * 0.3) m.quack(x, 0.1, 0.4 + r() * 0.3, 0.95 + r() * 0.15)
405
- m.shot(t + MA.shots[0], 0.6); m.shot(t + MA.shots[1], 0.45)
406
- m.flap(t + MA.scatter, 14, 12, 0.13, 0.4)
407
- for (let i = 0; i < 6; i++) m.quack(t + MA.scatter + 0.05 + i * 0.12, 0.08, 0.5, 1.1)
408
- m.splash(t + MA.hide + 0.2, 0.12)
409
- // 秋
410
- t = S('autumn')
411
- m.wind(t, D('autumn'), 0.03, 0.2)
412
- const AU = CUE.autumn.swans
413
- m.swanCall(t + AU + 0.8, 0.06, 0.5); m.swanCall(t + AU + 3.0, 0.07, 0.1); m.swanCall(t + AU + 5.6, 0.05, -0.5)
414
- m.flap(t + AU + 1.5, 12, 1.4, 0.04, 0.2)
415
- // 冬
416
- t = S('winter')
417
- m.wind(t, D('winter') + 0.8, 0.12, 0)
418
- m.wind(t + 2, D('winter') - 2, 0.06, 0.6)
419
- m.crack(t + CUE.winter.ice, 0.2, 0, true); m.noiseHit(t + CUE.winter.ice + 0.05, 0.9, 'bandpass', 300, 120, 0.12, 0, 4, 0.5)
420
- // 春
421
- t = S('spring')
422
- for (let x = t + 0.4; x < t + D('spring'); x += 0.7 + r() * 1.1) m.chirp(x, 0.04, r() * 1.4 - 0.7)
423
- const SP = CUE.spring
424
- m.flap(t + SP.spread, 2, 2, 0.1, 0)
425
- m.flap(t + SP.spread + 0.9, Math.floor((SP.flapHard - SP.spread - 0.9) * 0.83), 0.83, 0.08, 0)
426
- m.flap(t + SP.flapHard, Math.floor((SP.lift - SP.flapHard) * 1.6), 1.6, 0.13, 0); m.flap(t + SP.lift, 6, 1.46, 0.1, 0.2)
427
- m.noiseHit(t + SP.lift - 0.1, 1.2, 'bandpass', 400, 2400, 0.12, 0, 1, 0.4)
428
- m.pluck(t + SP.question, N.B4, 0.2, 0, { bend: 2 })
429
- // 倒影
430
- t = S('reflection')
431
- const RF = CUE.reflection
432
- m.flap(t, 5, 7, 0.1, -0.2)
433
- m.splash(t + RF.land, 0.3)
434
- m.lap(t + 1.2, t + D('reflection'), 0.03)
435
- m.bell(t + RF.reveal, 91, 0.06, 0, m.sfx)
436
- // 接纳
437
- t = S('accepted')
438
- m.lap(t, t + D('accepted'), 0.03)
439
- const CH = CUE.accepted.cheer
440
- for (let x = t + CH + 0.1; x < t + CH + 4.5; x += 0.11 + r() * 0.08) m.clap(x, 0.06 * (1 - (x - t - CH) / 5), -0.55 + r() * 0.2)
441
- for (let i = 0; i < 4; i++) m.chirp(t + CH + 0.4 + i * 1.3, 0.03, 0.4)
442
- // 高飞
443
- t = S('fly')
444
- m.wind(t, D('fly') + 1, 0.07, 0)
445
- m.flap(t + 0.3, Math.floor(D('fly') * 3.2 / 2 / Math.PI * 2), 3.2 / Math.PI, 0.09, 0)
446
- m.noiseHit(t + 0.9, 1.6, 'bandpass', 300, 2000, 0.1, 0, 1, 0.5)
447
- // 尾声
448
- t = S('ending')
449
- m.lap(t, t + D('ending'), 0.025)
450
- m.thump(t + CUE.ending.end + 0.12, 0.4)
451
- }
452
-
453
- let cached: Promise<AudioBuffer> | null = null
454
  export function buildSoundtrack(voiceUrl: string, onStage?: (s: string) => void): Promise<AudioBuffer> {
455
- if (cached) return cached
456
- cached = (async () => {
 
 
457
  const len = Math.ceil(SR * (TOTAL + 0.3))
458
  const ctx = new OfflineAudioContext(2, len, SR)
459
  onStage?.('加载旁白…')
@@ -464,17 +286,19 @@ export function buildSoundtrack(voiceUrl: string, onStage?: (s: string) => void)
464
  } catch (e) { console.warn('voice load failed', e) }
465
  onStage?.('合成配乐与音效…')
466
  const m = new Mixer(ctx)
 
467
  // 人声压低 BGM(sidechain 式):按时间轴里的配音区间自动化
468
  const mg = m.music.gain
469
  mg.setValueAtTime(0.9, 0)
470
  for (const s of TL.scenes) {
471
- if (!s.voice_start || !s.voice_dur) continue
472
- const a = s.voice_start, b = s.voice_start + s.voice_dur
473
- mg.setTargetAtTime(0.42, Math.max(0, a - 0.25), 0.08)
474
- mg.setTargetAtTime(0.9, b + 0.1, 0.25)
 
475
  }
476
- score(m)
477
- effects(m)
478
  m.flushPlucks()
479
  m.q.sort((a, b) => a.t - b.t)
480
  let qi = 0
@@ -497,7 +321,8 @@ export function buildSoundtrack(voiceUrl: string, onStage?: (s: string) => void)
497
  onStage?.('完成')
498
  return out
499
  })()
500
- cached.catch(() => { cached = null })
 
501
  return cached
502
  }
503
 
 
1
  // 声音:原创五声音阶配乐(古筝拨弦 + 竹笛 + 铺底 + 铃)+ 程序音效 + 旁白,离线混成一条立体声。
2
  // 完全确定:同一输入 → 同一音轨。网页播放、MP4 导出、质检用的是同一条音轨。
3
+ import { TL, TOTAL, FILM } from './film'
4
  import { rng } from './core'
 
5
 
6
+ export const SR = 44100
7
  type AC = OfflineAudioContext
8
+ export const midi = (m: number) => 440 * Math.pow(2, (m - 69) / 12)
9
  // 五声音阶(G 宫 / E 羽 共用)
10
+ export const N: Record<string, number> = { E2: 40, G2: 43, A2: 45, B2: 47, D3: 50, E3: 52, G3: 55, A3: 57, B3: 59, D4: 62, E4: 64, G4: 67, A4: 69, B4: 71, D5: 74, E5: 76, G5: 79, A5: 81, B5: 83, D6: 86, E6: 88 }
11
+ export const sceneStart = (id: string) => TL.scenes.find((s) => s.id === id)?.start ?? 0
12
+ export const sceneDur = (id: string) => TL.scenes.find((s) => s.id === id)?.dur ?? 0
13
 
14
+ export class Mixer {
15
  ctx: AC
16
  music: GainNode; sfx: GainNode; voice: GainNode; rev: ConvolverNode; revIn: GainNode; master: GainNode
17
  noise: AudioBuffer
18
  ksCache = new Map<number, AudioBuffer>()
19
  r = rng(4242)
20
  q: { t: number; fn: () => void }[] = []
21
+ /** 人声轨(供「山谷回声」等效果使用;在 score/effects 之前由 buildSoundtrack 设置) */
22
+ voiceBuf: AudioBuffer | null = null
23
+ /** 在 t 时刻真正创建节点(离线渲染按需建节点) */
24
+ at(t: number, fn: () => void) { this.q.push({ t, fn }) }
25
+ /** 把人声 [t0, t0+dur) 这一段当回声再放几遍:越来越远、越来越暗 */
26
+ echo(t0: number, dur: number, delay = 0.42, gain = 0.3, taps = 3) {
27
+ this.at(t0 + delay, () => {
28
+ if (!this.voiceBuf) return
29
+ for (let k = 1; k <= taps; k++) {
30
+ const s = this.ctx.createBufferSource(); s.buffer = this.voiceBuf
31
+ const f = this.ctx.createBiquadFilter(); f.type = 'lowpass'; f.frequency.value = 2800 / k
32
+ const g = this.ctx.createGain(); g.gain.value = gain * Math.pow(0.5, k - 1)
33
+ s.connect(f); f.connect(g); this.out(g, this.sfx, k % 2 ? -0.45 : 0.45, 0.6)
34
+ s.start(t0 + delay * k + 1 / 30, t0, dur)
35
+ }
36
+ })
37
+ }
38
  constructor(ctx: AC) {
39
  this.ctx = ctx
40
  // 延迟建节点:只在需要前 ~1 秒才真正创建(离线渲染快很多)
 
270
  lap(t0: number, t1: number, vel = 0.05) { for (let t = t0; t < t1; t += 0.6 + this.r() * 0.9) this.noiseHit(t, 0.5, 'bandpass', 700 + this.r() * 300, 300, vel, this.r() * 1.2 - 0.6, 1.5, 0.3) }
271
  }
272
 
273
+ const cache = new Map<string, Promise<AudioBuffer>>()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
  export function buildSoundtrack(voiceUrl: string, onStage?: (s: string) => void): Promise<AudioBuffer> {
275
+ const film = FILM
276
+ const hit = cache.get(film.id)
277
+ if (hit) return hit
278
+ const cached = (async () => {
279
  const len = Math.ceil(SR * (TOTAL + 0.3))
280
  const ctx = new OfflineAudioContext(2, len, SR)
281
  onStage?.('加载旁白…')
 
286
  } catch (e) { console.warn('voice load failed', e) }
287
  onStage?.('合成配乐与音效…')
288
  const m = new Mixer(ctx)
289
+ m.voiceBuf = voiceBuf
290
  // 人声压低 BGM(sidechain 式):按时间轴里的配音区间自动化
291
  const mg = m.music.gain
292
  mg.setValueAtTime(0.9, 0)
293
  for (const s of TL.scenes) {
294
+ const spans: [number, number][] = s.lines?.length ? s.lines.map((l) => [l.t0, l.t0 + l.dur]) : s.voice_start && s.voice_dur ? [[s.voice_start, s.voice_start + s.voice_dur]] : []
295
+ for (const [a, b] of spans) {
296
+ mg.setTargetAtTime(0.42, Math.max(0, a - 0.25), 0.08)
297
+ mg.setTargetAtTime(0.9, b + 0.1, 0.25)
298
+ }
299
  }
300
+ film.score(m)
301
+ film.effects(m)
302
  m.flushPlucks()
303
  m.q.sort((a, b) => a.t - b.t)
304
  let qi = 0
 
321
  onStage?.('完成')
322
  return out
323
  })()
324
+ cache.set(film.id, cached)
325
+ cached.catch(() => cache.delete(film.id))
326
  return cached
327
  }
328
 
ugly-duckling/web/src/film/film.ts CHANGED
@@ -1,12 +1,17 @@
1
  // 合成器:时间轴 → 场景 → 晕染转场 → 字幕。每一帧只由 T 决定。
2
- import timeline from './timeline.json'
3
  import { type Ctx, paper, makeCanvas, ctx2d, blobLoop, spline, tracePath, rgba, INK, PAPER, FONT_KAI, easeInOut, clamp, inv, smooth } from './core'
4
- import { SCENES, type SceneCtx } from './scenes'
5
 
6
- export type Sub = { t0: number; t1: number; text: string }
7
- export type TLScene = { id: string; start: number; dur: number; voice_start?: number; voice_dur?: number; subs?: Sub[]; sfx?: { at: number; type: string }[] }
8
- export const TL = timeline as unknown as { total: number; fps: number; scenes: TLScene[] }
9
- export const TOTAL = TL.total
 
 
 
 
 
 
10
 
11
  export type RenderOpts = { subs?: boolean; fadeEnd?: boolean }
12
 
@@ -97,15 +102,19 @@ export function renderFrame(g: Ctx, W: number, H: number, T: number, opts: Rende
97
  g.restore()
98
  }
99
 
100
- export function currentSub(T: number): { text: string; a: number } | null {
101
  const i = sceneIndexAt(T)
102
  const subs = TL.scenes[i].subs ?? []
103
  for (let k = 0; k < subs.length; k++) {
104
  const s = subs[k]
105
  const end = k + 1 < subs.length ? subs[k + 1].t0 : s.t1 + 0.35
106
  if (T >= s.t0 - 0.05 && T < end) {
107
- const a = Math.min(smooth((T - s.t0 + 0.05) / 0.18), smooth((end - T) / 0.15))
108
- return { text: s.text, a }
 
 
 
 
109
  }
110
  }
111
  return null
@@ -114,6 +123,7 @@ export function currentSub(T: number): { text: string; a: number } | null {
114
  export function drawSubs(g: Ctx, W: number, H: number, T: number) {
115
  const s = currentSub(T)
116
  if (!s || s.a <= 0) return
 
117
  const portrait = H > W * 1.05
118
  const u = Math.min(W, H) / 1080
119
  const fs = (portrait ? 58 : 50) * u
@@ -124,7 +134,7 @@ export function drawSubs(g: Ctx, W: number, H: number, T: number) {
124
  g.textAlign = 'center'; g.textBaseline = 'middle'
125
  const w = g.measureText(s.text).width
126
  // 宣纸色晕底(不是黑底字幕条):横向拉长的柔边纸带,压住身后的芦苇、草叶等纹理
127
- const rx = w / 2 + fs * 2.2, ry = fs * 0.95
128
  g.save(); g.translate(W / 2, y); g.scale(rx / ry, 1)
129
  const gr = g.createRadialGradient(0, 0, 0, 0, 0, ry)
130
  gr.addColorStop(0, rgba(PAPER, 0.95)); gr.addColorStop(0.72, rgba(PAPER, 0.9)); gr.addColorStop(1, rgba(PAPER, 0))
@@ -133,7 +143,7 @@ export function drawSubs(g: Ctx, W: number, H: number, T: number) {
133
  g.lineJoin = 'round'
134
  g.strokeStyle = rgba(PAPER, 0.96); g.lineWidth = fs * 0.2
135
  g.strokeText(s.text, W / 2, y)
136
- g.fillStyle = rgba(INK, 0.97)
137
  g.fillText(s.text, W / 2, y)
138
  g.restore()
139
  }
 
1
  // 合成器:时间轴 → 场景 → 晕染转场 → 字幕。每一帧只由 T 决定。
 
2
  import { type Ctx, paper, makeCanvas, ctx2d, blobLoop, spline, tracePath, rgba, INK, PAPER, FONT_KAI, easeInOut, clamp, inv, smooth } from './core'
3
+ import type { FilmDef, SceneCtx, SceneDef, Timeline } from './types'
4
 
5
+ // 当前片子(ES 模块导出是活绑定:setFilm 之后各处读到的 TL / TOTAL 自动更新)
6
+ export let FILM: FilmDef = null as unknown as FilmDef
7
+ export let TL: Timeline = { total: 1, fps: 30, scenes: [] }
8
+ export let TOTAL = 1
9
+ let SCENES: Record<string, SceneDef> = {}
10
+ export function setFilm(f: FilmDef) {
11
+ if (FILM === f) return
12
+ FILM = f; TL = f.timeline; TOTAL = f.timeline.total; SCENES = f.scenes
13
+ bgCache.clear()
14
+ }
15
 
16
  export type RenderOpts = { subs?: boolean; fadeEnd?: boolean }
17
 
 
102
  g.restore()
103
  }
104
 
105
+ export function currentSub(T: number): { text: string; a: number; who?: string } | null {
106
  const i = sceneIndexAt(T)
107
  const subs = TL.scenes[i].subs ?? []
108
  for (let k = 0; k < subs.length; k++) {
109
  const s = subs[k]
110
  const end = k + 1 < subs.length ? subs[k + 1].t0 : s.t1 + 0.35
111
  if (T >= s.t0 - 0.05 && T < end) {
112
+ // 同一句里紧挨着的字幕直接切换(不做淡出淡入,避免每 1–2 秒闪一下);只在真正的停顿处淡入淡出
113
+ const joinedPrev = k > 0 && s.t0 - subs[k - 1].t1 < 0.12
114
+ const joinedNext = k + 1 < subs.length && subs[k + 1].t0 - s.t1 < 0.12
115
+ const fin = joinedPrev ? 1 : smooth((T - s.t0 + 0.05) / 0.18)
116
+ const fout = joinedNext ? 1 : smooth((end - T) / 0.15)
117
+ return { text: s.text, a: Math.min(fin, fout), who: s.who }
118
  }
119
  }
120
  return null
 
123
  export function drawSubs(g: Ctx, W: number, H: number, T: number) {
124
  const s = currentSub(T)
125
  if (!s || s.a <= 0) return
126
+ if (SCENES[TL.scenes[sceneIndexAt(T)].id]?.noSubs) return
127
  const portrait = H > W * 1.05
128
  const u = Math.min(W, H) / 1080
129
  const fs = (portrait ? 58 : 50) * u
 
134
  g.textAlign = 'center'; g.textBaseline = 'middle'
135
  const w = g.measureText(s.text).width
136
  // 宣纸色晕底(不是黑底字幕条):横向拉长的柔边纸带,压住身后的芦苇、草叶等纹理
137
+ const rx = w / 2 + fs * 2.2, ry = fs * 1.35
138
  g.save(); g.translate(W / 2, y); g.scale(rx / ry, 1)
139
  const gr = g.createRadialGradient(0, 0, 0, 0, 0, ry)
140
  gr.addColorStop(0, rgba(PAPER, 0.95)); gr.addColorStop(0.72, rgba(PAPER, 0.9)); gr.addColorStop(1, rgba(PAPER, 0))
 
143
  g.lineJoin = 'round'
144
  g.strokeStyle = rgba(PAPER, 0.96); g.lineWidth = fs * 0.2
145
  g.strokeText(s.text, W / 2, y)
146
+ g.fillStyle = rgba((s.who && FILM.subColors?.[s.who]) || INK, 0.97)
147
  g.fillText(s.text, W / 2, y)
148
  g.restore()
149
  }
ugly-duckling/web/src/film/overlays.ts CHANGED
@@ -1,17 +1,11 @@
1
  // 封面与实况图:用同一引擎渲染关键帧,再叠加题字、印章、说明。
2
  import { type Ctx, INK, PAPER, rgba, blot, seal, FONT_BRUSH, FONT_KAI, brush } from './core'
3
- import { renderFrame, TL } from './film'
4
- import live from './live.json'
 
5
 
6
  const at = (id: string, dt: number) => (TL.scenes.find((s) => s.id === id)?.start ?? 0) + dt
7
 
8
- export type CoverDesign = { id: string; name: string; t: number; title: string; sub: string; side: 'left' | 'right'; vertical: boolean }
9
- export const COVER_DESIGNS: CoverDesign[] = [
10
- { id: 'reflect', name: '倒影揭晓', t: at('reflection', 7.6), title: '丑小鸭', sub: '原来,他是一只白天鹅', side: 'left', vertical: true },
11
- { id: 'hatch', name: '破壳而出', t: at('bigegg', 4.4), title: '丑小鸭', sub: '安徒生童话 · 水墨手绘动画', side: 'right', vertical: true },
12
- { id: 'fly', name: '展翅高飞', t: at('fly', 4.2), title: '相信自己', sub: '你也会展开属于自己的翅膀', side: 'right', vertical: true },
13
- { id: 'autumn', name: '秋日仰望', t: at('autumn', 6.6), title: '丑小鸭', sub: '像语文课本里的插图', side: 'left', vertical: true },
14
- ]
15
  export const COVER_RATIOS = [
16
  { id: '4:3', w: 1440, h: 1080 },
17
  { id: '3:4', w: 1080, h: 1440 },
@@ -50,7 +44,7 @@ function titleBlock(g: Ctx, W: number, H: number, title: string, sub: string, si
50
  g.restore()
51
  // 印章
52
  const sy = y0 + colH - fs * 0.15
53
- seal(g, x + (side === 'left' ? -fs * 0.05 : fs * 0.05), sy + fs * 0.1, fs * 0.42, o.seal ?? ['童话'])
54
  if (o.index) {
55
  g.save(); g.font = `${sfs * 1.1}px ${FONT_BRUSH}`; g.fillStyle = rgba('#b8322a', 0.9); g.textAlign = 'center'
56
  g.fillText(o.index, sx, sy0 - sfs * 1.6); g.restore()
@@ -65,12 +59,10 @@ export function drawCover(g: Ctx, W: number, H: number, d: CoverDesign) {
65
  brush(g, [[W * 0.3, H - 40 * u], [W * 0.5, H - 34 * u], [W * 0.7, H - 42 * u]], 5 * u, { alpha: 0.25, dry: 0.7, seed: 3 })
66
  }
67
 
68
- export type LiveDef = { id: string; scene: string; from: number; dur: number; cover: number; caption: string; index: string; xhs: string }
69
- export const LIVE: LiveDef[] = live as LiveDef[]
70
  export const liveStart = (l: LiveDef) => at(l.scene, l.from)
71
 
72
  /** 实况图每一帧:电影画面 + 固定的题字 */
73
  export function drawLive(g: Ctx, W: number, H: number, l: LiveDef, lt: number) {
74
  renderFrame(g, W, H, liveStart(l) + lt, { subs: false, fadeEnd: false })
75
- titleBlock(g, W, H, l.caption, '安徒生童话 · 丑小鸭', 'right', { big: 120, index: l.index })
76
  }
 
1
  // 封面与实况图:用同一引擎渲染关键帧,再叠加题字、印章、说明。
2
  import { type Ctx, INK, PAPER, rgba, blot, seal, FONT_BRUSH, FONT_KAI, brush } from './core'
3
+ import { renderFrame, FILM, TL } from './film'
4
+ import type { CoverDesign, LiveDef } from './types'
5
+ export type { CoverDesign, LiveDef }
6
 
7
  const at = (id: string, dt: number) => (TL.scenes.find((s) => s.id === id)?.start ?? 0) + dt
8
 
 
 
 
 
 
 
 
9
  export const COVER_RATIOS = [
10
  { id: '4:3', w: 1440, h: 1080 },
11
  { id: '3:4', w: 1080, h: 1440 },
 
44
  g.restore()
45
  // 印章
46
  const sy = y0 + colH - fs * 0.15
47
+ seal(g, x + (side === 'left' ? -fs * 0.05 : fs * 0.05), sy + fs * 0.1, fs * 0.42, o.seal ?? FILM.seal)
48
  if (o.index) {
49
  g.save(); g.font = `${sfs * 1.1}px ${FONT_BRUSH}`; g.fillStyle = rgba('#b8322a', 0.9); g.textAlign = 'center'
50
  g.fillText(o.index, sx, sy0 - sfs * 1.6); g.restore()
 
59
  brush(g, [[W * 0.3, H - 40 * u], [W * 0.5, H - 34 * u], [W * 0.7, H - 42 * u]], 5 * u, { alpha: 0.25, dry: 0.7, seed: 3 })
60
  }
61
 
 
 
62
  export const liveStart = (l: LiveDef) => at(l.scene, l.from)
63
 
64
  /** 实况图每一帧:电影画面 + 固定的题字 */
65
  export function drawLive(g: Ctx, W: number, H: number, l: LiveDef, lt: number) {
66
  renderFrame(g, W, H, liveStart(l) + lt, { subs: false, fadeEnd: false })
67
+ titleBlock(g, W, H, l.caption, FILM.series, 'right', { big: 120, index: l.index })
68
  }
ugly-duckling/web/src/film/standalone.ts CHANGED
@@ -1,9 +1,11 @@
1
  // 无头渲染入口(vibe render / 接触表 / 实况图):window.renderAt(t) 把画面冻结在 t 秒。
2
- import { renderFrame, TOTAL } from './film'
 
3
  import { loadFonts } from './core'
4
 
5
  declare global { interface Window { renderAt: (t: number) => void; __ready: boolean; __total: number; __opts: { subs: boolean } } }
6
  const q = new URLSearchParams(location.search)
 
7
  const W = Number(q.get('w')) || innerWidth, H = Number(q.get('h')) || innerHeight
8
  const c = document.createElement('canvas')
9
  c.width = W; c.height = H
@@ -20,7 +22,7 @@ loadFonts(q.get('base') || './').then(() => { window.renderAt(0); window.__ready
20
  import { buildSoundtrack, toWav } from './audio'
21
  declare global { interface Window { renderAudio: () => Promise<string> } }
22
  window.renderAudio = async () => {
23
- const t0 = performance.now(); const b = await buildSoundtrack((q.get('base') || './') + 'film/voice.mp3', (s) => console.log('stage', s, Math.round(performance.now() - t0)))
24
  const bytes = new Uint8Array(toWav(b))
25
  let s = ''
26
  for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
@@ -30,12 +32,13 @@ window.renderAudio = async () => {
30
  // 调试:在页面里直接调用角色绘制函数
31
  import * as actors from './actors'
32
  import * as core from './core'
 
33
  declare global { interface Window { __dbg: unknown; __g: CanvasRenderingContext2D; __W: number; __H: number } }
34
- window.__dbg = { ...actors, ...core }
35
  window.__g = g; window.__W = W; window.__H = H
36
 
37
  // 实况图:window.renderLive(id, lt)
38
- import { drawLive, LIVE } from './overlays'
39
  declare global { interface Window { renderLive: (id: string, lt: number) => void; __live: unknown } }
40
- window.__live = LIVE
41
- window.renderLive = (id: string, lt: number) => { const l = LIVE.find((x) => x.id === id); if (l) drawLive(g, W, H, l, lt) }
 
1
  // 无头渲染入口(vibe render / 接触表 / 实况图):window.renderAt(t) 把画面冻结在 t 秒。
2
+ import { renderFrame, TOTAL, setFilm, FILM } from './film'
3
+ import { FILMS } from '../films'
4
  import { loadFonts } from './core'
5
 
6
  declare global { interface Window { renderAt: (t: number) => void; __ready: boolean; __total: number; __opts: { subs: boolean } } }
7
  const q = new URLSearchParams(location.search)
8
+ setFilm(FILMS.find((f) => f.id === (q.get('film') || 'duckling'))!)
9
  const W = Number(q.get('w')) || innerWidth, H = Number(q.get('h')) || innerHeight
10
  const c = document.createElement('canvas')
11
  c.width = W; c.height = H
 
22
  import { buildSoundtrack, toWav } from './audio'
23
  declare global { interface Window { renderAudio: () => Promise<string> } }
24
  window.renderAudio = async () => {
25
+ const t0 = performance.now(); const b = await buildSoundtrack((q.get('base') || './') + FILM.assets + 'voice.mp3', (s) => console.log('stage', s, Math.round(performance.now() - t0)))
26
  const bytes = new Uint8Array(toWav(b))
27
  let s = ''
28
  for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
 
32
  // 调试:在页面里直接调用角色绘制函数
33
  import * as actors from './actors'
34
  import * as core from './core'
35
+ import * as wolfActors from '../films/wolf/actors'
36
  declare global { interface Window { __dbg: unknown; __g: CanvasRenderingContext2D; __W: number; __H: number } }
37
+ window.__dbg = { ...actors, ...core, W: wolfActors }
38
  window.__g = g; window.__W = W; window.__H = H
39
 
40
  // 实况图:window.renderLive(id, lt)
41
+ import { drawLive } from './overlays'
42
  declare global { interface Window { renderLive: (id: string, lt: number) => void; __live: unknown } }
43
+ window.__live = FILM.live
44
+ window.renderLive = (id: string, lt: number) => { const l = FILM.live.find((x) => x.id === id); if (l) drawLive(g, W, H, l, lt) }
ugly-duckling/web/src/film/types.ts ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 多片引擎的公共类型:每部片子提供一个 FilmDef。
2
+ import type { Ctx } from './core'
3
+ import type { Mixer } from './audio'
4
+
5
+ export type Sub = { t0: number; t1: number; text: string; who?: string }
6
+ export type TLLine = { who: string; text: string; t0: number; dur: number }
7
+ export type TLScene = { id: string; start: number; dur: number; voice_start?: number; voice_dur?: number; subs?: Sub[]; lines?: TLLine[]; sfx?: { at: number; type: string }[] }
8
+ export type Timeline = { total: number; fps: number; w?: number; h?: number; scenes: TLScene[] }
9
+
10
+ export type SceneCtx = {
11
+ g: Ctx; W: number; H: number; u: number; t: number; D: number; T: number; portrait: boolean
12
+ gy: number; X: (f: number) => number
13
+ }
14
+ export type Cam = { z: number; x: number; y: number; dx?: number; dy?: number }
15
+ export type SceneDef = {
16
+ label: string
17
+ bg?: (s: SceneCtx) => void
18
+ draw: (s: SceneCtx) => void
19
+ cam?: (s: SceneCtx) => Cam
20
+ overlay?: (s: SceneCtx) => void
21
+ enterAt?: [number, number]
22
+ noSubs?: boolean
23
+ }
24
+
25
+ export type CoverDesign = { id: string; name: string; t: number; title: string; sub: string; side: 'left' | 'right'; vertical: boolean }
26
+ export type LiveDef = { id: string; scene: string; from: number; dur: number; cover: number; caption: string; index: string; xhs: string }
27
+
28
+ export type Platform = {
29
+ id: string; name: string; titleMax: number; tagMax: number | null; color: string
30
+ titles: string[]; desc: string; tags: string[]; tip: string
31
+ }
32
+ export type PhotoPost = { id: string; name: string; titleMax: number; tagMax: number; titles: string[]; body: string; tags: string[]; tip: string }
33
+
34
+ export type QA = { status: string; generated?: string; meta?: { w: number; h: number; fps: number; dur: number }; results: { level: string; check: string; msg: string }[]; verdict?: string }
35
+
36
+ export type FilmDef = {
37
+ id: string
38
+ /** 题目,例如「丑小鸭」 */
39
+ title: string
40
+ /** 标题后的小字,例如「变成白天鹅」 */
41
+ tagline: string
42
+ /** 页面简介 */
43
+ intro: string
44
+ /** 来源说明(页脚) */
45
+ credit: string
46
+ /** 公共资源目录(public 下),如 'film/' */
47
+ assets: string
48
+ /** Space 部署路径(用于系列互链),如 'ugly-duckling' */
49
+ slug: string
50
+ timeline: Timeline
51
+ scenes: Record<string, SceneDef>
52
+ /** 印章文字(列) */
53
+ seal: string[]
54
+ /** 实况图 / 封面副标题小字 */
55
+ series: string
56
+ /** 字幕颜色(按说话人) */
57
+ subColors?: Record<string, string>
58
+ score: (m: Mixer) => void
59
+ effects: (m: Mixer) => void
60
+ covers: CoverDesign[]
61
+ live: LiveDef[]
62
+ platforms: Platform[]
63
+ photoPosts: PhotoPost[]
64
+ qa: QA
65
+ making: [string, string][]
66
+ /** 配乐里要延迟创建的额外乐器方法(默认已有) */
67
+ }
68
+
69
+ export const sceneAt = (tl: Timeline, id: string, dt: number) => (tl.scenes.find((s) => s.id === id)?.start ?? 0) + dt
ugly-duckling/web/src/films/duckling/content.ts ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 发布文案:四个平台的视频文案 + 小红书 / 抖音图文(实况图)文案。
2
+ import type { Platform, PhotoPost } from '../../film/types'
3
+
4
+ export const PLATFORMS: Platform[] = [
5
+ {
6
+ id: 'xhs', name: '小红书', titleMax: 20, tagMax: 10, color: '#ff2442',
7
+ titles: ['水墨版丑小鸭|像语文课本里的插图', '一笔一画画出的水墨丑小鸭', '睡前故事|丑小鸭变白天鹅'],
8
+ desc: `还记得小学语文书里的《丑小鸭》吗?
9
+ 这次把它画成了一部会动的水墨插图:宣纸底、毛笔勾线、淡彩晕染,连字幕都是楷书。
10
+
11
+ 🥚 最大的那颗蛋,钻出一只又大又灰的小家伙
12
+ 🐓 被笑话、被欺负,他悄悄离开了家
13
+ ❄️ 熬过最冷的冬天
14
+ 🦢 春天来了,水里的倒影是一只白天鹅
15
+
16
+ 送给每一个觉得自己「不一样」的小朋友,也送给小时候的自己:
17
+ 不怕风雪,也不怕嘲笑,总有一天你也会展开属于自己的翅膀。
18
+
19
+ 适合当睡前故事,和孩子一起看 👀`,
20
+ tags: ['丑小鸭', '安徒生童话', '水墨动画', '睡前故事', '儿童故事', '国风插画', '手绘动画', '亲子阅读', '小学语文', '童话故事'],
21
+ tip: '标题 ≤20 字,话题 ≤10 个;话题粘贴后在 App 里点选一下即可变成蓝色话题。',
22
+ },
23
+ {
24
+ id: 'dy', name: '抖音', titleMax: 30, tagMax: 5, color: '#111111',
25
+ titles: ['用代码一笔一画,画了一部水墨版《丑小鸭》', '像小时候语文书插图的丑小鸭,你还记得吗', '丑小鸭变成白天鹅,这次是水墨手绘版'],
26
+ desc: `宣纸、毛笔、淡彩、朱红印章——把安徒生的《丑小鸭》画成了会动的课本插图。
27
+ 从破壳、被嘲笑、熬过寒冬,到在水里看见自己的倒影:原来他本来就是一只天鹅。
28
+ 相信自己,你也会展开属于自己的翅膀。`,
29
+ tags: ['丑小鸭', '水墨动画', '儿童故事', '安徒生童话', '国风动画'],
30
+ tip: '标题 ≤30 字,话题 ≤5 个。',
31
+ },
32
+ {
33
+ id: 'sph', name: '微信视频号', titleMax: 16, tagMax: null, color: '#07c160',
34
+ titles: ['水墨版《丑小鸭》', '丑小鸭变白天鹅·水墨版', '相信自己,你也会飞'],
35
+ desc: `一部像语文课本插图的水墨动画。
36
+ 最大的那颗蛋里,钻出了又大又灰的丑小鸭;他被嘲笑、离开家、熬过寒冬,终于在春天的湖水里看见——自己是一只白天鹅。
37
+ 转给家里的小朋友,当作今晚的睡前故事吧。`,
38
+ tags: ['丑小鸭', '安徒生童话', '水墨动画', '儿童动画', '睡前故事', '童话故事', '国风', '手绘', '亲子', '语文课本', '成长', '相信自己'],
39
+ tip: '标题 ≤16 字,话题数量不限。',
40
+ },
41
+ {
42
+ id: 'bili', name: '哔哩哔哩', titleMax: 80, tagMax: 10, color: '#fb7299',
43
+ titles: ['【水墨手绘】安徒生童话《丑小鸭》|小学语文课本插图风儿童动画,全程代码逐帧绘制+原创国风配乐', '【儿童动画】水墨版《丑小鸭变成白天鹅》:一部像课本插图的国风童话短片'],
44
+ desc: `一部约 2 分半的水墨手绘风儿童动画,改编自安徒生童话《丑小鸭》(1843)。
45
+
46
+ 画面:全部由代码在浏览器里逐帧绘制——宣纸纹理、变宽毛笔笔触、飞白、淡彩晕染、朱砂印章,14 个场景横竖屏各自重新构图。
47
+ 声音:中文旁白;原创五声音阶配乐(古筝拨弦+竹笛+铺底,随四季换情绪);破壳、鸭叫、枪声、冰裂、振翅等音效全部程序合成。
48
+
49
+ 章节:
50
+ 00:00 片头
51
+ 00:06 夏日乡村
52
+ 00:18 破壳
53
+ 00:29 最大的蛋
54
+ 00:41 被嘲笑
55
+ 00:51 离家
56
+ 00:59 沼泽
57
+ 01:13 秋天的大鸟
58
+ 01:28 寒冬
59
+ 01:42 春回
60
+ 01:54 倒影
61
+ 02:07 接纳
62
+ 02:19 高飞
63
+ 02:26 寄语`,
64
+ tags: ['丑小鸭', '安徒生童话', '水墨动画', '儿童动画', '国风', '手绘动画', '睡前故事', '原创配乐', '代码艺术', '童话'],
65
+ tip: '标题 ≤80 字,标签 ≤10 个;章节时间可直接粘进简介。',
66
+ },
67
+ ]
68
+
69
+ export const PHOTO_POSTS: PhotoPost[] = [
70
+ {
71
+ id: 'xhs', name: '小红书图文(实况图)', titleMax: 20, tagMax: 10,
72
+ titles: ['丑小鸭的六个瞬间|水墨实况图', '会动的语文课本插图|丑小鸭'],
73
+ body: `把《丑小鸭》画成了 6 张会动的水墨插图,长按就能看它动起来 ✨
74
+
75
+ 其一 · 最大的蛋:好久才裂开,钻出一只又大又灰的小家伙
76
+ 其二 · 丑小鸭:哥哥姐姐笑话他,公鸡追着啄他
77
+ 其三 · 仰望:秋天傍晚,一群洁白的大鸟从天上飞过
78
+ 其四 · 寒冬:湖面结冰,他缩成一团熬过一个个寒夜
79
+ 其五 · 倒影:低头一看,水里是一只白天鹅
80
+ 其六 · 展翅:原来,他本来就是一只天鹅
81
+
82
+ 每一笔都是代码画出来的:宣纸、毛笔、淡彩、朱红印章。
83
+ 你小时候最喜欢课本里的哪一篇?`,
84
+ tags: ['丑小鸭', '实况图', '水墨插画', '安徒生童话', '国风插画', '手绘', '小学语���', '童话故事', '治愈系', '动态壁纸'],
85
+ tip: '发布时选择「实况图」上传 6 组 JPG+MOV;标题 ≤20 字,话题 ≤10 个。',
86
+ },
87
+ {
88
+ id: 'dy', name: '抖音图文(实况图)', titleMax: 30, tagMax: 5,
89
+ titles: ['水墨实况图:丑小鸭变成白天鹅的六个瞬间', '会动的课本插图,丑小鸭长大了'],
90
+ body: `6 张会动的水墨插图,讲完一个《丑小鸭》。
91
+ 破壳 → 被嘲笑 → 仰望天鹅 → 熬过寒冬 → 水中倒影 → 展翅高飞。
92
+ 不怕风雪,也不怕嘲笑,总有一天,你也会展开属于自己的翅膀。`,
93
+ tags: ['丑小鸭', '实况图', '水墨插画', '安徒生童话', '国风'],
94
+ tip: '图文模式上传实况图;标题 ≤30 字,话题 ≤5 个。',
95
+ },
96
+ ]
97
+
ugly-duckling/web/src/films/duckling/cues.ts ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 画面与音效共用的提示点(秒,相对各场景开头)。对齐 timeline.json 里的旁白字幕。
2
+ export const CUE = {
3
+ title: { drop: 0.32, write: 1.1, seal: 4.55 },
4
+ hatch: { first: 0.4, gap: 0.75 },
5
+ bigegg: { crackFrom: 3.0, open: 3.8, surprise: 5.2, question: 6.6, walk: 8.4 },
6
+ bullied: { laugh1: [0.6, 4.6] as const, laugh2: [8.6, 10.4] as const, roosterIn: 4.0, pecks: [4.9, 5.35, 5.8, 6.25], girlIn: 6.0, shoo: 7.0 },
7
+ leave: { walk: 2.6 },
8
+ marsh: { laugh: [2.4, 5.0] as const, shots: [5.75, 6.3], scatter: 6.4, hide: 6.8 },
9
+ autumn: { swans: 4.0, lookUp: 4.4, walk: 11.0 },
10
+ winter: { ice: 3.6 },
11
+ spring: { buds: 2.2, bloom: 3.5, spread: 4.8, flapHard: 7.6, lift: 9.5, question: 7.1 },
12
+ reflection: { land: 0.75, bow: 4.2, mirror: 5.4, reveal: 9.7, proud: 10.6 },
13
+ accepted: { cheer: 4.6, heart: 7.4 },
14
+ ending: { end: 8.5 },
15
+ }
ugly-duckling/web/src/films/duckling/index.ts ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 《丑小鸭》片子定义
2
+ import type { FilmDef, Timeline, LiveDef, QA } from '../../film/types'
3
+ import { sceneAt } from '../../film/types'
4
+ import timeline from './timeline.json'
5
+ import live from './live.json'
6
+ import qa from './qa.json'
7
+ import { SCENES } from './scenes'
8
+ import { score, effects } from './music'
9
+ import { PLATFORMS, PHOTO_POSTS } from './content'
10
+
11
+ const tl = timeline as unknown as Timeline
12
+ const at = (id: string, dt: number) => sceneAt(tl, id, dt)
13
+
14
+ export const duckling: FilmDef = {
15
+ id: 'duckling',
16
+ title: '丑小鸭',
17
+ tagline: '变成白天鹅',
18
+ intro: '安徒生童话 · 水墨手绘儿童动画。宣纸、毛笔、淡彩、朱红印章,像小学语文课本里的插图那样,一笔一画在你的浏览器里实时画出来;旁白、原创国风配乐与音效同步播放。',
19
+ credit: '故事改编自安徒生《丑小鸭》(1843,公有领域)',
20
+ assets: 'film/',
21
+ slug: 'ugly-duckling',
22
+ timeline: tl,
23
+ scenes: SCENES,
24
+ seal: ['童话'],
25
+ series: '安徒生童话 · 丑小鸭',
26
+ score, effects,
27
+ covers: [
28
+ { id: 'reflect', name: '倒影揭晓', t: at('reflection', 7.6), title: '丑小鸭', sub: '原来,他是一只白天鹅', side: 'left', vertical: true },
29
+ { id: 'hatch', name: '破壳而出', t: at('bigegg', 4.4), title: '丑小鸭', sub: '安徒生童话 · 水墨手绘动画', side: 'right', vertical: true },
30
+ { id: 'fly', name: '展翅高飞', t: at('fly', 4.2), title: '相信自己', sub: '你也会展开属于自己的翅膀', side: 'right', vertical: true },
31
+ { id: 'autumn', name: '秋日仰望', t: at('autumn', 6.6), title: '丑小鸭', sub: '像语文课本里的插图', side: 'left', vertical: true },
32
+ ],
33
+ live: live as LiveDef[],
34
+ platforms: PLATFORMS,
35
+ photoPosts: PHOTO_POSTS,
36
+ qa: qa as QA,
37
+ making: [
38
+ ['画面', '全部由代码逐帧绘制:程序生成宣纸纹理,变宽毛笔笔触带起笔、收笔和飞白,淡彩晕染有水彩积边,再盖朱砂印章。同一只「鸟」可以从小黄鸭一路长成天鹅。每帧只由时间决定,可逐帧复现。'],
39
+ ['声音', '中文神经网络语音旁白;原创五声音阶配乐(古筝拨弦、竹笛、铺底、铃),随四季换情绪;破壳、鸭叫、枪声、冰裂、振翅、拍手等音效都是程序合成。人声出现时配乐自动压低,整体响度 −14 LUFS。'],
40
+ ['流程', '按 vibe-video 流程:分镜 storyboard → 配音生成时间轴 → 接触表 / 静帧检查 → 确定性自检 → 全片渲染 → 混音 → vibe qa 质检 → 封面与发布包;实况图按 livecanvas 流程配对封装。'],
41
+ ],
42
+ }
ugly-duckling/web/src/films/duckling/live.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ [
2
+ { "id": "01-egg", "scene": "bigegg", "from": 2.2, "dur": 3.0, "cover": 2.4, "caption": "最大的蛋", "index": "其一", "xhs": "最后一颗蛋最大,好久才裂开,钻出来一只又大又灰的小家伙。" },
3
+ { "id": "02-laugh", "scene": "bullied", "from": 3.0, "dur": 3.0, "cover": 1.0, "caption": "丑小鸭", "index": "其二", "xhs": "哥哥姐姐笑话他,公鸡追着啄他,大家都叫他「丑小鸭」。" },
4
+ { "id": "03-swans", "scene": "autumn", "from": 4.6, "dur": 3.0, "cover": 1.6, "caption": "仰望", "index": "其三", "xhs": "秋天傍晚,一群洁白的大鸟飞过,他望着天空,心里说不出的向往。" },
5
+ { "id": "04-snow", "scene": "winter", "from": 6.4, "dur": 3.0, "cover": 1.6, "caption": "寒冬", "index": "其四", "xhs": "雪夜里湖面结了冰,他缩成一团,熬过了一个又一个寒冷的夜晚。" },
6
+ { "id": "05-reflect", "scene": "reflection", "from": 5.4, "dur": 3.0, "cover": 2.4, "caption": "倒影", "index": "其五", "xhs": "他低下头,看见水里的倒影——那是一只美丽的白天鹅。" },
7
+ { "id": "06-fly", "scene": "fly", "from": 2.6, "dur": 3.0, "cover": 1.6, "caption": "展翅", "index": "其六", "xhs": "原来,他本来就是一只天鹅。张开翅膀,飞向高高的蓝天。" }
8
+ ]
ugly-duckling/web/src/films/duckling/music.ts ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 《丑小鸭》配乐与音效编排(原 audio.ts 中的 score / effects,原样迁移)
2
+ import { type Mixer, N, sceneStart, sceneDur } from '../../film/audio'
3
+ import { TOTAL } from '../../film/film'
4
+ import { rng } from '../../film/core'
5
+ import { CUE } from './cues'
6
+
7
+ export function score(m: Mixer) {
8
+ const B = 60 / 72 // 每拍秒数
9
+ const S = sceneStart, D = sceneDur
10
+ const r = rng(808)
11
+ const arp = (t0: number, t1: number, chords: number[][], step: number, vel: number, pattern = [0, 1, 2, 1]) => {
12
+ let i = 0
13
+ for (let t = t0; t < t1 - 0.05; t += step, i++) {
14
+ const ch = chords[Math.floor((t - t0) / (step * 8)) % chords.length]
15
+ m.pluck(t, ch[pattern[i % pattern.length] % ch.length], vel * (i % 4 === 0 ? 1 : 0.7), (i % 2 ? 0.25 : -0.25), { dur: 1.8 })
16
+ }
17
+ }
18
+ const G = [N.G3, N.D4, N.B4], Em = [N.E3, N.B3, N.G4], Dm = [N.D3, N.A3, N.D4], Am = [N.A2, N.E3, N.A3], Gh = [N.G4, N.B4, N.D5]
19
+ // 片头
20
+ let t = S('title')
21
+ m.gliss(t + 0.35, [N.G3, N.A3, N.B3, N.D4, N.E4, N.G4, N.A4, N.B4, N.D5], 0.07, 0.28)
22
+ m.pad(t + 0.5, S('summer') + 1, [N.G2, N.D3], 0.05)
23
+ m.melody(t + 2.2, B, [['D5', 1], ['E5', 0.5], ['G5', 0.5], ['E5', 1], ['D5', 1.5]], 0.17)
24
+ // 夏
25
+ t = S('summer')
26
+ m.pad(t, S('hatch') + 0.5, [N.G2, N.D3, N.B3], 0.045)
27
+ arp(t + 0.3, S('hatch'), [G, Em, G, Dm], B / 2, 0.2)
28
+ m.melody(t + 1.2, B, [['D5', 1], ['E5', 0.5], ['G5', 0.5], ['E5', 1], ['D5', 1], ['B4', 1], ['D5', 0.5], ['B4', 0.5], ['A4', 2], ['G4', 1], ['A4', 0.5], ['B4', 0.5], ['D5', 1], ['E5', 0.5], ['D5', 0.5], ['B4', 1], ['A4', 0.5], ['G4', 0.5], ['G4', 2]], 0.13)
29
+ // 破壳:俏皮的跳音 + 木鱼
30
+ t = S('hatch')
31
+ for (let i = 0; i < Math.floor(D('hatch') / B); i++) { if (i % 2 === 0) m.block(t + i * B, 0.07, 1100, -0.3); m.pluck(t + i * B + B / 2, [N.G4, N.B4, N.D5, N.E5][i % 4], 0.13, 0.3, { dur: 0.5, bright: 0.9 }) }
32
+ m.pad(t, S('bigegg'), [N.G2, N.D3], 0.035)
33
+ // 大蛋:悬念颤音 → 揭晓 → 「咦?」
34
+ t = S('bigegg')
35
+ const BE = CUE.bigegg
36
+ m.pad(t, t + BE.open, [N.E2, N.B2], 0.05, 500)
37
+ for (let x = t + 0.6; x < t + BE.open - 0.05; x += 0.09) m.pluck(x, N.E3, 0.06 + 0.1 * (x - t) / BE.open, 0, { dur: 0.3, send: 0.1 })
38
+ m.pluck(t + BE.surprise, N.D4, 0.3, 0, { bend: -2 }); m.pluck(t + BE.surprise + 0.25, N.A3, 0.3, 0, { bend: -2 })
39
+ m.pluck(t + BE.question + 0.05, N.B4, 0.22, 0.2, { bend: 2 })
40
+ arp(t + BE.walk, S('bullied'), [Em, G], B / 2, 0.12)
41
+ // 被嘲笑:E 羽调式,慢
42
+ t = S('bullied')
43
+ m.pad(t, S('leave') + 0.5, [N.E2, N.B2, N.G3], 0.05, 700)
44
+ for (let i = 0; i < 12; i++) m.pluck(t + 0.3 + i * B, [N.E4, N.D4, N.B3, N.A3, N.G3, N.A3, N.B3, N.E3][i % 8], 0.16, -0.2, { dur: 2, bend: i % 4 === 3 ? -1 : 0 })
45
+ // 离家:竹笛哀歌
46
+ t = S('leave')
47
+ m.pad(t, S('marsh') + 0.3, [N.E2, N.B2], 0.045, 600)
48
+ m.melody(t + 0.4, B * 1.1, [['E5', 1.5], ['D5', 0.5], ['B4', 1], ['A4', 1], ['G4', 1], ['A4', 0.5], ['B4', 0.5], ['E4', 2]], 0.13)
49
+ // 沼泽:低音颤奏 → 枪声后寂静 → 心跳
50
+ t = S('marsh')
51
+ for (let x = t + 0.2; x < t + CUE.marsh.shots[0] - 0.02; x += 0.125) m.pluck(x, (Math.floor((x - t) / 0.5) % 2) ? N.B2 : N.E2, 0.17, 0, { dur: 0.25, send: 0.05 })
52
+ m.pad(t + CUE.marsh.hide, S('autumn') + 0.5, [N.E2], 0.04, 400)
53
+ for (let x = t + CUE.marsh.hide + 0.6; x < t + 13; x += 0.95) m.heart(x)
54
+ // 秋:向往(E 羽 → G 宫的过渡)
55
+ t = S('autumn')
56
+ m.pad(t, S('winter') + 0.5, [N.E2, N.B2, N.G3], 0.04, 800)
57
+ arp(t + 0.3, S('winter'), [Em, Am, G, Em], B * 0.75, 0.13)
58
+ m.bell(t + CUE.autumn.swans + 0.6, N.B5, 0.07, 0.4); m.bell(t + CUE.autumn.swans + 3, N.E6, 0.06, -0.3)
59
+ m.melody(t + CUE.autumn.swans + 1.4, B, [['B4', 1.5], ['D5', 0.5], ['E5', 1], ['G5', 1], ['E5', 2], ['D5', 1], ['B4', 1], ['A4', 2]], 0.11, 0)
60
+ // 冬:几乎只剩风与孤铃
61
+ t = S('winter')
62
+ m.pad(t, S('spring') + 0.5, [N.E2, N.B2], 0.035, 350)
63
+ for (let i = 0; i < 6; i++) m.bell(t + 1 + i * 2.1, [N.E5, N.B4, N.D5, N.A4, N.B4, N.E5][i], 0.05, (i % 2 ? 0.4 : -0.4))
64
+ // 春:回到 G 宫,逐渐加密
65
+ t = S('spring')
66
+ m.bell(t + 0.15, N.G5, 0.1, 0); m.bell(t + 0.4, N.D6, 0.07, 0.3)
67
+ m.pad(t, S('reflection') + 0.5, [N.G2, N.D3, N.B3], 0.045)
68
+ const SP = CUE.spring
69
+ arp(t + 0.5, t + SP.flapHard, [G, Em], B / 2, 0.15)
70
+ arp(t + SP.flapHard, S('reflection'), [G, Dm, Em, G], B / 4, 0.14, [0, 1, 2, 1, 2, 1])
71
+ m.gliss(t + SP.lift - 0.05, [N.G3, N.A3, N.B3, N.D4, N.E4, N.G4, N.A4, N.B4, N.D5, N.E5, N.G5], 0.05, 0.25)
72
+ m.melody(t + SP.lift + 0.4, B, [['D5', 1], ['E5', 0.5], ['G5', 0.5], ['A5', 1], ['G5', 1], ['E5', 1], ['G5', 0.5], ['E5', 0.5], ['D5', 2]], 0.13)
73
+ // 倒影:揭晓
74
+ t = S('reflection')
75
+ const RF = CUE.reflection
76
+ m.pad(t, t + RF.reveal, [N.E2, N.B2, N.E3], 0.04, 600)
77
+ for (let i = 0; i < 10; i++) m.pluck(t + 0.8 + i * B * 1.05, [N.B3, N.D4, N.E4, N.D4, N.B3, N.A3, N.B3, N.D4, N.E4, N.G4][i], 0.12, 0, { dur: 2 })
78
+ m.bell(t + RF.mirror, N.E5, 0.06, 0); m.bell(t + RF.mirror + 0.3, N.B5, 0.04, 0.3)
79
+ m.gliss(t + RF.reveal - 0.05, [N.G3, N.B3, N.D4, N.G4, N.B4, N.D5, N.G5, N.B5, N.D6], 0.06, 0.3)
80
+ ;[N.G5, N.B5, N.D6, N.G5].forEach((x, i) => m.bell(t + RF.reveal + i * 0.18, x, 0.08, i % 2 ? 0.3 : -0.3))
81
+ m.pad(t + RF.reveal, S('accepted') + 0.5, [N.G2, N.D3, N.B3, N.D4], 0.05, 1200)
82
+ m.melody(t + RF.reveal + 1.2, B, [['G5', 1.5], ['E5', 0.5], ['D5', 1], ['B4', 1], ['D5', 2]], 0.14)
83
+ // 接纳:温暖
84
+ t = S('accepted')
85
+ arp(t + 0.2, S('fly'), [G, Em, Dm, G], B / 2, 0.15)
86
+ m.pad(t, S('fly') + 0.3, [N.G2, N.D3], 0.04)
87
+ m.melody(t + 0.8, B, [['G4', 1], ['A4', 0.5], ['B4', 0.5], ['D5', 1], ['E5', 0.5], ['D5', 0.5], ['B4', 1], ['A4', 0.5], ['G4', 0.5], ['G4', 2]], 0.12)
88
+ // 高飞:高潮
89
+ t = S('fly')
90
+ m.pad(t, S('ending') + 1, [N.G2, N.D3, N.G3, N.B3], 0.06, 1600)
91
+ arp(t, S('ending'), [Gh, G, Em, Gh], B / 4, 0.16, [0, 1, 2, 1, 2, 1, 0, 2])
92
+ for (let i = 0; i < Math.floor(D('fly') / B); i++) m.block(t + i * B, 0.08, 800, 0)
93
+ m.melody(t + 0.2, B, [['D5', 1], ['E5', 0.5], ['G5', 0.5], ['A5', 1], ['G5', 1], ['E5', 1], ['G5', 0.5], ['A5', 0.5], ['B5', 2]], 0.15, 0)
94
+ // 尾声
95
+ t = S('ending')
96
+ m.pad(t, TOTAL, [N.G2, N.D3, N.B3], 0.045)
97
+ const EN = CUE.ending.end
98
+ arp(t + 0.2, t + EN - 0.2, [G, Em, G, Dm], B * 0.75, 0.12)
99
+ m.gliss(t + EN, [N.G3, N.B3, N.D4, N.G4, N.B4, N.D5, N.G5], 0.08, 0.22)
100
+ m.bell(t + EN + 0.1, N.G5, 0.08); m.bell(t + EN + 0.35, N.D6, 0.05, 0.3)
101
+ m.pluck(t + EN + 0.6, N.G3, 0.2, 0, { dur: 2 }); m.pluck(t + EN + 0.6, N.D4, 0.15, 0, { dur: 2 }); m.pluck(t + EN + 0.6, N.G4, 0.12, 0, { dur: 2 })
102
+ void r
103
+ }
104
+
105
+ export function effects(m: Mixer) {
106
+ const S = sceneStart, D = sceneDur
107
+ const r = m.r
108
+ let t = S('title')
109
+ const TT = CUE.title
110
+ m.drop(t + TT.drop); m.swish(t + TT.write); m.swish(t + TT.write + 0.45); m.swish(t + TT.write + 0.9); m.thump(t + TT.seal + 0.01, 0.45)
111
+ for (let i = 0; i < 6; i++) m.peep(t + 2.6 + i * 0.65, 0.04, 0.3)
112
+ // 夏:鸟鸣、微风
113
+ t = S('summer')
114
+ m.wind(t, D('summer'), 0.025)
115
+ for (let x = t + 0.6; x < t + D('summer'); x += 1.1 + r() * 1.6) m.chirp(x, 0.035, r() * 1.4 - 0.7)
116
+ // 破壳
117
+ t = S('hatch')
118
+ for (let k = 0; k < 4; k++) {
119
+ const tc = t + 0.4 + k * 0.75
120
+ m.crack(tc + 0.05, 0.22, -0.3 + k * 0.2); m.crack(tc + 0.25, 0.18, -0.3 + k * 0.2)
121
+ m.noiseHit(tc + 0.55, 0.08, 'bandpass', 1800, 900, 0.18, -0.3 + k * 0.2, 2, 0.1)
122
+ m.peep(tc + 0.75, 0.1, -0.3 + k * 0.2)
123
+ for (let p = 1; p < 5; p++) { const pt = tc + 1 + 2.3 * (p - 1); if (pt - t < D('hatch')) m.peep(pt + 0.05, 0.07, -0.3 + k * 0.2) }
124
+ m.noiseHit(tc + 3.0, 0.05, 'lowpass', 600, 200, 0.12, -0.3 + k * 0.2, 0.7, 0.05)
125
+ }
126
+ // 大蛋
127
+ t = S('bigegg')
128
+ const BE = CUE.bigegg
129
+ for (let x = t + 0.5; x < t + BE.crackFrom; x += Math.max(0.12, 0.5 - (x - t) * 0.12)) m.block(x, 0.05 + (x - t) * 0.02, 420, 0)
130
+ m.crack(t + BE.crackFrom + 0.05, 0.2); m.crack(t + BE.crackFrom + 0.4, 0.22)
131
+ m.crack(t + BE.open, 0.35, 0, true)
132
+ m.noiseHit(t + BE.surprise, 0.25, 'bandpass', 2400, 1200, 0.06, -0.4, 1.2, 0.2)
133
+ m.peep(t + BE.surprise + 0.1, 0.08, -0.4); m.peep(t + BE.surprise + 0.2, 0.08, -0.5)
134
+ for (let x = t + BE.walk + 0.1; x < t + BE.walk + 2.6; x += 0.6) m.noiseHit(x, 0.05, 'lowpass', 600, 200, 0.1, 0, 0.7, 0.05)
135
+ // 被嘲笑
136
+ t = S('bullied')
137
+ const BU = CUE.bullied
138
+ for (let x = t + BU.laugh1[0] + 0.1; x < t + BU.laugh1[1] - 0.2; x += 0.35 + r() * 0.3) m.quack(x, 0.09, -0.5 + r() * 0.3, 1.25 + r() * 0.2)
139
+ for (let x = t + BU.laugh2[0] + 0.1; x < t + BU.laugh2[1] - 0.2; x += 0.4 + r() * 0.35) m.quack(x, 0.07, -0.5 + r() * 0.3, 1.25 + r() * 0.2)
140
+ // 公鸡登场:扑翅 + 啄
141
+ m.flap(t + BU.roosterIn, 6, 9, 0.12, 0.5)
142
+ m.tone(t + BU.roosterIn + 0.2, 600, 900, 0.25, 0.08, 'sawtooth', 0.5, 0.3); m.tone(t + BU.roosterIn + 0.45, 900, 500, 0.5, 0.07, 'sawtooth', 0.5, 0.4)
143
+ for (const p of BU.pecks) { m.block(t + p, 0.25, 1500, 0.2); m.flap(t + p - 0.15, 2, 10, 0.08, 0.3) }
144
+ m.swish(t + BU.shoo, 0.07)
145
+ // 离家
146
+ t = S('leave')
147
+ m.wind(t, D('leave') + 1, 0.07, -0.2)
148
+ for (let x = t + CUE.leave.walk + 0.2; x < t + D('leave'); x += 0.62) m.noiseHit(x, 0.04, 'lowpass', 500, 200, 0.05, 0, 0.7, 0.05)
149
+ // 沼泽
150
+ t = S('marsh')
151
+ m.lap(t, t + D('marsh'), 0.035)
152
+ const MA = CUE.marsh
153
+ for (let x = t + MA.laugh[0] + 0.1; x < t + MA.laugh[1] - 0.2; x += 0.3 + r() * 0.3) m.quack(x, 0.1, 0.4 + r() * 0.3, 0.95 + r() * 0.15)
154
+ m.shot(t + MA.shots[0], 0.6); m.shot(t + MA.shots[1], 0.45)
155
+ m.flap(t + MA.scatter, 14, 12, 0.13, 0.4)
156
+ for (let i = 0; i < 6; i++) m.quack(t + MA.scatter + 0.05 + i * 0.12, 0.08, 0.5, 1.1)
157
+ m.splash(t + MA.hide + 0.2, 0.12)
158
+ // 秋
159
+ t = S('autumn')
160
+ m.wind(t, D('autumn'), 0.03, 0.2)
161
+ const AU = CUE.autumn.swans
162
+ m.swanCall(t + AU + 0.8, 0.06, 0.5); m.swanCall(t + AU + 3.0, 0.07, 0.1); m.swanCall(t + AU + 5.6, 0.05, -0.5)
163
+ m.flap(t + AU + 1.5, 12, 1.4, 0.04, 0.2)
164
+ // 冬
165
+ t = S('winter')
166
+ m.wind(t, D('winter') + 0.8, 0.12, 0)
167
+ m.wind(t + 2, D('winter') - 2, 0.06, 0.6)
168
+ m.crack(t + CUE.winter.ice, 0.2, 0, true); m.noiseHit(t + CUE.winter.ice + 0.05, 0.9, 'bandpass', 300, 120, 0.12, 0, 4, 0.5)
169
+ // 春
170
+ t = S('spring')
171
+ for (let x = t + 0.4; x < t + D('spring'); x += 0.7 + r() * 1.1) m.chirp(x, 0.04, r() * 1.4 - 0.7)
172
+ const SP = CUE.spring
173
+ m.flap(t + SP.spread, 2, 2, 0.1, 0)
174
+ m.flap(t + SP.spread + 0.9, Math.floor((SP.flapHard - SP.spread - 0.9) * 0.83), 0.83, 0.08, 0)
175
+ m.flap(t + SP.flapHard, Math.floor((SP.lift - SP.flapHard) * 1.6), 1.6, 0.13, 0); m.flap(t + SP.lift, 6, 1.46, 0.1, 0.2)
176
+ m.noiseHit(t + SP.lift - 0.1, 1.2, 'bandpass', 400, 2400, 0.12, 0, 1, 0.4)
177
+ m.pluck(t + SP.question, N.B4, 0.2, 0, { bend: 2 })
178
+ // 倒影
179
+ t = S('reflection')
180
+ const RF = CUE.reflection
181
+ m.flap(t, 5, 7, 0.1, -0.2)
182
+ m.splash(t + RF.land, 0.3)
183
+ m.lap(t + 1.2, t + D('reflection'), 0.03)
184
+ m.bell(t + RF.reveal, 91, 0.06, 0, m.sfx)
185
+ // 接纳
186
+ t = S('accepted')
187
+ m.lap(t, t + D('accepted'), 0.03)
188
+ const CH = CUE.accepted.cheer
189
+ for (let x = t + CH + 0.1; x < t + CH + 4.5; x += 0.11 + r() * 0.08) m.clap(x, 0.06 * (1 - (x - t - CH) / 5), -0.55 + r() * 0.2)
190
+ for (let i = 0; i < 4; i++) m.chirp(t + CH + 0.4 + i * 1.3, 0.03, 0.4)
191
+ // 高飞
192
+ t = S('fly')
193
+ m.wind(t, D('fly') + 1, 0.07, 0)
194
+ m.flap(t + 0.3, Math.floor(D('fly') * 3.2 / 2 / Math.PI * 2), 3.2 / Math.PI, 0.09, 0)
195
+ m.noiseHit(t + 0.9, 1.6, 'bandpass', 300, 2000, 0.1, 0, 1, 0.5)
196
+ // 尾声
197
+ t = S('ending')
198
+ m.lap(t, t + D('ending'), 0.025)
199
+ m.thump(t + CUE.ending.end + 0.12, 0.4)
200
+ }
201
+
ugly-duckling/web/src/films/duckling/qa.json ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "status": "done",
3
+ "generated": "2026-10-17",
4
+ "meta": {
5
+ "w": 1080,
6
+ "h": 1920,
7
+ "fps": 30.0,
8
+ "dur": 156.3,
9
+ "audio": true
10
+ },
11
+ "verdict": "PASS",
12
+ "results": [
13
+ {
14
+ "level": "PASS",
15
+ "check": "竖屏 分辨率",
16
+ "msg": "1080×1920(时间轴 1080×1920)"
17
+ },
18
+ {
19
+ "level": "PASS",
20
+ "check": "竖屏 帧率",
21
+ "msg": "30.0 fps"
22
+ },
23
+ {
24
+ "level": "PASS",
25
+ "check": "竖屏 时长",
26
+ "msg": "156.30s(时间轴 156.30s)"
27
+ },
28
+ {
29
+ "level": "PASS",
30
+ "check": "竖屏 音轨",
31
+ "msg": "有"
32
+ },
33
+ {
34
+ "level": "PASS",
35
+ "check": "竖屏 响度",
36
+ "msg": "-14.0 LUFS(目标 −14)"
37
+ },
38
+ {
39
+ "level": "PASS",
40
+ "check": "竖屏 真峰值",
41
+ "msg": "-1.4 dBTP(应 ≤ −1)"
42
+ },
43
+ {
44
+ "level": "PASS",
45
+ "check": "竖屏 黑场",
46
+ "msg": "无"
47
+ },
48
+ {
49
+ "level": "PASS",
50
+ "check": "竖屏 冻结画面",
51
+ "msg": "无"
52
+ },
53
+ {
54
+ "level": "PASS",
55
+ "check": "竖屏 文字@title",
56
+ "msg": "OCR 读出 16 字;字幕命中 100%"
57
+ },
58
+ {
59
+ "level": "PASS",
60
+ "check": "竖屏 文字@summer",
61
+ "msg": "OCR 读出 37 字;字幕命中 88%"
62
+ },
63
+ {
64
+ "level": "PASS",
65
+ "check": "竖屏 文字@hatch",
66
+ "msg": "OCR 读出 31 字;字幕命中 100%"
67
+ },
68
+ {
69
+ "level": "PASS",
70
+ "check": "竖屏 文字@bigegg",
71
+ "msg": "OCR 读出 33 字;字幕命中 100%"
72
+ },
73
+ {
74
+ "level": "PASS",
75
+ "check": "竖屏 文字@bullied",
76
+ "msg": "OCR 读出 35 字;字幕命中 100%"
77
+ },
78
+ {
79
+ "level": "PASS",
80
+ "check": "竖屏 文字@leave",
81
+ "msg": "OCR 读出 21 字;字幕命中 100%"
82
+ },
83
+ {
84
+ "level": "PASS",
85
+ "check": "竖屏 文字@marsh",
86
+ "msg": "OCR 读出 14 字;字幕命中 86%"
87
+ },
88
+ {
89
+ "level": "PASS",
90
+ "check": "竖屏 文字@autumn",
91
+ "msg": "OCR 读出 20 字;字幕命中 100%"
92
+ },
93
+ {
94
+ "level": "PASS",
95
+ "check": "竖屏 文字@winter",
96
+ "msg": "OCR 读出 40 字;字幕命中 91%"
97
+ },
98
+ {
99
+ "level": "PASS",
100
+ "check": "竖屏 文字@spring",
101
+ "msg": "OCR 读出 22 字;字幕命中 100%"
102
+ },
103
+ {
104
+ "level": "INFO",
105
+ "check": "竖屏 文字@reflection",
106
+ "msg": "OCR 读出 47 字;字幕命中 53%(检查窗口同时计入了上一句字幕;当前字幕「而是一只美丽的白天鹅」已被读出)"
107
+ },
108
+ {
109
+ "level": "PASS",
110
+ "check": "竖屏 文字@accepted",
111
+ "msg": "OCR 读出 33 字;字幕命中 100%"
112
+ },
113
+ {
114
+ "level": "PASS",
115
+ "check": "竖屏 文字@fly",
116
+ "msg": "OCR 读出 11 字;字幕命中 100%"
117
+ },
118
+ {
119
+ "level": "PASS",
120
+ "check": "竖屏 文字@ending",
121
+ "msg": "OCR 读出 28 字;字幕命中 100%"
122
+ },
123
+ {
124
+ "level": "PASS",
125
+ "check": "横屏 分辨率",
126
+ "msg": "1920×1080(时间轴 1920×1080)"
127
+ },
128
+ {
129
+ "level": "PASS",
130
+ "check": "横屏 帧率",
131
+ "msg": "30.0 fps"
132
+ },
133
+ {
134
+ "level": "PASS",
135
+ "check": "横屏 时长",
136
+ "msg": "156.30s(时间轴 156.30s)"
137
+ },
138
+ {
139
+ "level": "PASS",
140
+ "check": "横屏 音轨",
141
+ "msg": "有"
142
+ },
143
+ {
144
+ "level": "PASS",
145
+ "check": "横屏 响度",
146
+ "msg": "-14.0 LUFS(目标 −14)"
147
+ },
148
+ {
149
+ "level": "PASS",
150
+ "check": "横屏 真峰值",
151
+ "msg": "-1.4 dBTP(应 ≤ −1)"
152
+ },
153
+ {
154
+ "level": "PASS",
155
+ "check": "横屏 黑场",
156
+ "msg": "无"
157
+ },
158
+ {
159
+ "level": "PASS",
160
+ "check": "横屏 冻结画面",
161
+ "msg": "无"
162
+ },
163
+ {
164
+ "level": "PASS",
165
+ "check": "横屏 字幕@summer",
166
+ "msg": "「安安静静地孵着她的蛋」OCR 命中 88%;墨色/纸带对比度 12.5:1"
167
+ },
168
+ {
169
+ "level": "PASS",
170
+ "check": "横屏 字幕@hatch",
171
+ "msg": "「好奇地看着这个世界」OCR 命中 100%;墨色/纸带对比度 12.4:1"
172
+ },
173
+ {
174
+ "level": "PASS",
175
+ "check": "横屏 字幕@bigegg",
176
+ "msg": "「一点也不像他的哥哥姐姐」OCR 命中 100%;墨色/纸带对比度 12.7:1"
177
+ },
178
+ {
179
+ "level": "PASS",
180
+ "check": "横屏 字幕@bullied",
181
+ "msg": "「连喂鸭的小姑娘也嫌他碍事」OCR 命中 100%;墨色/纸带对比度 13.2:1"
182
+ },
183
+ {
184
+ "level": "PASS",
185
+ "check": "横屏 字幕@leave",
186
+ "msg": "「离开了家」OCR 命中 100%;墨色/纸带对比度 3.5:1"
187
+ },
188
+ {
189
+ "level": "PASS",
190
+ "check": "横屏 字幕@marsh",
191
+ "msg": "「他吓得躲进芦苇」OCR 命中 0%;墨色/纸带对比度 11.6:1"
192
+ },
193
+ {
194
+ "level": "PASS",
195
+ "check": "横屏 字幕@autumn",
196
+ "msg": "「丑小鸭望着他们」OCR 命中 100%;墨色/纸带对比度 11.2:1"
197
+ },
198
+ {
199
+ "level": "PASS",
200
+ "check": "横屏 字幕@winter",
201
+ "msg": "「熬过了一个又一个寒冷的夜晚」OCR 命中 91%;墨色/纸带对比度 12.9:1"
202
+ },
203
+ {
204
+ "level": "PASS",
205
+ "check": "横屏 字幕@spring",
206
+ "msg": "「翅膀变得这么有力」OCR 命中 100%;墨色/纸带对比度 12.2:1"
207
+ },
208
+ {
209
+ "level": "PASS",
210
+ "check": "横屏 字幕@reflection",
211
+ "msg": "「那不是又丑又灰的鸭子而是一只美丽的白天鹅」OCR 命中 53%;墨色/纸带对比度 12.1:1"
212
+ },
213
+ {
214
+ "level": "PASS",
215
+ "check": "横屏 字幕@accepted",
216
+ "msg": "「新来的那只天鹅最漂亮」OCR 命中 90%;墨色/纸带对比度 12.9:1"
217
+ },
218
+ {
219
+ "level": "PASS",
220
+ "check": "横屏 字幕@fly",
221
+ "msg": "「飞向高高的蓝天」OCR 命中 100%;墨色/纸带对比度 10.2:1"
222
+ },
223
+ {
224
+ "level": "PASS",
225
+ "check": "横屏 字幕@ending",
226
+ "msg": "「你也会展开属于自己的翅膀」OCR 命中 100%;墨色/纸带对比度 13.1:1"
227
+ },
228
+ {
229
+ "level": "INFO",
230
+ "check": "说明",
231
+ "msg": "横屏字幕位于画面底部 ~92%,vibe qa 的 OCR 只扫 60–84% 高度带,故横屏改用「字幕带 OCR + 墨色/纸带对比度(≥7:1)」补充检查。"
232
+ },
233
+ {
234
+ "level": "PASS",
235
+ "check": "确定性",
236
+ "msg": "vibe det:同一帧乱序重渲逐像素一致"
237
+ },
238
+ {
239
+ "level": "PASS",
240
+ "check": "实况图",
241
+ "msg": "6 组 JPG+MOV:content identifier 一致、still-image-time 读回正确、MOV 完整解码、哈希校验通过(PhotoKit 本机加载:Linux 环境不可用)"
242
+ }
243
+ ]
244
+ }