CODE · 9.8 KB
src/main.jsx
Workspace snapshot · 09/04 14:52
import React, { useEffect, useMemo, useState } from "react";
import { createRoot } from "react-dom/client";
import { buildPublicReport, createProject, creativePatterns, outputFormats, remainingBudget } from "./domain.js";
import { loadProjects, storeProjects } from "./storage.js";
import { videoProviders } from "./providers.js";
import sourceInventory from "../knowledge/source-inventory.json";
import Journey from "./Journey.jsx";
import "./styles.css";
const brands = [
["takumi", "Takumi Ueki", "個人の思想・実験・技術発信"],
["ai-dea", "AIディア", "AIを試し、結果と学びを伝える"],
["pengin", "PENGIN", "Web制作とクリエイティブ"],
["uncity", "UnCity", "事業と技術を実装する"],
["avs", "Agent Venture Studio", "AI経営の実験記録"]
];
const firstOwnedStory = {
title: "1,645件のAIセッションを捨てていた",
sourceType: "source-bank",
sourceText: "Claude 200セッション、Codex 1,445セッション、PENGINのMeet/Geminiメモ100件以上。AIを日常業務で使いながら、動画部署はこの一次資産を見ず、外部Provider比較だけを続けて完成動画0本だった。競争力は動画モデルではなく、自社にしかない判断・失敗・制作の履歴にある。",
sourceRefs: ["claude-sessions", "codex-sessions", "pengin-drive-meet", "pengin-official-site", "pengin-youtube"],
sourceClassification: "INTERNAL_DERIVATIVE_ONLY",
objective: "Agent Venture Studioが、自社の眠っている仕事の記録を次の事業と発信へ変える実験であることを30秒で伝える",
brandId: "avs",
formatId: "shorts",
patternId: "motion-editorial",
provider: "flux-3-draft"
};
function App() {
const [projects, setProjects] = useState(loadProjects);
const [selectedId, setSelectedId] = useState(projects[0]?.id || null);
const [input, setInput] = useState({ title: "", sourceType: "source-bank", sourceText: "", sourceRefs: [], sourceClassification: "INTERNAL_DERIVATIVE_ONLY", objective: "", brandId: "avs", formatId: "shorts", patternId: "motion-editorial", provider: "flux-3-draft" });
const [providerStatus, setProviderStatus] = useState({ state: "checking", engines: {} });
const budget = useMemo(() => remainingBudget(projects), [projects]);
const selected = projects.find((item) => item.id === selectedId);
const save = (next) => { setProjects(next); storeProjects(next); };
const create = () => {
if (!input.sourceText.trim()) return;
const project = createProject(input);
save([project, ...projects]); setSelectedId(project.id); setInput((current) => ({ ...current, title: "", sourceText: "", sourceRefs: [], objective: "" }));
};
const report = selected ? buildPublicReport(selected) : null;
useEffect(() => {
let active = true;
fetch("/video-api/health").then((response) => response.json()).then((data) => {
if (active) setProviderStatus({ state: data.ok ? "connected" : "error", engines: data.engines || {} });
}).catch(() => active && setProviderStatus({ state: "offline", engines: {} }));
return () => { active = false; };
}, []);
return <main>
<header><div><span className="serial">AVL—01 / PRIVATE R&D</span><h1>Owned Story<br/><em>Video Lab.</em></h1><p>外部AIのデモではなく、植木拓海とPENGINが積み上げた判断・会議・実装・発信を、独自の映像資産へ変える。</p><span className={`provider-state ${providerStatus.state}`}>{providerStatus.state === "checking" ? "VIDEO ENGINE CHECKING" : providerStatus.state === "connected" ? `ROUTER READY / VERTEX ${providerStatus.engines.vertex?.configured ? "ON" : "OFF"} / FAL ${providerStatus.engines.fal?.configured ? "ON" : "OFF"}` : "VIDEO ENGINE OFFLINE"}</span></div><div className="budget"><span>EXPERIMENT BUDGET</span><strong>¥{budget.available.toLocaleString("ja-JP")}</strong><small>¥50,000 − 使用 ¥{budget.spent.toLocaleString("ja-JP")} − 予約 ¥{budget.reserved.toLocaleString("ja-JP")}</small><i style={{ width: `${Math.max(0, budget.available / 500)}%` }}/></div></header>
<nav><button className="active">01 BRIEF</button><button>02 STORYBOARD</button><button>03 GENERATE</button><button>04 QUALITY GATE</button><button>05 REPORT</button></nav>
<div className="workspace"><section className="brief"><span className="kicker">SOURCE FIRST</span><h2>何を、動画にする?</h2><div className="source-bank"><div><b>{sourceInventory.sources.filter((item) => item.status === "INDEXED").reduce((sum, item) => sum + (item.files || 0), 0).toLocaleString("ja-JP")}</b><span>ローカル一次資産</span></div><div><b>100+</b><span>Meet / Geminiメモ</span></div><div><b>{sourceInventory.sources.length}</b><span>登録ソース群</span></div><button onClick={() => setInput({ ...firstOwnedStory })}>第一作を読み込む</button></div><p className="source-note">セッション・会議・案件資料は原文を公開しません。植木拓海自身の判断と学びだけを匿名化して使います。</p><div className="form"><label>タイトル<input value={input.title} onChange={(event) => setInput({ ...input, title: event.target.value })} placeholder="例:AI Agentを8日間動かして分かったこと"/></label><label>素材の種類<select value={input.sourceType} onChange={(event) => setInput({ ...input, sourceType: event.target.value })}><option value="source-bank">自社ストーリーバンク</option><option value="claude-codex">Claude / Codexセッション</option><option value="meet">Meet / 議事録</option><option value="chat">チャット履歴</option><option value="pengin-public">PENGIN公式サイト / YouTube</option><option value="local-assets">ローカル案件資産</option><option value="x">X投稿</option><option value="note">note / AIディア記事</option><option value="transcript">撮影動画の文字起こし</option><option value="service">サービス資料</option></select></label><label className="wide">原稿・素材<textarea rows="9" value={input.sourceText} onChange={(event) => setInput({ ...input, sourceText: event.target.value })} placeholder="素材台帳から選んだ根拠、判断、変化を入れます。一般論だけの企画は作りません。"/></label><label className="wide">今回の目的<input value={input.objective} onChange={(event) => setInput({ ...input, objective: event.target.value })} placeholder="例:noteへ遷移してもらう / AIディアの認知を増やす"/></label></div>
<h3>Brand Voice</h3><div className="choices brands">{brands.map(([id,label,description]) => <button key={id} className={input.brandId === id ? "selected" : ""} onClick={() => setInput({ ...input, brandId:id })}><b>{label}</b><small>{description}</small></button>)}</div>
<h3>Output</h3><div className="choices formats">{outputFormats.map((item) => <button key={item.id} className={input.formatId === item.id ? "selected" : ""} onClick={() => setInput({ ...input, formatId:item.id, patternId:item.recommendedPattern })}><b>{item.label}</b><small>{item.duration}s / {item.ratio}</small></button>)}</div>
<h3>Creative Direction</h3><div className="patterns">{creativePatterns.map((item) => <button key={item.id} className={input.patternId === item.id ? "selected" : ""} onClick={() => setInput({ ...input, patternId:item.id })}><span>{item.label}</span><p>{item.description}</p><small>生成映像 約{item.generatedSeconds}秒</small></button>)}</div>
<div className="provider"><label>動画Provider<select value={input.provider} onChange={(event) => setInput({ ...input, provider:event.target.value })}>{videoProviders.map((provider) => <option key={provider.id} value={provider.id}>{provider.label} / 約¥{provider.yenPerSecond}/秒</option>)}</select></label><p>{videoProviders.find((provider) => provider.id === input.provider)?.role}。生成費は見せ場と仕上げへ使います。</p></div><button className="create" disabled={!input.sourceText.trim()} onClick={create}>制作ブリーフを作る →</button></section>
<aside><span className="kicker">PRODUCTION QUEUE</span><h2>{projects.length} Projects</h2>{projects.length === 0 && <p className="empty">まだ制作はありません。最初の原稿を入れると、原価予約と実験記録を開始します。</p>}{projects.map((project) => <button className={selectedId === project.id ? "project selected" : "project"} key={project.id} onClick={() => setSelectedId(project.id)}><span>{project.status}</span><b>{project.title}</b><small>{outputFormats.find((item) => item.id === project.formatId)?.label} / 予定 ¥{project.estimate.estimatedTotalYen.toLocaleString("ja-JP")}</small></button>)}
{selected && <div className="inspector"><span className="kicker">SELECTED</span><h3>{selected.title}</h3><dl><dt>型</dt><dd>{creativePatterns.find((item) => item.id === selected.patternId)?.label}</dd><dt>Provider</dt><dd>{selected.provider}</dd><dt>予定原価</dt><dd>¥{selected.estimate.estimatedTotalYen.toLocaleString("ja-JP")}</dd><dt>画像費</dt><dd>¥0 / GPT subscription</dd></dl><h4>公開レポートに残る項目</h4><pre>{JSON.stringify(report, null, 2)}</pre><p className={providerStatus.engines.vertex?.configured || providerStatus.engines.fal?.configured ? "engine-ready" : "offline"}>{providerStatus.engines.vertex?.configured || providerStatus.engines.fal?.configured ? "利用可能なProviderがあります。絵コンテと支出Approval後に生成します。" : "ローカルAPIは接続確認できますが、Provider認証前に生成したふりはしません。"}</p></div>}
</aside></div>
<footer><span>OWNED STORY VIDEO LAB</span><span>SOURCE BEFORE PROVIDER</span><span>PRIVATE UNTIL APPROVED</span></footer>
</main>;
}
createRoot(document.getElementById("root")).render(
["localhost", "127.0.0.1"].includes(window.location.hostname) && window.location.pathname.startsWith("/lab")
? <App />
: <Journey />
);