Răsfoiți Sursa

修复容器环境不一致问题;webui支持同时输出多个视频

lkatzey 1 lună în urmă
părinte
comite
7ffab4999a

+ 39 - 15
Dockerfile

@@ -1,4 +1,4 @@
-# Stage 1: Install dependencies
+# Stage 1: Install dependencies (isolated layout, same as local dev)
 FROM node:22-bookworm AS deps
 
 # 使用阿里云镜像源
@@ -32,44 +32,68 @@ FROM deps AS build
 COPY . .
 RUN pnpm build
 
-# Stage 3: Runtime
+# Stage 3: Runtime — fresh prod install with hoisted layout
 FROM node:22-bookworm-slim AS runtime
 
 RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources
 
+RUN corepack enable && corepack prepare pnpm@10.24.0 --activate
+
 RUN apt-get update && \
-    apt-get install -y --no-install-recommends ffmpeg python3 python3-pip && \
+    apt-get install -y --no-install-recommends \
+      ffmpeg python3 python3-pip fontconfig \
+      libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
+      libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \
+      libgbm1 libpango-1.0-0 libcairo2 libasound2 && \
     rm -rf /var/lib/apt/lists/*
 
 RUN pip3 install --break-system-packages -i https://mirrors.aliyun.com/pypi/simple/ faster-whisper
 
 WORKDIR /app
 
-COPY --from=build /app/package.json ./
-COPY --from=build /app/node_modules node_modules/
-COPY --from=build /app/apps/cli/dist apps/cli/dist/
+# Copy workspace manifests + lockfile so pnpm install can resolve workspaces
+COPY --from=build /app/package.json /app/pnpm-lock.yaml /app/pnpm-workspace.yaml /app/turbo.json ./
 COPY --from=build /app/apps/cli/package.json apps/cli/
-COPY --from=build /app/apps/web/.next apps/web/.next/
-COPY --from=build /app/apps/web/node_modules apps/web/node_modules/
 COPY --from=build /app/apps/web/package.json apps/web/
-COPY --from=build /app/apps/web/next.config.ts apps/web/
+COPY --from=build /app/packages/shared/package.json packages/shared/
+COPY --from=build /app/packages/core/package.json packages/core/
+COPY --from=build /app/packages/tts/package.json packages/tts/
+COPY --from=build /app/packages/templates/package.json packages/templates/
+COPY --from=build /app/packages/collect/package.json packages/collect/
+
+# Install production deps with hoisted layout (everything flat at root node_modules)
+RUN pnpm config set registry https://registry.npmmirror.com && \
+    pnpm install --frozen-lockfile --prod --config.node-linker=hoisted
+
+# Re-create workspace symlinks at root so any package can resolve @pipeline/* by walking up
+RUN mkdir -p node_modules/@pipeline && \
+    ln -s ../../packages/shared node_modules/@pipeline/shared && \
+    ln -s ../../packages/core node_modules/@pipeline/core && \
+    ln -s ../../packages/tts node_modules/@pipeline/tts && \
+    ln -s ../../packages/templates node_modules/@pipeline/templates && \
+    ln -s ../../packages/collect node_modules/@pipeline/collect
+
+# Copy build artifacts (dist, .next, template src for Remotion entry, etc.)
+COPY --from=build /app/apps/cli/dist apps/cli/dist/
+COPY --from=build /app/apps/web/.next apps/web/.next/
+COPY --from=build /app/apps/web/next.config.mjs apps/web/
 COPY --from=build /app/apps/web/src apps/web/src/
 COPY --from=build /app/packages/shared/dist packages/shared/dist/
-COPY --from=build /app/packages/shared/package.json packages/shared/
 COPY --from=build /app/packages/core/dist packages/core/dist/
-COPY --from=build /app/packages/core/package.json packages/core/
 COPY --from=build /app/packages/tts/dist packages/tts/dist/
-COPY --from=build /app/packages/tts/package.json packages/tts/
 COPY --from=build /app/packages/tts/scripts packages/tts/scripts/
 COPY --from=build /app/packages/templates/dist packages/templates/dist/
 COPY --from=build /app/packages/templates/src packages/templates/src/
-COPY --from=build /app/packages/templates/package.json packages/templates/
 COPY --from=build /app/packages/collect/dist packages/collect/dist/
-COPY --from=build /app/packages/collect/package.json packages/collect/
 COPY --from=build /app/assets assets/
 COPY --from=build /app/config config/
 
+# Install bundled Noto Sans SC as system font so Chrome can render CJK via fontconfig fallback
+RUN mkdir -p /usr/share/fonts/truetype/noto && \
+    cp assets/fonts/NotoSansSC-Regular.ttf assets/fonts/NotoSansSC-Bold.ttf /usr/share/fonts/truetype/noto/ && \
+    fc-cache -f
+
 EXPOSE 3000
 
 WORKDIR /app/apps/web
-CMD ["./node_modules/.bin/next", "start"]
+CMD ["/app/node_modules/.bin/next", "start"]

+ 3 - 3
apps/web/next.config.ts → apps/web/next.config.mjs

@@ -1,10 +1,9 @@
-import type { NextConfig } from "next";
 import { existsSync, readFileSync } from "node:fs";
 import { resolve } from "node:path";
 
 // Load .env from monorepo root at build/start time
 const envPath = resolve(import.meta.dirname, "../../.env");
-const envVars: Record<string, string> = {};
+const envVars = {};
 if (existsSync(envPath)) {
   const content = readFileSync(envPath, "utf-8");
   for (const line of content.split("\n")) {
@@ -18,7 +17,8 @@ if (existsSync(envPath)) {
   }
 }
 
-const nextConfig: NextConfig = {
+/** @type {import('next').NextConfig} */
+const nextConfig = {
   transpilePackages: ["@pipeline/shared", "@pipeline/core", "@pipeline/tts"],
   env: envVars,
 };

