Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/media-os/.env.example
Original file line number Diff line number Diff line change
@@ -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=
12 changes: 12 additions & 0 deletions packages/media-os/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Media OS Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
23 changes: 23 additions & 0 deletions packages/media-os/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
40 changes: 40 additions & 0 deletions packages/media-os/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
style={{
fontFamily: "system-ui, sans-serif",
padding: "24px",
background: "#f4f7fb",
minHeight: "100vh",
}}
>
<h1>Media OS Dashboard</h1>
<p>Independent dashboard layer for HyperFrames-based video operations.</p>

<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: "16px",
marginTop: "24px",
}}
>
<StatCard label="今日文章数量" value={dashboard.todayPosts} />
<StatCard label="今日生成视频数量" value={dashboard.todayVideos} />
<StatCard label="AI状态" value={dashboard.aiStatus} />
<StatCard label="渲染状态" value={dashboard.gpuStatus} />
</div>

<h2 style={{ marginTop: "32px" }}>文章中心</h2>
<PostList posts={summary} loading={loading} error={error} onGenerated={refreshTodayVideos} />
</div>
);
}

export default App;
19 changes: 19 additions & 0 deletions packages/media-os/src/components/ActionPlanPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { ArticleAction } from "../lib/content";

export function ActionPlanPanel({ actions }: { actions: ArticleAction[] }) {
return (
<div style={{ display: "grid", gap: "8px" }}>
{actions.map((action) => (
<div
key={action.id}
style={{ border: "1px solid #e2e8f0", borderRadius: "8px", padding: "10px" }}
>
<div style={{ fontWeight: 600 }}>{action.label}</div>
<div style={{ color: "#64748b", fontSize: "13px", marginTop: "4px" }}>
{action.preview}
</div>
</div>
))}
</div>
);
}
24 changes: 24 additions & 0 deletions packages/media-os/src/components/GenerateVideoButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<button type="button" onClick={handleGenerateVideo} disabled={isGenerating}>
{isGenerating ? "生成中…" : "立即生成视频"}
</button>
{result && (
<div style={{ color: result.ok ? "#16a34a" : "crimson", fontSize: "13px", width: "100%" }}>
{result.message}
</div>
)}
</>
);
}
37 changes: 37 additions & 0 deletions packages/media-os/src/components/PostCard.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof summarizePosts>[number];

export function PostCard({
post,
isActive,
actions,
onToggleActions,
onGenerated,
}: {
post: Post;
isActive: boolean;
actions: ArticleAction[];
onToggleActions: () => void;
onGenerated: () => void;
}) {
return (
<div style={{ ...cardStyle, boxShadow: "0 8px 20px rgba(0,0,0,0.05)" }}>
<div style={{ fontWeight: 700, marginBottom: "8px" }}>{post.title}</div>
<div style={{ color: "#475569", marginBottom: "8px" }}>{post.excerpt || "暂无摘要"}</div>
<div style={{ color: "#64748b", fontSize: "12px", marginBottom: "8px" }}>
{post.date || "未知日期"}
</div>
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap", marginBottom: "12px" }}>
<button type="button" onClick={onToggleActions}>
查看生成动作
</button>
<GenerateVideoButton postId={post.id} onGenerated={onGenerated} />
</div>
{isActive && <ActionPlanPanel actions={actions} />}
</div>
);
}
39 changes: 39 additions & 0 deletions packages/media-os/src/components/PostList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useState } from "react";
import { buildActionPlan, type summarizePosts } from "../lib/content";
import { PostCard } from "./PostCard";

type Post = ReturnType<typeof summarizePosts>[number];

export function PostList({
posts,
loading,
error,
onGenerated,
}: {
posts: Post[];
loading: boolean;
error: string | null;
onGenerated: () => void;
}) {
const [activePostId, setActivePostId] = useState<number | null>(null);
const activePost = posts.find((post) => post.id === activePostId);
const activeActions = activePost ? buildActionPlan(activePost.title) : [];

if (loading) return <p>正在读取最新文章…</p>;
if (error) return <p style={{ color: "crimson" }}>读取失败: {error}</p>;

return (
<div style={{ display: "grid", gap: "12px", marginTop: "16px" }}>
{posts.map((post) => (
<PostCard
key={post.id}
post={post}
isActive={activePostId === post.id}
actions={activePostId === post.id ? activeActions : []}
onToggleActions={() => setActivePostId(post.id)}
onGenerated={onGenerated}
/>
))}
</div>
);
}
10 changes: 10 additions & 0 deletions packages/media-os/src/components/StatCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { cardStyle } from "./styles";

export function StatCard({ label, value }: { label: string; value: string | number }) {
return (
<div style={cardStyle}>
<div style={{ color: "#64748b", fontSize: "14px" }}>{label}</div>
<div style={{ fontSize: "28px", fontWeight: 700 }}>{value}</div>
</div>
);
}
8 changes: 8 additions & 0 deletions packages/media-os/src/components/styles.ts
Original file line number Diff line number Diff line change
@@ -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)",
};
143 changes: 143 additions & 0 deletions packages/media-os/src/lib/content.ts
Original file line number Diff line number Diff line change
@@ -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<GenerateVideoResult> {
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}”生成一张适合视频封面的视觉提案。`,
},
];
}
Loading