CODE · 5.4 KB
src/domain.js
Workspace snapshot · 09/04 14:52
import { findVideoProvider } from "./providers.js";
export const BUDGET_LIMIT_YEN = 50000;
export const PROJECT_LIMIT_YEN = 2500;
export const outputFormats = [
{ id: "x-teaser", label: "X Teaser", platform: "X", duration: 10, ratio: "16:9", recommendedPattern: "generated-hook" },
{ id: "shorts", label: "YouTube Shorts", platform: "YouTube", duration: 45, ratio: "9:16", recommendedPattern: "motion-editorial" },
{ id: "reels", label: "Instagram Reels", platform: "Instagram", duration: 30, ratio: "9:16", recommendedPattern: "generated-hook" },
{ id: "article-video", label: "note / AIディア誘導", platform: "Web", duration: 40, ratio: "9:16", recommendedPattern: "motion-editorial" },
{ id: "youtube-assets", label: "YouTube撮影素材", platform: "YouTube", duration: 15, ratio: "16:9", recommendedPattern: "product-proof" }
];
export const creativePatterns = [
{ id: "motion-editorial", label: "Motion Editorial", generatedSeconds: 0, baseCostYen: 120, description: "タイポグラフィ、図解、コラージュで主張を見せる" },
{ id: "generated-hook", label: "Generated Hook", generatedSeconds: 8, baseCostYen: 850, description: "冒頭の生成映像で止め、文字と証拠で具体化する" },
{ id: "product-proof", label: "Product Proof", generatedSeconds: 4, baseCostYen: 460, description: "実画面、結果、Before / Afterを中心に見せる" },
{ id: "recorded-insight", label: "Recorded Insight", generatedSeconds: 3, baseCostYen: 340, description: "撮影素材へ字幕、図解、Bロールを加える" }
];
export const qualityDimensions = [
["hook", "冒頭の停止力", 15],
["clarity", "主張の明瞭さ", 15],
["originality", "視覚的独自性", 15],
["brand", "ブランド一貫性", 10],
["motion", "モーションの自然さ", 10],
["audio", "音・ナレーション", 10],
["platform", "媒体適合", 10],
["cta", "CTAと次行動", 5],
["technical", "技術品質", 10]
];
export function estimateProjectCost({ patternId, provider = "flux-3-draft", retryFactor = 2 }) {
const pattern = creativePatterns.find((item) => item.id === patternId) ?? creativePatterns[0];
const providerPerSecondYen = findVideoProvider(provider).yenPerSecond;
const videoGenerationYen = Math.ceil(pattern.generatedSeconds * providerPerSecondYen * retryFactor);
return {
imageYen: 0,
compositionYen: pattern.baseCostYen,
videoGenerationYen,
estimatedTotalYen: pattern.baseCostYen + videoGenerationYen,
withinProjectLimit: pattern.baseCostYen + videoGenerationYen <= PROJECT_LIMIT_YEN
};
}
export function scoreQuality(review = {}) {
const dimensions = Object.fromEntries(qualityDimensions.map(([id, label, max]) => {
const value = Math.max(0, Math.min(max, Number(review[id] || 0)));
return [id, { label, max, value }];
}));
const score = Object.values(dimensions).reduce((sum, item) => sum + item.value, 0);
const blockers = Array.isArray(review.blockers) ? review.blockers.filter(Boolean) : [];
return { score, dimensions, blockers, passed: score >= 80 && blockers.length === 0 };
}
export function createProject(input, now = Date.now()) {
const format = outputFormats.find((item) => item.id === input.formatId) ?? outputFormats[1];
const patternId = input.patternId || format.recommendedPattern;
const estimate = estimateProjectCost({ patternId, provider: input.provider });
return {
id: crypto.randomUUID(),
title: String(input.title || "名称未設定").trim().slice(0, 80),
sourceType: input.sourceType || "idea",
sourceText: String(input.sourceText || "").trim().slice(0, 20000),
sourceRefs: Array.isArray(input.sourceRefs) ? input.sourceRefs.filter(Boolean).slice(0, 30) : [],
sourceClassification: input.sourceClassification || "INTERNAL_DERIVATIVE_ONLY",
objective: String(input.objective || "").trim().slice(0, 500),
brandId: input.brandId || "takumi",
formatId: format.id,
patternId,
provider: input.provider || "flux-3-draft",
status: "BRIEF_READY",
estimate,
quality: null,
actualCostYen: 0,
createdAt: now,
updatedAt: now,
events: [{ at: now, type: "project.created", summary: "制作ブリーフを作成" }]
};
}
export function remainingBudget(projects = []) {
const spent = projects.reduce((sum, item) => sum + Number(item.actualCostYen || 0), 0);
const reserved = projects.filter((item) => ["BRIEF_READY", "STORYBOARD_READY", "GENERATING"].includes(item.status)).reduce((sum, item) => sum + Number(item.estimate?.estimatedTotalYen || 0), 0);
return { budget: BUDGET_LIMIT_YEN, spent, reserved, available: BUDGET_LIMIT_YEN - spent - reserved };
}
export function buildPublicReport(project) {
const quality = project.quality ? scoreQuality(project.quality) : null;
return {
title: `${project.title} — AI動画制作実験`,
summary: "ひとつの発信テーマを、AIだけで短尺動画へ変換した制作記録です。",
format: outputFormats.find((item) => item.id === project.formatId)?.label,
pattern: creativePatterns.find((item) => item.id === project.patternId)?.label,
provider: project.provider,
estimatedCostYen: project.estimate?.estimatedTotalYen,
actualCostYen: project.actualCostYen,
qualityScore: quality?.score ?? null,
passedQualityGate: quality?.passed ?? false,
learning: project.publicLearning || "公開後に追記",
nextExperiment: project.nextExperiment || "同じ型を別テーマで再検証"
};
}