+ 42 - 21
apps/web/src/app/create/page.tsx

@@ -55,7 +55,7 @@ export default function CreatePage() {
   const [inputMode, setInputMode] = useState<InputMode>("manual");
   const [text, setText] = useState("");
   const [template, setTemplate] = useState("news");
-  const [platform, setPlatform] = useState("bilibili");
+  const [platforms, setPlatforms] = useState<string[]>(["bilibili"]);
   const [ttsProvider, setTtsProvider] = useState("openai-tts");
   const [voiceId, setVoiceId] = useState("");
   const [submitting, setSubmitting] = useState(false);
@@ -118,15 +118,17 @@ export default function CreatePage() {
     setError(null);
     try {
       const payload: any = {
-        input: { template, platform, ttsProvider, voiceId },
+        input: { template, platforms, ttsProvider, voiceId },
       };
-      if (inputMode === "source" && selectedSource) {
+      // Prefer already-collected (and possibly edited) text. Only fall back to
+      // server-side collection if the textarea is still empty.
+      if (text.trim()) {
+        payload.input.text = text;
+      } else if (inputMode === "source" && selectedSource) {
         payload.input.source = selectedSource;
         if (Object.keys(sourceArgs).length > 0) {
           payload.input.sourceArgs = sourceArgs;
         }
-      } else {
-        payload.input.text = text;
       }
 
       const res = await fetch("/api/render", {
@@ -150,6 +152,12 @@ export default function CreatePage() {
   const canProceed =
     inputMode === "manual" ? text.trim().length > 0 : selectedSource.length > 0;
 
+  const togglePlatform = (p: string) => {
+    setPlatforms((prev) =>
+      prev.includes(p) ? prev.filter((x) => x !== p) : [...prev, p]
+    );
+  };
+
   // Source-specific arg fields
   const currentSource = sources.find((s) => s.name === selectedSource);
   const needsOwnerRepo = currentSource?.name === "github-repo";
@@ -337,23 +345,34 @@ export default function CreatePage() {
             </div>
 
             <div className="form-group">
-              <label>Platform</label>
-              <select
-                className="form-select"
-                value={platform}
-                onChange={(e) => setPlatform(e.target.value)}
-              >
-                {PLATFORM_PRESET_KEYS.map((p) => (
-                  <option key={p} value={p}>{PLATFORMS_INFO[p]}</option>
-                ))}
-              </select>
+              <label>Platforms (select one or more)</label>
+              <div className="card-grid">
+                {PLATFORM_PRESET_KEYS.map((p) => {
+                  const selected = platforms.includes(p);
+                  return (
+                    <div
+                      key={p}
+                      className={`card card-hover template-card ${selected ? "selected" : ""}`}
+                      style={selected ? { borderColor: "var(--primary)", boxShadow: "0 0 0 1px var(--primary)" } : {}}
+                      onClick={() => togglePlatform(p)}
+                    >
+                      <h3>{p}</h3>
+                      <p>{PLATFORMS_INFO[p]}</p>
+                    </div>
+                  );
+                })}
+              </div>
             </div>
 
             <div style={{ display: "flex", gap: 12 }}>
               <button className="btn btn-secondary" onClick={() => setStep("input")}>
                 Back
               </button>
-              <button className="btn btn-primary" onClick={() => setStep("voice")}>
+              <button
+                className="btn btn-primary"
+                disabled={platforms.length === 0}
+                onClick={() => setStep("voice")}
+              >
                 Next
               </button>
             </div>
@@ -404,8 +423,8 @@ export default function CreatePage() {
                 <div style={{ fontWeight: 600 }}>{TEMPLATES_INFO[template]?.label} ({template})</div>
               </div>
               <div className="card">
-                <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>Platform</div>
-                <div style={{ fontWeight: 600 }}>{platform}</div>
+                <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>Platforms</div>
+                <div style={{ fontWeight: 600 }}>{platforms.join(", ")}</div>
               </div>
               <div className="card">
                 <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>TTS Provider</div>
@@ -414,9 +433,11 @@ export default function CreatePage() {
               <div className="card">
                 <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>Input</div>
                 <div style={{ fontWeight: 600 }}>
-                  {inputMode === "source"
-                    ? `Source: ${selectedSource}`
-                    : `${text.length} chars`}
+                  {text.trim()
+                    ? `${text.length} chars`
+                    : inputMode === "source"
+                      ? `Source: ${selectedSource} (will collect at render time)`
+                      : "empty"}
                 </div>
               </div>
             </div>

+ 24 - 13
apps/web/src/lib/config.ts

@@ -1,22 +1,33 @@
 import { readFileSync, existsSync } from "node:fs";
-import { resolve, dirname } from "node:path";
+import { resolve } from "node:path";
 import { parse as parseYaml } from "yaml";
 
-const projectRoot = resolve(dirname(new URL(import.meta.url).pathname), "../../../..");
-
-export function loadConfig(configPath?: string): Record<string, any> | null {
-  const paths = [
-    configPath,
-    resolve(projectRoot, "pipeline.config.yaml"),
-    resolve(projectRoot, "pipeline.config.yml"),
-    resolve(projectRoot, "config/default.yaml"),
+function findConfigPath(explicit?: string): string | null {
+  const candidates = [
+    explicit,
+    "pipeline.config.yaml",
+    "pipeline.config.yml",
+    "config/default.yaml",
   ].filter(Boolean) as string[];
 
-  for (const p of paths) {
-    if (existsSync(p)) {
-      const content = readFileSync(p, "utf-8");
-      return parseYaml(content);
+  // Walk up from cwd so the lookup works in both dev (cwd = apps/web) and
+  // Docker production (cwd = /app/apps/web), where the bundled import.meta.url
+  // points inside .next rather than the source tree.
+  let dir = process.cwd();
+  for (let i = 0; i < 6; i++) {
+    for (const c of candidates) {
+      const p = resolve(dir, c);
+      if (existsSync(p)) return p;
     }
+    if (dir === "/") break;
+    dir = resolve(dir, "..");
   }
   return null;
 }
+
+export function loadConfig(configPath?: string): Record<string, any> | null {
+  const p = findConfigPath(configPath);
+  if (!p) return null;
+  const content = readFileSync(p, "utf-8");
+  return parseYaml(content);
+}

+ 1 - 2
docker-compose.yml

@@ -13,10 +13,9 @@ services:
       - MINIMAX_GROUP_ID=${MINIMAX_GROUP_ID}
       - ELEVENLABS_API_KEY=${ELEVENLABS_API_KEY}
     volumes:
-      - pipeline-output:/app/apps/web/output
+      - ./output:/app/apps/web/output
       - pipeline-jobs:/tmp/pipeline-jobs
     restart: unless-stopped
 
 volumes:
-  pipeline-output:
   pipeline-jobs:

+ 1 - 1
packages/core/src/pipeline.ts

@@ -119,7 +119,7 @@ export async function runPipeline(
       // Stage 5: Render
       callbacks?.onStageStart?.(`render:${platform}`);
       const renderOutput = join(workDir, `render-${platform}.mp4`);
-      await renderVideo(composed, renderOutput, config.templates.entryPoint);
+      await renderVideo(composed, renderOutput, config.templates.entryPoint, config.assets.root);
       callbacks?.onStageComplete?.(`render:${platform}`);
 
       // Stage 6: Export

+ 22 - 3
packages/core/src/stages/render.ts

@@ -1,5 +1,6 @@
 import { basename, dirname, join } from "node:path";
 import { copyFile, mkdir } from "node:fs/promises";
+import { existsSync } from "node:fs";
 import type { ComposedProject } from "@pipeline/shared";
 import { renderMedia, getCompositions } from "@remotion/renderer";
 import { bundle } from "@remotion/bundler";
@@ -9,7 +10,7 @@ export interface RenderResult {
   fileSizeBytes: number;
 }
 
-async function preparePublicDir(project: ComposedProject): Promise<string> {
+async function preparePublicDir(project: ComposedProject, assetsRoot: string): Promise<string> {
   const publicDir = join(dirname(project.audioPath), "public");
   await mkdir(publicDir, { recursive: true });
 
@@ -35,6 +36,23 @@ async function preparePublicDir(project: ComposedProject): Promise<string> {
     }
   }
 
+  // Copy bundled fonts so templates can load them via staticFile()
+  const fontsDir = join(assetsRoot, "fonts");
+  if (existsSync(fontsDir)) {
+    const fontsDest = join(publicDir, "fonts");
+    await mkdir(fontsDest, { recursive: true });
+    for (const f of ["NotoSansSC-Regular.ttf", "NotoSansSC-Bold.ttf"]) {
+      const src = join(fontsDir, f);
+      if (existsSync(src)) {
+        await copyFile(src, join(fontsDest, f));
+      } else {
+        console.warn(`[render] font source missing: ${src}`);
+      }
+    }
+  } else {
+    console.warn(`[render] assets fonts dir missing: ${fontsDir}`);
+  }
+
   return publicDir;
 }
 
@@ -64,9 +82,10 @@ function buildInputProps(project: ComposedProject) {
 export async function renderVideo(
   project: ComposedProject,
   outputPath: string,
-  templatesEntry: string
+  templatesEntry: string,
+  assetsRoot: string
 ): Promise<RenderResult> {
-  const publicDir = await preparePublicDir(project);
+  const publicDir = await preparePublicDir(project, assetsRoot);
   const inputProps = buildInputProps(project);
 
   const bundleLocation = await bundle({

+ 55 - 7
packages/templates/src/Root.tsx

@@ -1,5 +1,5 @@
-import React from "react";
-import { Composition, Sequence, AbsoluteFill, Audio, staticFile, Img, useCurrentFrame, interpolate } from "remotion";
+import React, { useEffect, useState } from "react";
+import { Composition, Sequence, AbsoluteFill, Audio, staticFile, Img, useCurrentFrame, interpolate, continueRender, delayRender } from "remotion";
 import type { TemplateType, AspectRatio } from "@pipeline/shared";
 import type { WordTimestamp } from "@pipeline/shared";
 import NewsScene from "./news/index";
@@ -163,7 +163,7 @@ const CoverScene: React.FC<{
       }}>
         <div style={{
           fontSize: 72, fontWeight: 800, color: "white",
-          fontFamily: "sans-serif", lineHeight: 1.2, marginBottom: 20,
+          fontFamily: "Noto Sans SC", lineHeight: 1.2, marginBottom: 20,
           maxWidth: "80%",
         }}>
           {title}
@@ -171,7 +171,7 @@ const CoverScene: React.FC<{
         {subtitle && (
           <div style={{
             fontSize: 28, color: "rgba(255,255,255,0.7)",
-            fontFamily: "sans-serif", marginBottom: 40,
+            fontFamily: "Noto Sans SC", marginBottom: 40,
           }}>
             {subtitle}
           </div>
@@ -181,7 +181,7 @@ const CoverScene: React.FC<{
             {keyframes.map((kf, i) => (
               <div key={i} style={{
                 fontSize: 22, color: "rgba(255,255,255,0.5)",
-                fontFamily: "sans-serif",
+                fontFamily: "Noto Sans SC",
               }}>
                 {kf.content}
               </div>
@@ -212,7 +212,7 @@ const OutroScene: React.FC<{
       }}>
         <div style={{
           fontSize: 48, fontWeight: 700, color: "white",
-          fontFamily: "sans-serif", lineHeight: 1.4, marginBottom: 40,
+          fontFamily: "Noto Sans SC", lineHeight: 1.4, marginBottom: 40,
           maxWidth: "80%",
         }}>
           {text}
@@ -220,7 +220,7 @@ const OutroScene: React.FC<{
         {cta && (
           <div style={{
             fontSize: 28, fontWeight: 600, color: "#fbbf24",
-            fontFamily: "sans-serif",
+            fontFamily: "Noto Sans SC",
             padding: "16px 48px",
             borderRadius: 12,
             border: "2px solid #fbbf24",
@@ -314,6 +314,54 @@ const defaultProps: RemotionProps = {
 };
 
 export const RemotionRoot: React.FC = () => {
+  // Load bundled Noto Sans SC at component mount (staticFile() requires the
+  // bundle context which is only ready after the component tree mounts).
+  const [fontHandle] = useState(() => delayRender("Loading Noto Sans SC"));
+  useEffect(() => {
+    let cancelled = false;
+    try {
+      const style = document.createElement("style");
+      style.textContent = `
+        @font-face {
+          font-family: "Noto Sans SC";
+          src: local("Noto Sans CJK SC"),
+               url(${staticFile("fonts/NotoSansSC-Regular.ttf")}) format("truetype");
+          font-weight: 400;
+          font-display: block;
+        }
+        @font-face {
+          font-family: "Noto Sans SC";
+          src: local("Noto Sans CJK SC"),
+               url(${staticFile("fonts/NotoSansSC-Bold.ttf")}) format("truetype");
+          font-weight: 700;
+          font-display: block;
+        }
+      `;
+      document.head.appendChild(style);
+
+      const fonts = document.fonts as unknown as {
+        load: (font: string) => Promise<unknown>;
+      };
+      Promise.all([
+        fonts.load('400 16px "Noto Sans SC"'),
+        fonts.load('700 16px "Noto Sans SC"'),
+      ])
+        .then(() => {
+          if (!cancelled) continueRender(fontHandle);
+        })
+        .catch((err) => {
+          console.error("Font loading failed:", err);
+          if (!cancelled) continueRender(fontHandle);
+        });
+    } catch (err) {
+      console.error("Font init failed:", err);
+      continueRender(fontHandle);
+    }
+    return () => {
+      cancelled = true;
+    };
+  }, [fontHandle]);
+
   return (
     <>
       {TEMPLATES.map((template) =>

+ 1 - 1
packages/templates/src/base/components/animated-text.tsx

@@ -54,7 +54,7 @@ export const AnimatedText: React.FC<AnimatedTextProps> = ({
         fontWeight,
         color,
         textAlign: align,
-        fontFamily: "sans-serif",
+        fontFamily: "Noto Sans SC",
         opacity,
         transform: `translateY(${translateY}px)`,
         lineHeight: 1.3,

+ 2 - 2
packages/templates/src/base/components/lower-third.tsx

@@ -48,7 +48,7 @@ export const LowerThird: React.FC<LowerThirdProps> = ({
           fontSize: 36,
           fontWeight: 700,
           color: "white",
-          fontFamily: "sans-serif",
+          fontFamily: "Noto Sans SC",
           borderTopRightRadius: 8,
           borderBottomRightRadius: 8,
         }}
@@ -63,7 +63,7 @@ export const LowerThird: React.FC<LowerThirdProps> = ({
             padding: "10px 40px 10px 60px",
             fontSize: 24,
             color: "rgba(255,255,255,0.8)",
-            fontFamily: "sans-serif",
+            fontFamily: "Noto Sans SC",
             borderTopRightRadius: 8,
             borderBottomRightRadius: 8,
           }}

+ 1 - 1
packages/templates/src/base/components/subtitle-bar.tsx

@@ -86,7 +86,7 @@ export const SubtitleBar: React.FC<SubtitleBarProps> = ({
             color: "#ffffff",
             fontSize: 40,
             lineHeight: 1.6,
-            fontFamily: "sans-serif",
+            fontFamily: "Noto Sans SC",
             display: "-webkit-box",
             WebkitLineClamp: 2,
             WebkitBoxOrient: "vertical",

+ 1 - 1
packages/templates/src/base/components/watermark.tsx

@@ -25,7 +25,7 @@ export const Watermark: React.FC<WatermarkProps> = ({
         fontWeight: 600,
         color: "white",
         opacity: fadeIn,
-        fontFamily: "sans-serif",
+        fontFamily: "Noto Sans SC",
         letterSpacing: 2,
         textTransform: "uppercase",
       }}

+ 2 - 2
packages/templates/src/knowledge/components/key-point-card.tsx

@@ -70,7 +70,7 @@ const KeyPointItem: React.FC<{
           fontSize: 16,
           fontWeight: 700,
           color,
-          fontFamily: "sans-serif",
+          fontFamily: "Noto Sans SC",
           flexShrink: 0,
         }}
       >
@@ -80,7 +80,7 @@ const KeyPointItem: React.FC<{
         style={{
           fontSize: 24,
           color: "white",
-          fontFamily: "sans-serif",
+          fontFamily: "Noto Sans SC",
           lineHeight: 1.4,
         }}
       >

+ 1 - 1
packages/templates/src/knowledge/index.tsx

@@ -74,7 +74,7 @@ const KnowledgeScene: React.FC<KnowledgeSceneProps> = ({
           right: 80,
           fontSize: 18,
           color: "#94a3b8",
-          fontFamily: "sans-serif",
+          fontFamily: "Noto Sans SC",
         }}
       >
         {title}

+ 3 - 3
packages/templates/src/marketing/components/cta-overlay.tsx

@@ -39,7 +39,7 @@ export const CTAOverlay: React.FC<CTAOverlayProps> = ({
           fontSize: 56,
           fontWeight: 800,
           color: "white",
-          fontFamily: "sans-serif",
+          fontFamily: "Noto Sans SC",
           textAlign: "center",
           lineHeight: 1.2,
           textShadow: `0 0 40px ${color}66`,
@@ -53,7 +53,7 @@ export const CTAOverlay: React.FC<CTAOverlayProps> = ({
           style={{
             fontSize: 24,
             color: "rgba(255,255,255,0.7)",
-            fontFamily: "sans-serif",
+            fontFamily: "Noto Sans SC",
           }}
         >
           {subtitle}
@@ -70,7 +70,7 @@ export const CTAOverlay: React.FC<CTAOverlayProps> = ({
           fontSize: 24,
           fontWeight: 700,
           color: "white",
-          fontFamily: "sans-serif",
+          fontFamily: "Noto Sans SC",
           boxShadow: `0 8px 32px ${color}55`,
         }}
       >

+ 2 - 2
packages/templates/src/marketing/components/product-showcase.tsx

@@ -70,7 +70,7 @@ const FeatureItem: React.FC<{
           fontSize: 18,
           fontWeight: 800,
           color: "white",
-          fontFamily: "sans-serif",
+          fontFamily: "Noto Sans SC",
           flexShrink: 0,
         }}
       >
@@ -80,7 +80,7 @@ const FeatureItem: React.FC<{
         style={{
           fontSize: 22,
           color: "white",
-          fontFamily: "sans-serif",
+          fontFamily: "Noto Sans SC",
           lineHeight: 1.3,
         }}
       >

+ 1 - 1
packages/templates/src/marketing/index.tsx

@@ -79,7 +79,7 @@ const MarketingScene: React.FC<MarketingSceneProps> = ({
           fontSize: 20,
           fontWeight: 700,
           color: "rgba(255,255,255,0.3)",
-          fontFamily: "sans-serif",
+          fontFamily: "Noto Sans SC",
           letterSpacing: 4,
           textTransform: "uppercase",
         }}

+ 2 - 2
packages/templates/src/news/components/headline-card.tsx

@@ -43,7 +43,7 @@ export const HeadlineCard: React.FC<HeadlineCardProps> = ({
           fontSize: 28,
           fontWeight: 700,
           color: "white",
-          fontFamily: "sans-serif",
+          fontFamily: "Noto Sans SC",
           lineHeight: 1.4,
         }}
       >
@@ -54,7 +54,7 @@ export const HeadlineCard: React.FC<HeadlineCardProps> = ({
           style={{
             fontSize: 20,
             color: "#94a3b8",
-            fontFamily: "sans-serif",
+            fontFamily: "Noto Sans SC",
             marginTop: 8,
           }}
         >

+ 2 - 2
packages/templates/src/news/components/news-ticker.tsx

@@ -41,7 +41,7 @@ export const NewsTicker: React.FC<NewsTickerProps> = ({
             fontSize: 22,
             fontWeight: 600,
             color: "white",
-            fontFamily: "sans-serif",
+            fontFamily: "Noto Sans SC",
             paddingRight: 100,
           }}
         >
@@ -52,7 +52,7 @@ export const NewsTicker: React.FC<NewsTickerProps> = ({
             fontSize: 22,
             fontWeight: 600,
             color: "white",
-            fontFamily: "sans-serif",
+            fontFamily: "Noto Sans SC",
             paddingRight: 100,
           }}
         >

+ 1 - 1
packages/templates/src/news/components/source-citation.tsx

@@ -20,7 +20,7 @@ export const SourceCitation: React.FC<SourceCitationProps> = ({ source }) => {
         right: 40,
         fontSize: 16,
         color: "#94a3b8",
-        fontFamily: "sans-serif",
+        fontFamily: "Noto Sans SC",
         fontStyle: "italic",
       }}
     >

+ 4 - 4
packages/templates/src/news/index.tsx

@@ -74,7 +74,7 @@ const NewsScene: React.FC<NewsSceneProps> = ({
               padding: "8px 20px",
               fontSize: 20,
               fontWeight: 800,
-              fontFamily: "sans-serif",
+              fontFamily: "Noto Sans SC",
               borderRadius: 4,
               letterSpacing: 2,
             }}
@@ -85,7 +85,7 @@ const NewsScene: React.FC<NewsSceneProps> = ({
             style={{
               fontSize: 20,
               color: "#94a3b8",
-              fontFamily: "sans-serif",
+              fontFamily: "Noto Sans SC",
             }}
           >
             {title}
@@ -115,7 +115,7 @@ const NewsScene: React.FC<NewsSceneProps> = ({
                 fontSize: visualText.length > 60 ? 40 : 64,
                 fontWeight: 800,
                 color: "white",
-                fontFamily: "sans-serif",
+                fontFamily: "Noto Sans SC",
                 lineHeight: 1.2,
                 marginBottom: 24,
                 maxHeight: 260,
@@ -143,7 +143,7 @@ const NewsScene: React.FC<NewsSceneProps> = ({
                 fontSize: visualText.length > 60 ? 30 : 42,
                 fontWeight: 700,
                 color: "white",
-                fontFamily: "sans-serif",
+                fontFamily: "Noto Sans SC",
                 marginBottom: 32,
                 lineHeight: 1.3,
                 maxHeight: 200,

+ 3 - 3
packages/templates/src/opinion/components/author-card.tsx

@@ -41,7 +41,7 @@ export const AuthorCard: React.FC<AuthorCardProps> = ({ name, role }) => {
           fontSize: 22,
           fontWeight: 700,
           color: "white",
-          fontFamily: "sans-serif",
+          fontFamily: "Noto Sans SC",
         }}
       >
         {name[0]}
@@ -52,7 +52,7 @@ export const AuthorCard: React.FC<AuthorCardProps> = ({ name, role }) => {
             fontSize: 20,
             fontWeight: 600,
             color: "white",
-            fontFamily: "sans-serif",
+            fontFamily: "Noto Sans SC",
           }}
         >
           {name}
@@ -62,7 +62,7 @@ export const AuthorCard: React.FC<AuthorCardProps> = ({ name, role }) => {
             style={{
               fontSize: 16,
               color: "#9ca3af",
-              fontFamily: "sans-serif",
+              fontFamily: "Noto Sans SC",
             }}
           >
             {role}

+ 1 - 1
packages/templates/src/opinion/components/quote-block.tsx

@@ -85,7 +85,7 @@ export const QuoteBlock: React.FC<QuoteBlockProps> = ({
             marginTop: 20,
             fontSize: 22,
             color: "#9ca3af",
-            fontFamily: "sans-serif",
+            fontFamily: "Noto Sans SC",
           }}
         >
           — {author}

+ 1 - 1
packages/templates/src/opinion/index.tsx

@@ -73,7 +73,7 @@ const OpinionScene: React.FC<OpinionSceneProps> = ({
           style={{
             fontSize: 18,
             color: "#9ca3af",
-            fontFamily: "sans-serif",
+            fontFamily: "Noto Sans SC",
             letterSpacing: 1,
           }}
         >