← GPT-AI-VIDEO
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 || "同じ型を別テーマで再検証"
  };
}