> = {
+ youtube: YoutubeFilled,
+ instagram: InstagramFilled,
+ facebook: FacebookFilled,
+ globe: GlobeFilled,
+ tiktok: TiktokFilled,
+};
+
+interface ChannelCardProps {
+ option: ChannelFormatOption;
+ isSelected: boolean;
+ onSelect: (channel: StudioChannel | null) => void;
+}
+
+export function ChannelCard({ option, isSelected, onSelect }: ChannelCardProps) {
+ const Icon = iconMap[option.icon] ?? GlobeFilled;
+
+ return (
+
+ );
+}
diff --git a/src/features/studio/ui/channelFormat/ChannelSelectGrid.tsx b/src/features/studio/ui/channelFormat/ChannelSelectGrid.tsx
new file mode 100644
index 0000000..d557dde
--- /dev/null
+++ b/src/features/studio/ui/channelFormat/ChannelSelectGrid.tsx
@@ -0,0 +1,28 @@
+import type { StudioChannel } from "@/features/studio/types/studio";
+import { MOCK_CHANNEL_OPTIONS } from "@/features/studio/mocks/channels";
+import { ChannelCard } from "@/features/studio/ui/channelFormat/ChannelCard";
+
+interface ChannelSelectGridProps {
+ selectedChannel: StudioChannel | null;
+ onChannelSelect: (channel: StudioChannel | null) => void;
+}
+
+export function ChannelSelectGrid({ selectedChannel, onChannelSelect }: ChannelSelectGridProps) {
+ return (
+
+
채널 선택
+
콘텐츠를 게시할 채널을 선택하세요
+
+
+ {MOCK_CHANNEL_OPTIONS.map((option) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/features/studio/ui/channelFormat/FormatCard.tsx b/src/features/studio/ui/channelFormat/FormatCard.tsx
new file mode 100644
index 0000000..36b89ff
--- /dev/null
+++ b/src/features/studio/ui/channelFormat/FormatCard.tsx
@@ -0,0 +1,45 @@
+import type { ContentFormat } from "@/features/studio/types/studio";
+import { AspectRatioPreview } from "@/features/studio/ui/channelFormat/AspectRatioPreview";
+
+interface FormatCardProps {
+ fmt: {
+ key: ContentFormat;
+ label: string;
+ aspectRatio: "16:9" | "9:16" | "1:1" | "4:5";
+ };
+ isSelected: boolean;
+ onSelect: (format: ContentFormat | null) => void;
+}
+
+export function FormatCard({ fmt, isSelected, onSelect }: FormatCardProps) {
+ return (
+
+ );
+}
diff --git a/src/features/studio/ui/channelFormat/FormatSelectGrid.tsx b/src/features/studio/ui/channelFormat/FormatSelectGrid.tsx
new file mode 100644
index 0000000..9cc807f
--- /dev/null
+++ b/src/features/studio/ui/channelFormat/FormatSelectGrid.tsx
@@ -0,0 +1,30 @@
+import type { ChannelFormatOption, ContentFormat } from "@/features/studio/types/studio";
+import { FormatCard } from "@/features/studio/ui/channelFormat/FormatCard";
+
+interface FormatSelectGridProps {
+ option: ChannelFormatOption;
+ selectedFormat: ContentFormat | null;
+ onFormatSelect: (format: ContentFormat | null) => void;
+}
+
+export function FormatSelectGrid({ option, selectedFormat, onFormatSelect }: FormatSelectGridProps) {
+ return (
+
+
포맷 선택
+
+ {option.label}에 적합한 콘텐츠 포맷을 선택하세요
+
+
+
+ {option.formats.map((fmt) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/features/studio/ui/generate/OutputTypeTabs.tsx b/src/features/studio/ui/generate/OutputTypeTabs.tsx
new file mode 100644
index 0000000..6a97fa4
--- /dev/null
+++ b/src/features/studio/ui/generate/OutputTypeTabs.tsx
@@ -0,0 +1,38 @@
+import type { GenerateOutputType } from "@/features/studio/types/studio";
+import { FileTextFilled, VideoFilled } from "@/components/icons/FilledIcons";
+
+interface OutputTypeTabsProps {
+ outputType: GenerateOutputType;
+ onOutputTypeChange: (type: GenerateOutputType) => void;
+}
+
+const TABS = [
+ { key: "image" as const, label: "이미지 생성", Icon: FileTextFilled },
+ { key: "video" as const, label: "영상 생성", Icon: VideoFilled },
+];
+
+export function OutputTypeTabs({ outputType, onOutputTypeChange }: OutputTypeTabsProps) {
+ return (
+
+ {TABS.map((tab) => (
+
+ ))}
+
+ );
+}
diff --git a/src/features/studio/ui/generate/PreviewArea.tsx b/src/features/studio/ui/generate/PreviewArea.tsx
new file mode 100644
index 0000000..dd90460
--- /dev/null
+++ b/src/features/studio/ui/generate/PreviewArea.tsx
@@ -0,0 +1,112 @@
+import { motion } from "motion/react";
+import type { GenerateOutputType } from "@/features/studio/types/studio";
+import { FileTextFilled, VideoFilled } from "@/components/icons/FilledIcons";
+import type { GenerateResult } from "@/features/studio/services/generateImage";
+
+type GenerateStatus = "idle" | "generating" | "done" | "error";
+
+interface PreviewAreaProps {
+ status: GenerateStatus;
+ result: GenerateResult | null;
+ errorMsg: string;
+ outputType: GenerateOutputType;
+ channelLabel: string;
+ formatLabel: string;
+ aspectRatio: string;
+ aspectClass: string;
+}
+
+export function PreviewArea({
+ status,
+ result,
+ errorMsg,
+ outputType,
+ channelLabel,
+ formatLabel,
+ aspectRatio,
+ aspectClass,
+}: PreviewAreaProps) {
+ return (
+
+
프리뷰
+
+
+ {/* Idle */}
+ {status === "idle" && (
+
+
+ {outputType === "image" ? (
+
+ ) : (
+
+ )}
+
+
설정을 확인하고 생성 버튼을 눌러주세요
+
+ )}
+
+ {/* Generating */}
+ {status === "generating" && (
+
+
+
+ {outputType === "image" ? "이미지" : "영상"} 생성 중...
+
+
AI가 콘텐츠를 제작하고 있습니다
+
+ )}
+
+ {/* Error */}
+ {status === "error" && (
+
+ )}
+
+ {/* Done — with image */}
+ {status === "done" && result?.imageDataUrl && (
+
+ )}
+
+ {/* Done — video placeholder */}
+ {status === "done" && !result?.imageDataUrl && (
+
+
+ 생성 완료
+
+ {channelLabel} {formatLabel}
+ {outputType === "image" ? " 이미지" : " 영상"}이 준비되었습니다
+
+
+ {aspectRatio} | {outputType === "image" ? "PNG" : "MP4"}
+
+
+ )}
+
+
+ );
+}
diff --git a/src/features/studio/ui/generate/SettingsSummary.tsx b/src/features/studio/ui/generate/SettingsSummary.tsx
new file mode 100644
index 0000000..8748eae
--- /dev/null
+++ b/src/features/studio/ui/generate/SettingsSummary.tsx
@@ -0,0 +1,104 @@
+import type { GenerateOutputType } from "@/features/studio/types/studio";
+import { FileTextFilled, VideoFilled } from "@/components/icons/FilledIcons";
+import type { GenerateResult } from "@/features/studio/services/generateImage";
+
+type GenerateStatus = "idle" | "generating" | "done" | "error";
+
+interface SettingsSummaryProps {
+ channelLabel: string;
+ formatLabel: string;
+ aspectRatio: string;
+ trackName: string;
+ narrationValue: string;
+ subtitleValue: string;
+ outputType: GenerateOutputType;
+ status: GenerateStatus;
+ result: GenerateResult | null;
+ errorMsg: string;
+ onGenerate: () => void;
+ onReset: () => void;
+ onDownload: () => void;
+}
+
+function SummaryRow({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+export function SettingsSummary({
+ channelLabel,
+ formatLabel,
+ aspectRatio,
+ trackName,
+ narrationValue,
+ subtitleValue,
+ outputType,
+ status,
+ result,
+ errorMsg,
+ onGenerate,
+ onReset,
+ onDownload,
+}: SettingsSummaryProps) {
+ return (
+
+
설정 요약
+
+
+
+
+
+
+
+
+
+
+ {/* Generate / Retry button */}
+ {(status === "idle" || status === "error") && (
+
+ )}
+
+ {/* Error message */}
+ {status === "error" && (
+
{errorMsg}
+ )}
+
+ {/* Done actions */}
+ {status === "done" && (
+
+ {result?.imageDataUrl && (
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/src/features/studio/ui/index.ts b/src/features/studio/ui/index.ts
new file mode 100644
index 0000000..82e20fa
--- /dev/null
+++ b/src/features/studio/ui/index.ts
@@ -0,0 +1,14 @@
+// ─── features/studio/ui barrel export ──────────────────────────────────────
+export { StudioWizardProvider } from "@/features/studio/ui/StudioWizardProvider";
+export { StudioProgressBar } from "@/features/studio/ui/StudioProgressBar";
+
+// ── 위저드 공용 섹션
+export { StudioWizardHeaderSection } from "@/features/studio/ui/StudioWizardHeaderSection";
+export { StudioWizardFooterSection } from "@/features/studio/ui/StudioWizardFooterSection";
+
+// ── 스텝 섹션
+export { StudioChannelFormatSection } from "@/features/studio/ui/StudioChannelFormatSection";
+export { StudioStrategySourceSection } from "@/features/studio/ui/StudioStrategySourceSection";
+export { StudioSoundSection } from "@/features/studio/ui/StudioSoundSection";
+export { StudioGenerateSection } from "@/features/studio/ui/StudioGenerateSection";
+export { StudioBlogEditorSection } from "@/features/studio/ui/StudioBlogEditorSection";
diff --git a/src/features/studio/ui/sound/GenreChipList.tsx b/src/features/studio/ui/sound/GenreChipList.tsx
new file mode 100644
index 0000000..ce7e980
--- /dev/null
+++ b/src/features/studio/ui/sound/GenreChipList.tsx
@@ -0,0 +1,29 @@
+import type { MusicGenre } from "@/features/studio/types/studio";
+import { GENRE_OPTIONS } from "@/features/studio/content/sound";
+
+interface GenreChipListProps {
+ selectedGenre: MusicGenre;
+ onGenreChange: (genre: MusicGenre) => void;
+}
+
+export function GenreChipList({ selectedGenre, onGenreChange }: GenreChipListProps) {
+ return (
+
+ {GENRE_OPTIONS.map((g) => (
+
+ ))}
+
+ );
+}
diff --git a/src/features/studio/ui/sound/NarrationSettings.tsx b/src/features/studio/ui/sound/NarrationSettings.tsx
new file mode 100644
index 0000000..1e525fe
--- /dev/null
+++ b/src/features/studio/ui/sound/NarrationSettings.tsx
@@ -0,0 +1,78 @@
+import type { NarrationLanguage, NarrationVoice } from "@/features/studio/types/studio";
+import { LANGUAGE_OPTIONS } from "@/features/studio/content/sound";
+import { MessageFilled } from "@/components/icons/FilledIcons";
+
+interface NarrationSettingsProps {
+ language: NarrationLanguage;
+ voice: NarrationVoice;
+ onLanguageChange: (lang: NarrationLanguage) => void;
+ onVoiceChange: (voice: NarrationVoice) => void;
+}
+
+const VOICE_OPTIONS: { key: NarrationVoice; label: string }[] = [
+ { key: "female", label: "Female" },
+ { key: "male", label: "Male" },
+];
+
+export function NarrationSettings({
+ language,
+ voice,
+ onLanguageChange,
+ onVoiceChange,
+}: NarrationSettingsProps) {
+ return (
+
+
+ {/* 언어 선택 */}
+
+
언어
+
+ {LANGUAGE_OPTIONS.map((lang) => (
+
+ ))}
+
+
+
+ {/* 보이스 선택 */}
+
+
보이스
+
+ {VOICE_OPTIONS.map((v) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/features/studio/ui/sound/ToggleSwitch.tsx b/src/features/studio/ui/sound/ToggleSwitch.tsx
new file mode 100644
index 0000000..aa65073
--- /dev/null
+++ b/src/features/studio/ui/sound/ToggleSwitch.tsx
@@ -0,0 +1,26 @@
+interface ToggleSwitchProps {
+ enabled: boolean;
+ onToggle: (next: boolean) => void;
+ label: string;
+}
+
+export function ToggleSwitch({ enabled, onToggle, label }: ToggleSwitchProps) {
+ return (
+
+ );
+}
diff --git a/src/features/studio/ui/sound/TrackCard.tsx b/src/features/studio/ui/sound/TrackCard.tsx
new file mode 100644
index 0000000..3b0b116
--- /dev/null
+++ b/src/features/studio/ui/sound/TrackCard.tsx
@@ -0,0 +1,40 @@
+import type { MusicTrack } from "@/features/studio/types/studio";
+import { MusicFilled } from "@/components/icons/FilledIcons";
+
+interface TrackCardProps {
+ track: MusicTrack;
+ isSelected: boolean;
+ onSelect: (trackId: string | null) => void;
+}
+
+export function TrackCard({ track, isSelected, onSelect }: TrackCardProps) {
+ return (
+
+ );
+}
diff --git a/src/features/studio/ui/sound/TrackSelectGrid.tsx b/src/features/studio/ui/sound/TrackSelectGrid.tsx
new file mode 100644
index 0000000..cc94d66
--- /dev/null
+++ b/src/features/studio/ui/sound/TrackSelectGrid.tsx
@@ -0,0 +1,28 @@
+import type { MusicGenre } from "@/features/studio/types/studio";
+import { MOCK_MUSIC_TRACKS } from "@/features/studio/mocks/musicTracks";
+import { TrackCard } from "@/features/studio/ui/sound/TrackCard";
+
+interface TrackSelectGridProps {
+ genre: MusicGenre;
+ selectedTrackId: string | null;
+ onTrackChange: (trackId: string | null) => void;
+}
+
+export function TrackSelectGrid({ genre, selectedTrackId, onTrackChange }: TrackSelectGridProps) {
+ if (genre === "none") return null;
+
+ const tracks = MOCK_MUSIC_TRACKS.filter((t) => t.genre === genre);
+
+ return (
+
+ {tracks.map((track) => (
+
+ ))}
+
+ );
+}
diff --git a/src/features/studio/ui/strategySource/PillarCard.tsx b/src/features/studio/ui/strategySource/PillarCard.tsx
new file mode 100644
index 0000000..6e2ccb4
--- /dev/null
+++ b/src/features/studio/ui/strategySource/PillarCard.tsx
@@ -0,0 +1,43 @@
+import type { ContentPillar, ContentPillarId } from "@/features/studio/types/studio";
+
+interface PillarCardProps {
+ pillar: ContentPillar;
+ isSelected: boolean;
+ onSelect: (id: ContentPillarId) => void;
+}
+
+export function PillarCard({ pillar, isSelected, onSelect }: PillarCardProps) {
+ return (
+
+ );
+}
diff --git a/src/features/studio/ui/strategySource/PillarSelectList.tsx b/src/features/studio/ui/strategySource/PillarSelectList.tsx
new file mode 100644
index 0000000..e30f347
--- /dev/null
+++ b/src/features/studio/ui/strategySource/PillarSelectList.tsx
@@ -0,0 +1,28 @@
+import type { ContentPillarId } from "@/features/studio/types/studio";
+import { MOCK_CONTENT_PILLARS } from "@/features/studio/mocks/contentPillars";
+import { PillarCard } from "@/features/studio/ui/strategySource/PillarCard";
+
+interface PillarSelectListProps {
+ selectedPillarId: ContentPillarId | null;
+ onPillarSelect: (id: ContentPillarId) => void;
+}
+
+export function PillarSelectList({ selectedPillarId, onPillarSelect }: PillarSelectListProps) {
+ return (
+
+
콘텐츠 전략
+
콘텐츠의 핵심 메시지 필러를 선택하세요
+
+
+ {MOCK_CONTENT_PILLARS.map((pillar) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/features/studio/ui/strategySource/SourceCard.tsx b/src/features/studio/ui/strategySource/SourceCard.tsx
new file mode 100644
index 0000000..e7bffb7
--- /dev/null
+++ b/src/features/studio/ui/strategySource/SourceCard.tsx
@@ -0,0 +1,47 @@
+import type { AssetSourceType } from "@/features/studio/types/studio";
+
+interface SourceCardProps {
+ source: {
+ key: AssetSourceType;
+ title: string;
+ description: string;
+ };
+ isSelected: boolean;
+ onToggle: (source: AssetSourceType) => void;
+}
+
+export function SourceCard({ source, isSelected, onToggle }: SourceCardProps) {
+ return (
+
+ );
+}
diff --git a/src/features/studio/ui/strategySource/SourceSelectList.tsx b/src/features/studio/ui/strategySource/SourceSelectList.tsx
new file mode 100644
index 0000000..2c1b4e9
--- /dev/null
+++ b/src/features/studio/ui/strategySource/SourceSelectList.tsx
@@ -0,0 +1,33 @@
+import type { AssetSourceType } from "@/features/studio/types/studio";
+import { SourceCard } from "@/features/studio/ui/strategySource/SourceCard";
+
+const ASSET_SOURCE_OPTIONS: { key: AssetSourceType; title: string; description: string }[] = [
+ { key: "collected", title: "수집된 에셋", description: "홈페이지, 블로그, SNS에서 수집한 기존 에셋" },
+ { key: "my_assets", title: "My Assets", description: "직접 업로드한 이미지, 영상, 텍스트 파일" },
+ { key: "ai_generated", title: "AI 생성", description: "AI가 새로 생성하는 이미지, 텍스트, 영상" },
+];
+
+interface SourceSelectListProps {
+ selectedSources: AssetSourceType[];
+ onSourceToggle: (source: AssetSourceType) => void;
+}
+
+export function SourceSelectList({ selectedSources, onSourceToggle }: SourceSelectListProps) {
+ return (
+
+
소스 선택
+
콘텐츠에 사용할 에셋 소스를 선택하세요 (복수 선택)
+
+
+ {ASSET_SOURCE_OPTIONS.map((source) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/layouts/PageNavigator.tsx b/src/layouts/PageNavigator.tsx
index e815e48..15459b5 100644
--- a/src/layouts/PageNavigator.tsx
+++ b/src/layouts/PageNavigator.tsx
@@ -30,7 +30,7 @@ const PAGE_FLOW: FlowStep[] = [
navigatePath: DEFAULT_PLAN_NAV_PATH,
isActive: (p) => p === "/plan" || p.startsWith("/plan/"),
},
- { id: "studio", label: "콘텐츠 제작", navigatePath: "/studio", isActive: (p) => p === "/studio" },
+ { id: "studio", label: "콘텐츠 제작", navigatePath: "/studio/demo", isActive: (p) => p === "/studio" || p.startsWith("/studio/") },
{ id: "channels", label: "채널 연결", navigatePath: "/channels", isActive: (p) => p === "/channels" },
{
id: "distribute",
diff --git a/src/pages/Studio.tsx b/src/pages/Studio.tsx
new file mode 100644
index 0000000..c94198b
--- /dev/null
+++ b/src/pages/Studio.tsx
@@ -0,0 +1,18 @@
+import { StudioWizardProvider } from "@/features/studio/ui/StudioWizardProvider";
+import { StudioWizardHeaderSection } from "@/features/studio/ui/StudioWizardHeaderSection";
+import { StudioWizardFooterSection } from "@/features/studio/ui/StudioWizardFooterSection";
+import { StudioStepAnimator } from "@/features/studio/ui/StudioStepAnimator";
+
+export function StudioPage() {
+ return (
+
+
+
+ );
+}