다중 이미지 업로드 & 본문에 이미지 삽입
이번 편에서 완성되는 것
- 다중 이미지 업로드(갤러리에서 여러 장 선택) → 서버 저장
- 여러 이미지를 함께 Gemini에 전달하여 통합 아이디어 생성
- 생성되는 글 초안에 이미지(Markdown) 삽입
- 업로드된 이미지를 Flask에서 정적 제공하여 Flutter에서 표시
백엔드(Flask) 변경사항
1) 업로드된 이미지 정적 제공 라우트
# app.py (추가)
from flask import send_from_directory
@app.route('/uploads/<path:filename>')
def serve_upload(filename):
return send_from_directory(app.config['UPLOAD_FOLDER'], filename)
이렇게 하면 http://<HOST>:5000/uploads/<파일명> 형태로 접근 가능.
2) 다중 이미지 업로드 API
# app.py (추가)
from werkzeug.utils import secure_filename
import os
@app.route('/api/upload-images', methods=['POST'])
def upload_images():
# 클라이언트는 "images" 키로 여러 파일 전송
files = request.files.getlist('images')
if not files:
return jsonify({"error": "No files provided"}), 400
saved_paths = []
saved_urls = []
for f in files:
if f and '.' in f.filename:
ext = f.filename.rsplit('.', 1)[1].lower()
if ext not in {'png', 'jpg', 'jpeg'}:
continue
filename = secure_filename(f.filename)
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
f.save(filepath)
saved_paths.append(filepath) # 예: "uploads/xxx.jpg"
# 요청 기반 절대 URL 생성
base = request.host_url.rstrip('/')
saved_urls.append(f"{base}/uploads/{filename}")
if not saved_paths:
return jsonify({"error": "No valid image files"}), 400
return jsonify({
"message": "Images uploaded",
"file_paths": saved_paths,
"file_urls": saved_urls
}), 200
테스트 (curl):
# sample1.jpg, sample2.jpg가 프로젝트 폴더에 있어야 함 curl -X POST http://127.0.0.1:5000/api/upload-images \ -F "images=@sample1.jpg" \ -F "images=@sample2.jpg"
3) 여러 이미지를 Gemini에 전달해 아이디어 생성
# app.py (generate_ideas 수정 또는 신규 /api/generate-ideas-v2 추가 예시)
import google.generativeai as genai
def _guess_mime(path):
ext = path.rsplit('.', 1)[-1].lower()
return "image/png" if ext == "png" else "image/jpeg"
@app.route('/api/generate-ideas', methods=['POST'])
def generate_ideas():
data = request.json or {}
# 단일 or 다중 모두 지원
paths = data.get("image_paths") or []
if not paths:
# 하위호환: image_path 단일 지원
single = data.get("image_path")
if single: paths = [single]
# 유효성
abs_paths = []
for p in paths:
if not os.path.exists(p):
return jsonify({"error": f"Invalid image path: {p}"}), 400
abs_paths.append(p)
# 파일 읽기 → image parts 구성
image_parts = []
for p in abs_paths:
with open(p, "rb") as f:
image_parts.append({"mime_type": _guess_mime(p), "data": f.read()})
# 프롬프트: 여러 이미지를 하나의 주제로 통합
prompt = """
다음의 여러 이미지를 함께 분석해줘.
1) 공통적인 장면/주제/분위기를 요약 설명
2) 핵심 키워드를 5~8개 추출
3) 이 이미지들로 글을 쓴다면 적절한 주제를 5개 제시
출력은 JSON 형식으로:
{
"description": "...",
"keywords": ["...", "..."],
"topics": ["...", "...", "..."]
}
"""
response = model.generate_content([prompt, *image_parts])
return jsonify({"ideas": response.text}), 200
참고: Part 4와 동일하게 {"ideas": "<JSON 문자열>"} 형태로 반환합니다.
4) 글 초안에 이미지 삽입 (Markdown)
# app.py (/api/generate-text 수정)
@app.route('/api/generate-text', methods=['POST'])
def generate_text():
data = request.json or {}
topic = data.get("topic")
category = data.get("category")
keywords = data.get("keywords", [])
image_paths = data.get("image_paths", []) # 선택사항
if not topic or not category:
return jsonify({"error": "Topic and category are required"}), 400
style_map = {
"블로그 게시물": "친근하고 흥미로운 어조로 작성",
"짧은 보고서": "객관적이고 사실적인 어조로 작성",
"아이디어 노트": "간결하고 메모 형식으로 작성",
"시": "문학적이고 감성적인 어조로 작성"
}
style_instruction = style_map.get(category, "자연스럽게 작성")
# 이미지 URL 빌드 (클라이언트에서 표시하기 위한 Markdown)
base = request.host_url.rstrip('/')
image_urls = []
for p in image_paths:
fn = os.path.basename(p)
image_urls.append(f"{base}/uploads/{fn}")
# 모델 프롬프트 (본문 끝에 이미지 마크다운 첨부)
prompt = f"""
주제: "{topic}"
카테고리: "{category}"
키워드: {", ".join(keywords)}
스타일: {style_instruction}
약 150~200단어 내외의 글을 작성하되, 독자가 바로 게시할 수 있도록 자연스러운 문단 구성으로 출력하세요.
가능하다면 이미지와 연결되는 묘사를 포함하세요.
마지막에 아래 형식으로 이미지 마크다운을 나열하세요(있을 때만):
{'\\n'.join([f'' for u in image_urls])}
"""
response = model.generate_content(prompt)
return jsonify({
"topic": topic,
"category": category,
"generated_text": response.text, # 마크다운 포함 가능
"image_urls": image_urls
}), 200
프론트엔드(Flutter) 변경사항
1) pubspec.yaml 업데이트
dependencies: flutter_markdown: ^0.6.18+3 image_picker: ^1.1.2 http: ^1.2.2 provider: ^6.1.2 # (Part 7 설정 그대로 + 필요 패키지 유지)
2) 다중 선택 & 업로드 API
// lib/services/api_service.dart (추가)
Future<Map<String, dynamic>> uploadImages(List<File> files) async {
final uri = Uri.parse("${AppConfig.baseUrl}/api/upload-images");
final req = http.MultipartRequest("POST", uri);
for (final f in files) {
req.files.add(await http.MultipartFile.fromPath("images", f.path));
}
final resp = await req.send();
final body = await resp.stream.bytesToString();
if (resp.statusCode == 200) {
return jsonDecode(body) as Map<String, dynamic>;
} else {
throw Exception("uploadImages failed: $body");
}
}
generateIdeas와 generateText에 배열 입력 지원:
// lib/services/api_service.dart (수정)
Future<Map<String, dynamic>> generateIdeasMulti(List<String> imagePaths) async {
final uri = Uri.parse("${AppConfig.baseUrl}/api/generate-ideas");
final resp = await http.post(
uri,
headers: {"Content-Type": "application/json"},
body: jsonEncode({"image_paths": imagePaths}),
);
if (resp.statusCode == 200) {
final outer = jsonDecode(resp.body);
final ideasRaw = outer["ideas"];
if (ideasRaw is String) return jsonDecode(ideasRaw);
return ideasRaw;
} else {
throw Exception("generate-ideas failed: ${resp.body}");
}
}
Future<String> generateTextWithImages({
required String topic,
required String category,
List<String> keywords = const [],
List<String> imagePaths = const [],
}) async {
final uri = Uri.parse("${AppConfig.baseUrl}/api/generate-text}");
final resp = await http.post(
uri,
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"topic": topic,
"category": category,
"keywords": keywords,
"image_paths": imagePaths,
}),
);
if (resp.statusCode == 200) {
final json = jsonDecode(resp.body);
return (json["generated_text"] as String?)?.trim() ?? "";
} else {
throw Exception("generate-text failed: ${resp.body}");
}
}
3) Provider: 다중 이미지 상태
// lib/providers/idea_provider.dart (핵심 변경)
class IdeaProvider extends ChangeNotifier {
final ApiService api = ApiService();
List<File> selectedImages = [];
List<String> uploadedPaths = []; // e.g., ["uploads/a.jpg","uploads/b.jpg"]
List<String> uploadedUrls = []; // e.g., ["http://.../uploads/a.jpg", ...]
String description = "";
List<String> keywords = [];
List<String> topics = [];
String? selectedTopic;
String category = "블로그 게시물";
String generatedText = "";
bool loading = false;
String? error;
Future<void> setImages(List<File> files) async {
selectedImages = files;
uploadedPaths = [];
uploadedUrls = [];
description = "";
keywords = [];
topics = [];
selectedTopic = null;
generatedText = "";
error = null;
notifyListeners();
}
Future<void> uploadAndFetchIdeasMulti() async {
if (selectedImages.isEmpty) return;
loading = true; error = null; notifyListeners();
try {
final res = await api.uploadImages(selectedImages);
uploadedPaths = (res["file_paths"] as List).map((e) => "$e").toList().cast<String>();
uploadedUrls = (res["file_urls"] as List).map((e) => "$e").toList().cast<String>();
final ideaJson = await api.generateIdeasMulti(uploadedPaths);
description = (ideaJson["description"] as String?) ?? "";
keywords = (ideaJson["keywords"] as List?)?.map((e) => "$e").toList().cast<String>() ?? [];
topics = (ideaJson["topics"] as List?)?.map((e) => "$e").toList().cast<String>() ?? [];
} catch (e) {
error = e.toString();
} finally {
loading = false; notifyListeners();
}
}
Future<void> generateDraftWithImages() async {
if (selectedTopic == null) {
error = "주제를 선택해주세요.";
notifyListeners();
return;
}
loading = true; error = null; notifyListeners();
try {
generatedText = await api.generateTextWithImages(
topic: selectedTopic!,
category: category,
keywords: keywords,
imagePaths: uploadedPaths,
);
} catch (e) {
error = e.toString();
} finally {
loading = false; notifyListeners();
}
}
}
4) Home: 여러 장 선택
// lib/screens/home_screen.dart (갤러리 다중 선택 버튼 추가)
import 'package:image_picker/image_picker.dart';
import 'dart:io';
Future<void> _pickMulti(BuildContext context) async {
final picker = ImagePicker();
final xs = await picker.pickMultiImage(imageQuality: 85);
if (xs.isNotEmpty) {
await context.read<IdeaProvider>().setImages(xs.map((x) => File(x.path)).toList());
// 업로드 & 아이디어 생성은 다음 화면에서 수행
Navigator.push(context, MaterialPageRoute(builder: (_) => const IdeaScreen()));
}
}
// 버튼 예시
OutlinedButton.icon(
icon: const Icon(Icons.collections_outlined),
label: const Text("여러 장 선택"),
onPressed: () => _pickMulti(context),
),
5) IdeaScreen: 썸네일 프리뷰 & 다중 업로드 호출
// lib/screens/idea_screen.dart (initState에서 uploadAndFetchIdeasMulti 호출)
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<IdeaProvider>().uploadAndFetchIdeasMulti();
});
}
// 썸네일 프리뷰(상단에 Grid)
final thumbs = p.uploadedUrls; // 업로드 이후 접근 가능 (업로드 전에는 selectedImages로 로컬 썸네일 표시)
if (thumbs.isNotEmpty) SizedBox(
height: 110,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemBuilder: (_, i) => ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(thumbs[i], height: 100),
),
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemCount: thumbs.length,
),
),
6) ResultScreen: Markdown 렌더링으로 이미지 포함
// lib/screens/result_screen.dart
import 'package:flutter_markdown/flutter_markdown.dart';
// 본문 표시 위젯 교체
Expanded(
child: SingleChildScrollView(
child: MarkdownBody(
data: p.generatedText.isEmpty ? "생성된 내용이 없습니다." : p.generatedText,
shrinkWrap: true,
),
),
),
체크리스트
- 여러 장 선택 → 썸네일 프리뷰 표시
- 업로드 후
/api/generate-ideas에서 통합 주제/키워드가 생성되는지 /api/generate-text응답에 이미지 Markdown이 포함되는지- 결과 화면에서 이미지가 본문에 렌더링되는지
- (실기기)
baseUrl과 정적 경로(/uploads/...)가 접근 가능한지
트러블슈팅
- 이미지가 표시되지 않음 → 기기에서 PC의
http://<IP>:5000/uploads/...에 접근 가능한지 확인(동일 Wi-Fi, 방화벽 해제) - CORS/네트워크 오류 → 모바일 보안 정책/프록시 확인, 테스트는 에뮬레이터가 간편
- 응답 포맷 에러 →
ideasJSON 이중 파싱(문자열 → JSON) 로직 점검
댓글 남기기