diff --git a/packages/media-os/.env.example b/packages/media-os/.env.example
new file mode 100644
index 000000000..2fa7ed38e
--- /dev/null
+++ b/packages/media-os/.env.example
@@ -0,0 +1,8 @@
+# Copy to .env.local and fill in — read server-side by vite.config.ts's dev
+# proxy, never exposed to the client bundle (no VITE_ prefix, on purpose).
+
+# video.kuaibaoguang.cn base URL (no trailing slash)
+VIDEO_API_BASE=https://video.kuaibaoguang.cn
+
+# Must match HF_BRIDGE_TOKEN in video.kuaibaoguang.cn/.env
+VIDEO_API_TOKEN=
diff --git a/packages/media-os/index.html b/packages/media-os/index.html
new file mode 100644
index 000000000..0b44d50fd
--- /dev/null
+++ b/packages/media-os/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Media OS Dashboard
+
+
+
+
+
+
diff --git a/packages/media-os/package.json b/packages/media-os/package.json
new file mode 100644
index 000000000..6ff918554
--- /dev/null
+++ b/packages/media-os/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "@hyperframes/media-os",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite --host 0.0.0.0 --port 5191",
+ "build": "vite build",
+ "test": "vitest run"
+ },
+ "dependencies": {
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "@vitejs/plugin-react": "^4.0.0",
+ "typescript": "^5.0.0",
+ "vite": "^6.4.2",
+ "vitest": "^3.2.4"
+ }
+}
diff --git a/packages/media-os/src/App.tsx b/packages/media-os/src/App.tsx
new file mode 100644
index 000000000..ba2887466
--- /dev/null
+++ b/packages/media-os/src/App.tsx
@@ -0,0 +1,40 @@
+import { PostList } from "./components/PostList";
+import { StatCard } from "./components/StatCard";
+import { useMediaOsData } from "./lib/hooks";
+
+function App() {
+ const { summary, dashboard, loading, error, refreshTodayVideos } = useMediaOsData();
+
+ return (
+
+
Media OS Dashboard
+
Independent dashboard layer for HyperFrames-based video operations.
+
+
+
+
+
+
+
+
+
文章中心
+
+
+ );
+}
+
+export default App;
diff --git a/packages/media-os/src/components/ActionPlanPanel.tsx b/packages/media-os/src/components/ActionPlanPanel.tsx
new file mode 100644
index 000000000..7ccb605ab
--- /dev/null
+++ b/packages/media-os/src/components/ActionPlanPanel.tsx
@@ -0,0 +1,19 @@
+import type { ArticleAction } from "../lib/content";
+
+export function ActionPlanPanel({ actions }: { actions: ArticleAction[] }) {
+ return (
+
+ {actions.map((action) => (
+
+
{action.label}
+
+ {action.preview}
+
+
+ ))}
+
+ );
+}
diff --git a/packages/media-os/src/components/GenerateVideoButton.tsx b/packages/media-os/src/components/GenerateVideoButton.tsx
new file mode 100644
index 000000000..178207eca
--- /dev/null
+++ b/packages/media-os/src/components/GenerateVideoButton.tsx
@@ -0,0 +1,24 @@
+import { useGenerateVideo } from "../lib/hooks";
+
+export function GenerateVideoButton({
+ postId,
+ onGenerated,
+}: {
+ postId: number;
+ onGenerated: () => void;
+}) {
+ const { isGenerating, result, handleGenerateVideo } = useGenerateVideo(postId, onGenerated);
+
+ return (
+ <>
+
+ {isGenerating ? "生成中…" : "立即生成视频"}
+
+ {result && (
+
+ {result.message}
+
+ )}
+ >
+ );
+}
diff --git a/packages/media-os/src/components/PostCard.tsx b/packages/media-os/src/components/PostCard.tsx
new file mode 100644
index 000000000..2b89f4b46
--- /dev/null
+++ b/packages/media-os/src/components/PostCard.tsx
@@ -0,0 +1,37 @@
+import type { ArticleAction, summarizePosts } from "../lib/content";
+import { ActionPlanPanel } from "./ActionPlanPanel";
+import { GenerateVideoButton } from "./GenerateVideoButton";
+import { cardStyle } from "./styles";
+
+type Post = ReturnType[number];
+
+export function PostCard({
+ post,
+ isActive,
+ actions,
+ onToggleActions,
+ onGenerated,
+}: {
+ post: Post;
+ isActive: boolean;
+ actions: ArticleAction[];
+ onToggleActions: () => void;
+ onGenerated: () => void;
+}) {
+ return (
+
+
{post.title}
+
{post.excerpt || "暂无摘要"}
+
+ {post.date || "未知日期"}
+
+
+
+ 查看生成动作
+
+
+
+ {isActive &&
}
+
+ );
+}
diff --git a/packages/media-os/src/components/PostList.tsx b/packages/media-os/src/components/PostList.tsx
new file mode 100644
index 000000000..1387dacd5
--- /dev/null
+++ b/packages/media-os/src/components/PostList.tsx
@@ -0,0 +1,39 @@
+import { useState } from "react";
+import { buildActionPlan, type summarizePosts } from "../lib/content";
+import { PostCard } from "./PostCard";
+
+type Post = ReturnType[number];
+
+export function PostList({
+ posts,
+ loading,
+ error,
+ onGenerated,
+}: {
+ posts: Post[];
+ loading: boolean;
+ error: string | null;
+ onGenerated: () => void;
+}) {
+ const [activePostId, setActivePostId] = useState(null);
+ const activePost = posts.find((post) => post.id === activePostId);
+ const activeActions = activePost ? buildActionPlan(activePost.title) : [];
+
+ if (loading) return 正在读取最新文章…
;
+ if (error) return 读取失败: {error}
;
+
+ return (
+
+ {posts.map((post) => (
+
setActivePostId(post.id)}
+ onGenerated={onGenerated}
+ />
+ ))}
+
+ );
+}
diff --git a/packages/media-os/src/components/StatCard.tsx b/packages/media-os/src/components/StatCard.tsx
new file mode 100644
index 000000000..0f49ce9a7
--- /dev/null
+++ b/packages/media-os/src/components/StatCard.tsx
@@ -0,0 +1,10 @@
+import { cardStyle } from "./styles";
+
+export function StatCard({ label, value }: { label: string; value: string | number }) {
+ return (
+
+ );
+}
diff --git a/packages/media-os/src/components/styles.ts b/packages/media-os/src/components/styles.ts
new file mode 100644
index 000000000..44d2bc8c7
--- /dev/null
+++ b/packages/media-os/src/components/styles.ts
@@ -0,0 +1,8 @@
+import type { CSSProperties } from "react";
+
+export const cardStyle: CSSProperties = {
+ background: "#fff",
+ padding: "16px",
+ borderRadius: "12px",
+ boxShadow: "0 10px 30px rgba(0,0,0,0.06)",
+};
diff --git a/packages/media-os/src/lib/content.ts b/packages/media-os/src/lib/content.ts
new file mode 100644
index 000000000..420b91e8a
--- /dev/null
+++ b/packages/media-os/src/lib/content.ts
@@ -0,0 +1,143 @@
+export type WordPressPost = {
+ id: number;
+ title?: { rendered?: string };
+ excerpt?: { rendered?: string };
+ link?: string;
+ date?: string;
+};
+
+export type DashboardSummary = {
+ todayPosts: number;
+ todayVideos: number;
+ aiStatus: string;
+ gpuStatus: string;
+};
+
+export type ArticleAction = {
+ id: string;
+ label: string;
+ preview: string;
+};
+
+// Proxied by vite.config.ts's dev server -> video.kuaibaoguang.cn/api/video-dashboard.php
+// (token injected server-side, see .env.example).
+export const VIDEO_API = "/video-api";
+
+// Shape of video.kuaibaoguang.cn's api/video-dashboard.php?action=system
+// response (only the fields this dashboard reads).
+export type VideoSystemStatus = {
+ currently_rendering: { task_id: number; post_id: number } | null;
+ last_hour?: { total: number; success_rate_pct: number | null };
+};
+
+// There is no GPU in this pipeline — rendering is ffmpeg/edge-tts on CPU,
+// text via the Claude API. "currently_rendering" is the closest real signal
+// for "is the render engine busy right now".
+export function deriveRenderStatus(status: VideoSystemStatus | null): string {
+ if (!status) return "未知";
+ return status.currently_rendering ? "渲染中" : "空闲";
+}
+
+// Below 50% success in the last hour mirrors health-check.php's own
+// checkSuccessRate() threshold, so this reads the same as the ops alerting.
+export function deriveAiStatus(status: VideoSystemStatus | null): string {
+ if (!status) return "离线";
+ const rate = status.last_hour?.success_rate_pct;
+ if (rate !== null && rate !== undefined && rate < 50) return "降级";
+ return "在线";
+}
+
+export function getTodayPostCount(posts: WordPressPost[]): number {
+ const today = new Date();
+ const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate());
+
+ return posts.filter((post) => {
+ if (!post.date) return false;
+ const postDate = new Date(post.date);
+ return postDate >= startOfToday;
+ }).length;
+}
+
+export function summarizePosts(posts: WordPressPost[]) {
+ return posts.map((post) => ({
+ id: post.id,
+ title: post.title?.rendered ?? "Untitled",
+ excerpt:
+ post.excerpt?.rendered
+ ?.replace(/<[^>]+>/g, " ")
+ .replace(/\s+/g, " ")
+ .trim() ?? "",
+ link: post.link ?? "",
+ date: post.date ?? "",
+ }));
+}
+
+export function buildDashboardSummary(
+ posts: WordPressPost[],
+ todayVideos: number,
+ aiStatus: string,
+ gpuStatus: string,
+): DashboardSummary {
+ return {
+ todayPosts: getTodayPostCount(posts),
+ todayVideos,
+ aiStatus,
+ gpuStatus,
+ };
+}
+
+export type GenerateVideoResult = { ok: boolean; message: string };
+
+// action=generate runs synchronously on the server (AI content + ffmpeg
+// render, ~4 minutes) and returns the final result in one response — there
+// is no separate job-status endpoint to poll, so the caller just awaits this.
+// Params travel as query string (matching the API's own doc comment,
+// `POST ?action=generate&post_id=123`), not a JSON body — video-dashboard.php
+// reads $_REQUEST, which a JSON body would never populate.
+export async function generateVideo(
+ postId: number,
+ fetchImpl: typeof fetch = fetch,
+): Promise {
+ try {
+ const res = await fetchImpl(`${VIDEO_API}?action=generate&post_id=${postId}`, {
+ method: "POST",
+ });
+ const data = await res.json();
+
+ if (!res.ok || !data.ok) {
+ return { ok: false, message: (data && data.error) || `请求失败 (HTTP ${res.status})` };
+ }
+ if (data.skipped) {
+ return { ok: true, message: `该文章已有生成任务(${data.reason ?? "跳过"})` };
+ }
+ return { ok: true, message: "视频生成完成,已进入待审核队列" };
+ } catch (err) {
+ return { ok: false, message: err instanceof Error ? err.message : "网络请求失败" };
+ }
+}
+
+export function buildActionPlan(title: string): ArticleAction[] {
+ const safeTitle = title || "未命名文章";
+ return [
+ {
+ id: "script",
+ label: "AI生成脚本",
+ preview: `为“${safeTitle}”生成一段适合短视频的脚本。`,
+ },
+ {
+ id: "voice",
+ label: "AI生成旁白",
+ preview: `为“${safeTitle}”生成自然流畅的旁白文案。`,
+ },
+ {
+ id: "title",
+ label: "AI生成标题",
+ preview: `为“${safeTitle}”生成更适合社媒传播的标题。`,
+ },
+ {
+ id: "cover",
+ label: "AI生成封面",
+ preview: `为“${safeTitle}”生成一张适合视频封面的视觉提案。`,
+ },
+ ];
+}
diff --git a/packages/media-os/src/lib/hooks.ts b/packages/media-os/src/lib/hooks.ts
new file mode 100644
index 000000000..8f605e24c
--- /dev/null
+++ b/packages/media-os/src/lib/hooks.ts
@@ -0,0 +1,98 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import {
+ buildDashboardSummary,
+ deriveAiStatus,
+ deriveRenderStatus,
+ generateVideo,
+ summarizePosts,
+ VIDEO_API,
+ type GenerateVideoResult,
+ type VideoSystemStatus,
+ type WordPressPost,
+} from "./content";
+
+const WORDPRESS_API = "https://kuaibaoguang.cn/wp-json/wp/v2/posts?per_page=10";
+
+function todayDateString(): string {
+ const now = new Date();
+ const pad = (n: number) => String(n).padStart(2, "0");
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
+}
+
+function useWordPressPosts() {
+ const [posts, setPosts] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ fetch(WORDPRESS_API)
+ .then(async (res) => {
+ if (!res.ok) throw new Error(`Request failed: ${res.status}`);
+ return res.json();
+ })
+ .then((data) => setPosts(data as WordPressPost[]))
+ .catch((err) => setError(err instanceof Error ? err.message : "Unknown error"))
+ .finally(() => setLoading(false));
+ }, []);
+
+ return { posts, loading, error };
+}
+
+function useVideoPipelineStatus() {
+ const [todayVideos, setTodayVideos] = useState(0);
+ const [systemStatus, setSystemStatus] = useState(null);
+
+ const refreshTodayVideos = useCallback(() => {
+ fetch(`${VIDEO_API}?action=list&date=${todayDateString()}&limit=1`)
+ .then((res) => (res.ok ? res.json() : Promise.reject(new Error(`HTTP ${res.status}`))))
+ .then((data) => setTodayVideos(typeof data.total === "number" ? data.total : 0))
+ .catch(() => setTodayVideos(0));
+ }, []);
+
+ useEffect(() => {
+ refreshTodayVideos();
+ fetch(`${VIDEO_API}?action=system`)
+ .then((res) => (res.ok ? res.json() : Promise.reject(new Error(`HTTP ${res.status}`))))
+ .then((data) => setSystemStatus(data as VideoSystemStatus))
+ .catch(() => setSystemStatus(null));
+ }, [refreshTodayVideos]);
+
+ return { todayVideos, systemStatus, refreshTodayVideos };
+}
+
+// Combines the WordPress article feed with video.kuaibaoguang.cn's live
+// pipeline status into the dashboard's stat tiles + article summaries.
+export function useMediaOsData() {
+ const { posts, loading, error } = useWordPressPosts();
+ const { todayVideos, systemStatus, refreshTodayVideos } = useVideoPipelineStatus();
+
+ const summary = useMemo(() => summarizePosts(posts), [posts]);
+ const dashboard = useMemo(
+ () =>
+ buildDashboardSummary(
+ posts,
+ todayVideos,
+ deriveAiStatus(systemStatus),
+ deriveRenderStatus(systemStatus),
+ ),
+ [posts, todayVideos, systemStatus],
+ );
+
+ return { summary, dashboard, loading, error, refreshTodayVideos };
+}
+
+export function useGenerateVideo(postId: number, onGenerated: () => void) {
+ const [isGenerating, setIsGenerating] = useState(false);
+ const [result, setResult] = useState(null);
+
+ const handleGenerateVideo = useCallback(async () => {
+ setIsGenerating(true);
+ setResult(null);
+ const outcome = await generateVideo(postId);
+ setResult(outcome);
+ setIsGenerating(false);
+ if (outcome.ok) onGenerated();
+ }, [postId, onGenerated]);
+
+ return { isGenerating, result, handleGenerateVideo };
+}
diff --git a/packages/media-os/src/main.tsx b/packages/media-os/src/main.tsx
new file mode 100644
index 000000000..385492962
--- /dev/null
+++ b/packages/media-os/src/main.tsx
@@ -0,0 +1,9 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import App from "./App";
+
+ReactDOM.createRoot(document.getElementById("root")!).render(
+
+
+ ,
+);
diff --git a/packages/media-os/tests/content.test.ts b/packages/media-os/tests/content.test.ts
new file mode 100644
index 000000000..f4776a4a4
--- /dev/null
+++ b/packages/media-os/tests/content.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from "vitest";
+import { getTodayPostCount, summarizePosts } from "../src/lib/content";
+
+describe("content helpers", () => {
+ it("counts posts from today", () => {
+ const now = new Date();
+ const posts = [
+ { date: now.toISOString() },
+ { date: new Date(now.getTime() - 86400000).toISOString() },
+ ];
+
+ expect(getTodayPostCount(posts as any)).toBe(1);
+ });
+
+ it("summarizes posts with clean display values", () => {
+ const posts = [
+ {
+ title: { rendered: "Hello Media OS" },
+ excerpt: { rendered: "Short summary
" },
+ link: "https://example.com/post/1",
+ },
+ ];
+
+ const result = summarizePosts(posts as any);
+
+ expect(result[0].title).toBe("Hello Media OS");
+ expect(result[0].excerpt).toContain("Short summary");
+ expect(result[0].link).toBe("https://example.com/post/1");
+ });
+});
diff --git a/packages/media-os/tests/dashboard.test.ts b/packages/media-os/tests/dashboard.test.ts
new file mode 100644
index 000000000..d52230fc6
--- /dev/null
+++ b/packages/media-os/tests/dashboard.test.ts
@@ -0,0 +1,99 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildDashboardSummary,
+ buildActionPlan,
+ deriveAiStatus,
+ deriveRenderStatus,
+ generateVideo,
+} from "../src/lib/content";
+
+function fakeFetch(body: unknown, status = 200): typeof fetch {
+ return (async () => new Response(JSON.stringify(body), { status })) as unknown as typeof fetch;
+}
+
+describe("dashboard helpers", () => {
+ it("builds a dashboard summary from posts and generation counts", () => {
+ const summary = buildDashboardSummary(
+ [{ id: 1, date: new Date().toISOString() }] as any,
+ 3,
+ "Online",
+ "Ready",
+ );
+
+ expect(summary.todayPosts).toBe(1);
+ expect(summary.todayVideos).toBe(3);
+ expect(summary.aiStatus).toBe("Online");
+ expect(summary.gpuStatus).toBe("Ready");
+ });
+
+ it("creates action cards for a selected article", () => {
+ const plan = buildActionPlan("东南亚快曝光 2026 主题");
+
+ expect(plan).toHaveLength(4);
+ expect(plan[0].label).toBe("AI生成脚本");
+ expect(plan[0].preview).toContain("东南亚快曝光 2026 主题");
+ });
+});
+
+describe("video system status derivation", () => {
+ it("reports unknown/offline when the status endpoint could not be reached", () => {
+ expect(deriveRenderStatus(null)).toBe("未知");
+ expect(deriveAiStatus(null)).toBe("离线");
+ });
+
+ it("reports rendering vs idle based on currently_rendering", () => {
+ expect(deriveRenderStatus({ currently_rendering: { task_id: 1, post_id: 2 } })).toBe("渲染中");
+ expect(deriveRenderStatus({ currently_rendering: null })).toBe("空闲");
+ });
+
+ it("reports degraded when the last-hour success rate drops below 50%", () => {
+ expect(
+ deriveAiStatus({ currently_rendering: null, last_hour: { total: 4, success_rate_pct: 25 } }),
+ ).toBe("降级");
+ expect(
+ deriveAiStatus({ currently_rendering: null, last_hour: { total: 4, success_rate_pct: 75 } }),
+ ).toBe("在线");
+ expect(
+ deriveAiStatus({
+ currently_rendering: null,
+ last_hour: { total: 0, success_rate_pct: null },
+ }),
+ ).toBe("在线");
+ });
+});
+
+describe("generateVideo", () => {
+ it("reports success when the render completes", async () => {
+ const result = await generateVideo(123, fakeFetch({ ok: true, task_id: 1, video_id: "v1" }));
+ expect(result.ok).toBe(true);
+ expect(result.message).toContain("待审核");
+ });
+
+ it("reports the existing-task reason when the server skips generation", async () => {
+ const result = await generateVideo(
+ 123,
+ fakeFetch({ ok: true, skipped: true, reason: "already has a processing task" }),
+ );
+ expect(result.ok).toBe(true);
+ expect(result.message).toContain("already has a processing task");
+ });
+
+ it("surfaces the server error message on failure", async () => {
+ const result = await generateVideo(
+ 123,
+ fakeFetch({ ok: false, error: "content generation failed" }, 500),
+ );
+ expect(result.ok).toBe(false);
+ expect(result.message).toBe("content generation failed");
+ });
+
+ it("reports failure when the request itself throws", async () => {
+ const throwingFetch = (async () => {
+ throw new Error("network down");
+ }) as unknown as typeof fetch;
+
+ const result = await generateVideo(123, throwingFetch);
+ expect(result.ok).toBe(false);
+ expect(result.message).toBe("network down");
+ });
+});
diff --git a/packages/media-os/tsconfig.json b/packages/media-os/tsconfig.json
new file mode 100644
index 000000000..53d970a6d
--- /dev/null
+++ b/packages/media-os/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "Node",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx"
+ },
+ "include": ["src"],
+ "references": []
+}
diff --git a/packages/media-os/vite.config.ts b/packages/media-os/vite.config.ts
new file mode 100644
index 000000000..10c36fe0d
--- /dev/null
+++ b/packages/media-os/vite.config.ts
@@ -0,0 +1,34 @@
+import { defineConfig, loadEnv } from "vite";
+import react from "@vitejs/plugin-react";
+
+// video.kuaibaoguang.cn's api/video-dashboard.php is shared-secret gated
+// (?token=...) and sends no CORS headers, so the browser can't call it
+// directly from this dev server's origin without baking the token into the
+// client bundle. Proxying through Vite keeps VIDEO_API_TOKEN server-side —
+// see .env.example over there for where that token comes from.
+export default defineConfig(({ mode }) => {
+ const env = loadEnv(mode, process.cwd(), "");
+ const videoApiBase = env.VIDEO_API_BASE || "https://video.kuaibaoguang.cn";
+ const videoApiToken = env.VIDEO_API_TOKEN || "";
+
+ return {
+ plugins: [react()],
+ server: {
+ host: "0.0.0.0",
+ port: 5191,
+ proxy: {
+ "/video-api": {
+ target: videoApiBase,
+ changeOrigin: true,
+ secure: true,
+ rewrite: (path: string) => {
+ const [, query = ""] = path.split("?");
+ const params = new URLSearchParams(query);
+ params.set("token", videoApiToken);
+ return `/api/video-dashboard.php?${params.toString()}`;
+ },
+ },
+ },
+ },
+ };
+});