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 ( + <> + + {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 ( +
+
{label}
+
{value}
+
+ ); +} 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()}`; + }, + }, + }, + }, + }; +});