jacky-creator 0.1.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BRAND_ASSETS.md +14 -0
- package/CONTRIBUTING.md +46 -0
- package/LICENSE +22 -0
- package/README.md +156 -0
- package/SECURITY.md +25 -0
- package/assets/readme/hero.png +0 -0
- package/cordis.patch.yml +5 -0
- package/docs/files.md +29 -0
- package/docs/installation.md +102 -0
- package/docs/usage.md +112 -0
- package/lib/client.js +14340 -0
- package/lib/collect-publish.mjs +523 -0
- package/lib/index.js +6183 -0
- package/lib/typert.host.js +1006 -0
- package/package.json +132 -0
- package/scripts/collect-publish.mjs +523 -0
- package/scripts/generate_jacky_cover.py +2009 -0
|
@@ -0,0 +1,2009 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Generate Jacky Cover assets for Xiaohongshu and Bilibili with ZenMux.
|
|
4
|
+
|
|
5
|
+
The script reads the oil-cover reference rules, asks Gemini to select/plan a
|
|
6
|
+
cover, then calls gpt-image-2 to generate the final cover images.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import base64
|
|
13
|
+
import concurrent.futures
|
|
14
|
+
import json
|
|
15
|
+
import mimetypes
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
import urllib.error
|
|
23
|
+
import urllib.request
|
|
24
|
+
import uuid
|
|
25
|
+
from datetime import datetime
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _resolve_skill_dir() -> Path:
|
|
31
|
+
"""Locate the installed oil-cover skill directory without hardcoding a user.
|
|
32
|
+
|
|
33
|
+
Prefer an explicit override, then the directory that owns this script. A
|
|
34
|
+
project-local copy of the script falls back to installed Codex/Claude skills.
|
|
35
|
+
Override with OIL_COVER_SKILL_DIR if needed.
|
|
36
|
+
"""
|
|
37
|
+
override = os.environ.get("OIL_COVER_SKILL_DIR", "").strip()
|
|
38
|
+
candidates = [Path(override)] if override else []
|
|
39
|
+
script_skill_dir = Path(__file__).resolve().parent.parent
|
|
40
|
+
if (script_skill_dir / "references" / "cover-rules.md").exists():
|
|
41
|
+
candidates.append(script_skill_dir)
|
|
42
|
+
candidates += [
|
|
43
|
+
Path.home() / ".codex" / "skills" / "oil-cover",
|
|
44
|
+
Path.home() / ".claude" / "skills" / "oil-cover",
|
|
45
|
+
]
|
|
46
|
+
for candidate in candidates:
|
|
47
|
+
if (candidate / "references" / "cover-rules.md").exists():
|
|
48
|
+
return candidate
|
|
49
|
+
return candidates[-1]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
SKILL_DIR = _resolve_skill_dir()
|
|
53
|
+
DEFAULT_RULES_FILE = SKILL_DIR / "references" / "cover-rules.md"
|
|
54
|
+
DEFAULT_API_BASE = "https://zenmux.ai/api/v1"
|
|
55
|
+
DEFAULT_ANALYSIS_MODEL = "google/gemini-3.5-flash"
|
|
56
|
+
DEFAULT_IMAGE_MODEL = "openai/gpt-image-2"
|
|
57
|
+
|
|
58
|
+
USER_CONFIG_FILE = Path(
|
|
59
|
+
os.environ.get("OIL_COVER_CONFIG", str(Path.home() / ".oil-cover" / "config.json"))
|
|
60
|
+
).expanduser()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_user_config() -> dict[str, Any]:
|
|
64
|
+
if not USER_CONFIG_FILE.exists():
|
|
65
|
+
return {}
|
|
66
|
+
try:
|
|
67
|
+
payload = json.loads(USER_CONFIG_FILE.read_text(encoding="utf-8"))
|
|
68
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
69
|
+
raise RuntimeError(f"Invalid oil-cover user config: {USER_CONFIG_FILE}: {exc}") from exc
|
|
70
|
+
if not isinstance(payload, dict):
|
|
71
|
+
raise RuntimeError(f"oil-cover user config must be a JSON object: {USER_CONFIG_FILE}")
|
|
72
|
+
return payload
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
USER_CONFIG = load_user_config()
|
|
76
|
+
CREATOR_NAME = str(USER_CONFIG.get("creator_name") or "the creator").strip()
|
|
77
|
+
configured_api_key_file = str(USER_CONFIG.get("api_key_file") or "").strip()
|
|
78
|
+
DEFAULT_API_KEY_FILE = (
|
|
79
|
+
Path(configured_api_key_file).expanduser()
|
|
80
|
+
if configured_api_key_file
|
|
81
|
+
else Path.home() / ".config" / "oil-cover" / "zenmux_api_key"
|
|
82
|
+
)
|
|
83
|
+
PRODUCT_LOGO_DIR = SKILL_DIR / "assets" / "product-logos"
|
|
84
|
+
creator_portrait_config = USER_CONFIG.get("creator_portrait") or {}
|
|
85
|
+
if not isinstance(creator_portrait_config, dict):
|
|
86
|
+
raise RuntimeError(f"creator_portrait config must be an object: {USER_CONFIG_FILE}")
|
|
87
|
+
configured_portrait_path = str(creator_portrait_config.get("path") or "").strip()
|
|
88
|
+
DEFAULT_CREATOR_PORTRAIT_OVERLAY = {
|
|
89
|
+
"label": "configured_creator_portrait",
|
|
90
|
+
"path": Path(configured_portrait_path).expanduser() if configured_portrait_path else None,
|
|
91
|
+
"role": "local_code_composite",
|
|
92
|
+
}
|
|
93
|
+
DEFAULT_CREATOR_PORTRAIT_ENABLED = bool(creator_portrait_config.get("enabled", False))
|
|
94
|
+
CREATOR_PORTRAIT_LAYOUTS = {
|
|
95
|
+
# The transparent asset is intentionally allowed to extend below the canvas.
|
|
96
|
+
"3x4": {
|
|
97
|
+
"width_ratio": 0.55,
|
|
98
|
+
"top_ratio": 0.58,
|
|
99
|
+
"right_ratio": -0.06,
|
|
100
|
+
"safe_area": "x=48%-100%, y=56%-100%",
|
|
101
|
+
},
|
|
102
|
+
"4x3": {
|
|
103
|
+
"width_ratio": 0.38,
|
|
104
|
+
"top_ratio": 0.40,
|
|
105
|
+
"right_ratio": -0.03,
|
|
106
|
+
"safe_area": "x=60%-100%, y=37%-100%",
|
|
107
|
+
},
|
|
108
|
+
"16x9": {
|
|
109
|
+
"width_ratio": 0.32,
|
|
110
|
+
"top_ratio": 0.40,
|
|
111
|
+
"right_ratio": 0.02,
|
|
112
|
+
"safe_area": "x=62%-100%, y=37%-100%",
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
RETRYABLE_HTTP_CODES = {408, 429, 500, 502, 503, 504}
|
|
116
|
+
AUTO_PRODUCT_LOGOS = [
|
|
117
|
+
(r"\bkimi(?:\s+k3)?\b|月之暗面|Moonshot(?:\s*AI)?", "kimi.png"),
|
|
118
|
+
(r"\bclaude\s+code\b|Claude Code|ClaudeCode|claude-code", "claude-code.png"),
|
|
119
|
+
(r"\bcodex\b|Codex|代码智能体|Coding Agent", "codex-openai.png"),
|
|
120
|
+
(r"\bchatgpt\b|ChatGPT|\bopenai\b|OpenAI", "openai.png"),
|
|
121
|
+
(r"\bgemini\b|Gemini", "gemini.png"),
|
|
122
|
+
(r"\banthropic\b|Anthropic", "anthropic.png"),
|
|
123
|
+
(r"\bclaude\b|Claude", "claude.png"),
|
|
124
|
+
(r"\bcursor\b|Cursor", "cursor.png"),
|
|
125
|
+
(r"\bcopilot\b|Copilot|GitHub Copilot", "github-copilot.png"),
|
|
126
|
+
(r"\bgithub\b|GitHub", "github.png"),
|
|
127
|
+
(r"\bego\s+lite\b|ego-lite|ego browser|ego-browser", "ego-lite.png"),
|
|
128
|
+
(r"\bselector\b|Selector|Visual Element Picker|元素选择器", "selector.png"),
|
|
129
|
+
(r"\bqoder\b|Qoder", "qoder.png"),
|
|
130
|
+
(r"\bqwen\b|Qwen|通义千问|千问|通义", "qwen.png"),
|
|
131
|
+
(r"\blongcat(?:[-\s]?2(?:\.0)?)?\b|LongCat|Long Cat|美团龙猫", "longcat.png"),
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _resolve_jacky_cover_skill_dir() -> Path:
|
|
136
|
+
override = os.environ.get("JACKY_COVER_SKILL_DIR", "").strip()
|
|
137
|
+
candidates = [Path(override)] if override else []
|
|
138
|
+
candidates += [
|
|
139
|
+
Path.home() / ".claude" / "skills" / "jacky-cover",
|
|
140
|
+
Path.home() / ".codex" / "skills" / "jacky-cover",
|
|
141
|
+
Path.home() / ".agents" / "skills" / "jacky-cover",
|
|
142
|
+
Path.home() / ".grok" / "skills" / "jacky-cover",
|
|
143
|
+
]
|
|
144
|
+
for candidate in candidates:
|
|
145
|
+
if (candidate / "references" / "visual-system.md").is_file():
|
|
146
|
+
return candidate
|
|
147
|
+
raise RuntimeError("jacky-cover skill is not installed")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
JACKY_SKILL_DIR = _resolve_jacky_cover_skill_dir()
|
|
151
|
+
JACKY_VISUAL_SYSTEM = JACKY_SKILL_DIR / "references" / "visual-system.md"
|
|
152
|
+
JACKY_VALIDATOR = JACKY_SKILL_DIR / "scripts" / "validate_run.py"
|
|
153
|
+
OIL_GALLERY = SKILL_DIR / "docs" / "showcase" / "gallery.png"
|
|
154
|
+
# The installed Jacky Cover validator inherits the upstream Oil Cover contract.
|
|
155
|
+
# Keep the canonical marker in generated prompts while making the Jacky brand
|
|
156
|
+
# explicit, so branding changes cannot silently break the preflight gate.
|
|
157
|
+
STYLE_REFERENCE_LABEL = "Oil Cover style reference gallery"
|
|
158
|
+
DEFAULT_JACKY_REFERENCES = [
|
|
159
|
+
JACKY_SKILL_DIR / "assets" / "jacky-reference-front.jpg",
|
|
160
|
+
JACKY_SKILL_DIR / "assets" / "jacky-reference-casual.jpg",
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def retry_delay(attempt: int) -> float:
|
|
165
|
+
return min(2 ** attempt, 8) + attempt * 0.25
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def read_urlopen_json(request: urllib.request.Request, timeout: int, *, attempts: int = 3) -> dict[str, Any]:
|
|
169
|
+
last_error: BaseException | None = None
|
|
170
|
+
for attempt in range(attempts):
|
|
171
|
+
try:
|
|
172
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
173
|
+
return json.loads(response.read().decode("utf-8"))
|
|
174
|
+
except urllib.error.HTTPError as exc:
|
|
175
|
+
if exc.code not in RETRYABLE_HTTP_CODES or attempt == attempts - 1:
|
|
176
|
+
body = exc.read().decode("utf-8", errors="replace")
|
|
177
|
+
raise RuntimeError(f"HTTP {exc.code} from {request.full_url}: {body}") from exc
|
|
178
|
+
last_error = exc
|
|
179
|
+
except (urllib.error.URLError, TimeoutError, ConnectionError) as exc:
|
|
180
|
+
if attempt == attempts - 1:
|
|
181
|
+
raise RuntimeError(f"Network error from {request.full_url}: {exc}") from exc
|
|
182
|
+
last_error = exc
|
|
183
|
+
time.sleep(retry_delay(attempt))
|
|
184
|
+
raise RuntimeError(f"Request failed after retries: {last_error}")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def parse_args() -> argparse.Namespace:
|
|
188
|
+
parser = argparse.ArgumentParser(description="Generate Jacky Cover images with ZenMux.")
|
|
189
|
+
source = parser.add_mutually_exclusive_group(required=True)
|
|
190
|
+
source.add_argument("--video", type=Path, help="Input video file.")
|
|
191
|
+
source.add_argument(
|
|
192
|
+
"--image",
|
|
193
|
+
action="append",
|
|
194
|
+
type=Path,
|
|
195
|
+
help="Input screenshot/keyframe. Can be passed multiple times.",
|
|
196
|
+
)
|
|
197
|
+
parser.add_argument("--title", default="", help="Known video title or desired topic title.")
|
|
198
|
+
parser.add_argument("--topic", default="", help="Extra topic/context for the cover.")
|
|
199
|
+
parser.add_argument("--subtitle", type=Path, help="Optional subtitle, transcript, or script file.")
|
|
200
|
+
parser.add_argument("--logo", action="append", type=Path, help="Optional product logo image.")
|
|
201
|
+
parser.add_argument(
|
|
202
|
+
"--jacky-reference",
|
|
203
|
+
action="append",
|
|
204
|
+
type=Path,
|
|
205
|
+
help="Jacky identity reference. Pass at least two; defaults to the Jacky Cover assets.",
|
|
206
|
+
)
|
|
207
|
+
parser.add_argument("--rules-file", type=Path, default=DEFAULT_RULES_FILE)
|
|
208
|
+
parser.add_argument(
|
|
209
|
+
"--output-root",
|
|
210
|
+
type=Path,
|
|
211
|
+
default=None,
|
|
212
|
+
help="Output root for frames, prompts, images, and logs. Default: output to the video (or image) directory.",
|
|
213
|
+
)
|
|
214
|
+
parser.add_argument("--api-base", default=DEFAULT_API_BASE)
|
|
215
|
+
parser.add_argument("--analysis-model", default=DEFAULT_ANALYSIS_MODEL)
|
|
216
|
+
parser.add_argument("--image-model", default=DEFAULT_IMAGE_MODEL)
|
|
217
|
+
parser.add_argument("--api-key", default="", help="Optional API key. Prefer ZENMUX_API_KEY.")
|
|
218
|
+
parser.add_argument(
|
|
219
|
+
"--api-key-file",
|
|
220
|
+
type=Path,
|
|
221
|
+
default=DEFAULT_API_KEY_FILE,
|
|
222
|
+
help="Optional local file containing the Zenmux API key. Default: user config or ~/.config/oil-cover/zenmux_api_key.",
|
|
223
|
+
)
|
|
224
|
+
parser.add_argument("--frame-count", type=int, default=8, help="How many candidate frames the local prefilter surfaces for the analysis model to choose from. More candidates give the model better frame choices but a larger payload.")
|
|
225
|
+
parser.add_argument(
|
|
226
|
+
"--candidate-seconds",
|
|
227
|
+
default="",
|
|
228
|
+
help="Comma-separated timestamps to extract from the video, for example 1,8,24.5. Explicit manual override that skips the local prefilter.",
|
|
229
|
+
)
|
|
230
|
+
parser.add_argument(
|
|
231
|
+
"--scan-fps",
|
|
232
|
+
type=float,
|
|
233
|
+
default=0.0,
|
|
234
|
+
help="Sampling rate for the local prefilter scan. 0 = auto (2 fps for videos <= 5 min, otherwise 1 fps).",
|
|
235
|
+
)
|
|
236
|
+
parser.add_argument("--max-frame-width", type=int, default=1280)
|
|
237
|
+
parser.add_argument(
|
|
238
|
+
"--portrait-size",
|
|
239
|
+
default="960x1280",
|
|
240
|
+
help="Exact 3:4 size for the image API. Zenmux requires width and height divisible by 16.",
|
|
241
|
+
)
|
|
242
|
+
parser.add_argument(
|
|
243
|
+
"--landscape-size",
|
|
244
|
+
default="1280x960",
|
|
245
|
+
help="Exact 4:3 size for the image API. Zenmux requires width and height divisible by 16.",
|
|
246
|
+
)
|
|
247
|
+
parser.add_argument(
|
|
248
|
+
"--bilibili-size",
|
|
249
|
+
default="1280x720",
|
|
250
|
+
help="Exact 16:9 Bilibili personal-space companion size for the image API. The default Bilibili upload source remains the 4:3 cover. Zenmux requires width and height divisible by 16.",
|
|
251
|
+
)
|
|
252
|
+
parser.set_defaults(
|
|
253
|
+
composite_base=None,
|
|
254
|
+
composite_output=None,
|
|
255
|
+
composite_aspect=None,
|
|
256
|
+
generation_only=False,
|
|
257
|
+
default_creator_portrait=False,
|
|
258
|
+
)
|
|
259
|
+
parser.add_argument(
|
|
260
|
+
"--aspect",
|
|
261
|
+
choices=("all", "both", "3x4", "4x3", "16x9"),
|
|
262
|
+
default="all",
|
|
263
|
+
help="Which cover aspect to generate. Default: all three in parallel. Legacy 'both' keeps 3x4 + 4x3.",
|
|
264
|
+
)
|
|
265
|
+
parser.add_argument(
|
|
266
|
+
"--allow-subtitle",
|
|
267
|
+
action=argparse.BooleanOptionalAction,
|
|
268
|
+
default=True,
|
|
269
|
+
help="Whether Gemini may add an external subtitle. Default: enabled. Use --no-allow-subtitle to keep only the main title.",
|
|
270
|
+
)
|
|
271
|
+
parser.add_argument(
|
|
272
|
+
"--skip-generate",
|
|
273
|
+
action="store_true",
|
|
274
|
+
help="Only run Gemini analysis and write prompts. Do not call the image API.",
|
|
275
|
+
)
|
|
276
|
+
parser.add_argument(
|
|
277
|
+
"--dry-run",
|
|
278
|
+
action="store_true",
|
|
279
|
+
help="Prepare local files and prompts for the analysis call, but do not call any API.",
|
|
280
|
+
)
|
|
281
|
+
parser.add_argument("--timeout", type=int, default=180)
|
|
282
|
+
return parser.parse_args()
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def jacky_reference_paths(args: argparse.Namespace) -> list[Path]:
|
|
286
|
+
refs = [path.expanduser().resolve() for path in (args.jacky_reference or DEFAULT_JACKY_REFERENCES)]
|
|
287
|
+
if len(refs) < 2:
|
|
288
|
+
raise RuntimeError("Jacky Cover requires at least two identity references")
|
|
289
|
+
missing = [str(path) for path in refs if not path.is_file()]
|
|
290
|
+
if missing:
|
|
291
|
+
raise RuntimeError(f"Jacky identity reference is missing: {', '.join(missing)}")
|
|
292
|
+
return refs
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def fail(message: str) -> None:
|
|
296
|
+
print(f"Error: {message}", file=sys.stderr)
|
|
297
|
+
sys.exit(1)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def run(cmd: list[str]) -> subprocess.CompletedProcess[str]:
|
|
301
|
+
return subprocess.run(cmd, check=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def slugify(value: str, fallback: str = "oil-cover") -> str:
|
|
305
|
+
value = value.strip() or fallback
|
|
306
|
+
value = re.sub(r"[^\w\-\u4e00-\u9fff]+", "-", value)
|
|
307
|
+
value = re.sub(r"-+", "-", value).strip("-_")
|
|
308
|
+
return value[:80] or fallback
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def read_text(path: Path | None, limit: int = 60000) -> str:
|
|
312
|
+
if not path:
|
|
313
|
+
return ""
|
|
314
|
+
if not path.exists():
|
|
315
|
+
fail(f"file does not exist: {path}")
|
|
316
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
317
|
+
if len(text) > limit:
|
|
318
|
+
return text[:limit] + "\n\n[TRUNCATED BY SCRIPT]\n"
|
|
319
|
+
return text
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def api_key_from_args(args: argparse.Namespace) -> str:
|
|
323
|
+
key = args.api_key or os.environ.get("ZENMUX_API_KEY", "")
|
|
324
|
+
if not key and args.api_key_file and args.api_key_file.exists():
|
|
325
|
+
key = args.api_key_file.read_text(encoding="utf-8").strip()
|
|
326
|
+
if not key and not args.dry_run:
|
|
327
|
+
fail(
|
|
328
|
+
f"ZENMUX_API_KEY is not set. Export it, put it in {DEFAULT_API_KEY_FILE}, or pass --dry-run."
|
|
329
|
+
)
|
|
330
|
+
return key
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def ffprobe_duration(video: Path) -> float:
|
|
334
|
+
if not shutil.which("ffprobe"):
|
|
335
|
+
fail("ffprobe is required for video input.")
|
|
336
|
+
result = run(
|
|
337
|
+
[
|
|
338
|
+
"ffprobe",
|
|
339
|
+
"-v",
|
|
340
|
+
"error",
|
|
341
|
+
"-show_entries",
|
|
342
|
+
"format=duration",
|
|
343
|
+
"-of",
|
|
344
|
+
"default=noprint_wrappers=1:nokey=1",
|
|
345
|
+
str(video),
|
|
346
|
+
]
|
|
347
|
+
)
|
|
348
|
+
try:
|
|
349
|
+
return max(float(result.stdout.strip()), 0.1)
|
|
350
|
+
except ValueError as exc:
|
|
351
|
+
raise RuntimeError(f"could not read duration for {video}") from exc
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def parse_candidate_seconds(args: argparse.Namespace, duration: float) -> list[float]:
|
|
355
|
+
"""Parse the explicit --candidate-seconds manual override into clamped timestamps."""
|
|
356
|
+
values = []
|
|
357
|
+
for raw in args.candidate_seconds.split(","):
|
|
358
|
+
raw = raw.strip()
|
|
359
|
+
if raw:
|
|
360
|
+
values.append(max(0.0, min(float(raw), duration)))
|
|
361
|
+
return unique_times(values)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def unique_times(values: list[float]) -> list[float]:
|
|
365
|
+
output: list[float] = []
|
|
366
|
+
seen: set[int] = set()
|
|
367
|
+
for value in values:
|
|
368
|
+
marker = int(round(value * 10))
|
|
369
|
+
if marker in seen:
|
|
370
|
+
continue
|
|
371
|
+
seen.add(marker)
|
|
372
|
+
output.append(round(value, 2))
|
|
373
|
+
return output
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def extract_video_frames(args: argparse.Namespace, run_dir: Path) -> list[dict[str, str]]:
|
|
377
|
+
video = args.video
|
|
378
|
+
if not video or not video.exists():
|
|
379
|
+
fail(f"video does not exist: {video}")
|
|
380
|
+
if not shutil.which("ffmpeg"):
|
|
381
|
+
fail("ffmpeg is required for video input.")
|
|
382
|
+
|
|
383
|
+
frames_dir = run_dir / "frames"
|
|
384
|
+
frames_dir.mkdir(parents=True, exist_ok=True)
|
|
385
|
+
for stale in frames_dir.glob("*.jpg"):
|
|
386
|
+
stale.unlink()
|
|
387
|
+
duration = ffprobe_duration(video)
|
|
388
|
+
frames: list[dict[str, str]] = []
|
|
389
|
+
|
|
390
|
+
# No forced first_frame: the local prefilter already scans the opening seconds, and a
|
|
391
|
+
# forced t=0 frame is almost always a title/intro card that just pollutes the candidate set.
|
|
392
|
+
for idx, timestamp in enumerate(resolve_candidate_timestamps(args, run_dir, duration), start=1):
|
|
393
|
+
out = frames_dir / f"candidate_{idx:02d}_{timestamp:06.2f}s.jpg"
|
|
394
|
+
extract_frame(video, timestamp, out, args.max_frame_width)
|
|
395
|
+
frames.append({"label": f"candidate_{idx:02d}", "path": str(out), "timestamp": f"{timestamp:.2f}"})
|
|
396
|
+
|
|
397
|
+
return frames
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def extract_frame(video: Path, timestamp: float, output: Path, max_width: int) -> None:
|
|
401
|
+
run(
|
|
402
|
+
[
|
|
403
|
+
"ffmpeg",
|
|
404
|
+
"-y",
|
|
405
|
+
"-ss",
|
|
406
|
+
f"{timestamp:.3f}",
|
|
407
|
+
"-i",
|
|
408
|
+
str(video),
|
|
409
|
+
"-frames:v",
|
|
410
|
+
"1",
|
|
411
|
+
"-vf",
|
|
412
|
+
f"scale={max_width}:-2:force_original_aspect_ratio=decrease",
|
|
413
|
+
"-q:v",
|
|
414
|
+
"3",
|
|
415
|
+
str(output),
|
|
416
|
+
]
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _laplacian_variance(gray: Any) -> float:
|
|
421
|
+
"""Variance of the 4-neighbour Laplacian: a cheap, robust sharpness proxy.
|
|
422
|
+
|
|
423
|
+
Low values mean blur / motion-blur / out-of-focus; high values mean crisp edges
|
|
424
|
+
and detail. Computed with plain numpy slicing so no scipy/opencv dependency is needed.
|
|
425
|
+
"""
|
|
426
|
+
lap = (
|
|
427
|
+
4.0 * gray[1:-1, 1:-1]
|
|
428
|
+
- gray[:-2, 1:-1]
|
|
429
|
+
- gray[2:, 1:-1]
|
|
430
|
+
- gray[1:-1, :-2]
|
|
431
|
+
- gray[1:-1, 2:]
|
|
432
|
+
)
|
|
433
|
+
return float(lap.var())
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _scan_video_frames(
|
|
437
|
+
args: argparse.Namespace, run_dir: Path, fps: float, width: int
|
|
438
|
+
) -> list[Path]:
|
|
439
|
+
"""Down-sample the whole video to small JPEGs for local scoring (one decode pass)."""
|
|
440
|
+
scan_dir = run_dir / "scan"
|
|
441
|
+
scan_dir.mkdir(parents=True, exist_ok=True)
|
|
442
|
+
for stale in scan_dir.glob("scan_*.jpg"):
|
|
443
|
+
stale.unlink()
|
|
444
|
+
run(
|
|
445
|
+
[
|
|
446
|
+
"ffmpeg",
|
|
447
|
+
"-y",
|
|
448
|
+
"-i",
|
|
449
|
+
str(args.video),
|
|
450
|
+
"-vf",
|
|
451
|
+
f"fps={fps},scale={width}:-2",
|
|
452
|
+
"-q:v",
|
|
453
|
+
"5",
|
|
454
|
+
"-an",
|
|
455
|
+
str(scan_dir / "scan_%05d.jpg"),
|
|
456
|
+
]
|
|
457
|
+
)
|
|
458
|
+
return sorted(scan_dir.glob("scan_*.jpg"))
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def select_timestamps_local(
|
|
462
|
+
args: argparse.Namespace, run_dir: Path, duration: float
|
|
463
|
+
) -> list[float]:
|
|
464
|
+
"""Pick candidate cover-frame timestamps locally, with no model call.
|
|
465
|
+
|
|
466
|
+
Scans the whole video at a low resolution/fps, scores every sampled frame for
|
|
467
|
+
sharpness (Laplacian variance), brightness and content (std-dev), hard-drops
|
|
468
|
+
black / blown-out / near-uniform (blank/loading) frames, then returns the sharpest
|
|
469
|
+
surviving frame in each of N time buckets so candidates are technically clean AND
|
|
470
|
+
spread across the video. The analysis model then chooses the best one semantically.
|
|
471
|
+
"""
|
|
472
|
+
try:
|
|
473
|
+
import numpy as np
|
|
474
|
+
from PIL import Image
|
|
475
|
+
except Exception as exc: # pragma: no cover - dependency guard
|
|
476
|
+
raise RuntimeError(f"local frame selection needs numpy + Pillow: {exc}") from exc
|
|
477
|
+
|
|
478
|
+
want = max(2, args.frame_count)
|
|
479
|
+
scan_fps = args.scan_fps if args.scan_fps and args.scan_fps > 0 else (2.0 if duration <= 300 else 1.0)
|
|
480
|
+
files = _scan_video_frames(args, run_dir, scan_fps, 384)
|
|
481
|
+
if not files:
|
|
482
|
+
raise RuntimeError("local scan produced no frames")
|
|
483
|
+
|
|
484
|
+
scored: list[dict[str, Any]] = []
|
|
485
|
+
for idx, path in enumerate(files):
|
|
486
|
+
try:
|
|
487
|
+
with Image.open(path) as im:
|
|
488
|
+
gray = np.asarray(im.convert("L"), dtype=np.float32)
|
|
489
|
+
except Exception:
|
|
490
|
+
continue
|
|
491
|
+
if gray.shape[0] < 3 or gray.shape[1] < 3:
|
|
492
|
+
continue
|
|
493
|
+
mean = float(gray.mean())
|
|
494
|
+
std = float(gray.std())
|
|
495
|
+
sharp = _laplacian_variance(gray)
|
|
496
|
+
ts = idx / scan_fps
|
|
497
|
+
if duration > 0.1:
|
|
498
|
+
ts = min(ts, duration - 0.05)
|
|
499
|
+
# Hard-reject: near-black, blown-out white, and near-uniform (blank/solid/loading) frames.
|
|
500
|
+
usable = 15.0 <= mean <= 248.0 and std >= 8.0
|
|
501
|
+
scored.append(
|
|
502
|
+
{"ts": round(ts, 2), "mean": mean, "std": std, "sharp": sharp, "usable": usable}
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
if not scored:
|
|
506
|
+
raise RuntimeError("local scan scored no frames")
|
|
507
|
+
pool = [s for s in scored if s["usable"]] or scored
|
|
508
|
+
|
|
509
|
+
# Temporal spread: split the timeline into `want` buckets, keep the sharpest survivor in each.
|
|
510
|
+
buckets = max(1, want)
|
|
511
|
+
seg = duration / buckets if duration > 0 else max((s["ts"] for s in pool), default=1.0) + 1.0
|
|
512
|
+
picks: list[dict[str, Any]] = []
|
|
513
|
+
for b in range(buckets):
|
|
514
|
+
lo, hi = b * seg, (b + 1) * seg
|
|
515
|
+
in_bucket = [s for s in pool if lo <= s["ts"] < hi]
|
|
516
|
+
if not in_bucket:
|
|
517
|
+
continue
|
|
518
|
+
picks.append(max(in_bucket, key=lambda s: s["sharp"]))
|
|
519
|
+
|
|
520
|
+
# Top up from the sharpest unused survivors if some buckets were empty.
|
|
521
|
+
if len(picks) < want:
|
|
522
|
+
chosen = {p["ts"] for p in picks}
|
|
523
|
+
for s in sorted((s for s in pool if s["ts"] not in chosen), key=lambda s: s["sharp"], reverse=True):
|
|
524
|
+
picks.append(s)
|
|
525
|
+
chosen.add(s["ts"])
|
|
526
|
+
if len(picks) >= want:
|
|
527
|
+
break
|
|
528
|
+
|
|
529
|
+
picks.sort(key=lambda s: s["ts"])
|
|
530
|
+
times = unique_times([p["ts"] for p in picks])[:want]
|
|
531
|
+
|
|
532
|
+
(run_dir / "frame_selection_local.json").write_text(
|
|
533
|
+
json.dumps(
|
|
534
|
+
{
|
|
535
|
+
"scan_fps": scan_fps,
|
|
536
|
+
"sampled": len(scored),
|
|
537
|
+
"usable": sum(1 for s in scored if s["usable"]),
|
|
538
|
+
"buckets": buckets,
|
|
539
|
+
"picked": times,
|
|
540
|
+
"detail": [
|
|
541
|
+
{
|
|
542
|
+
"ts": p["ts"],
|
|
543
|
+
"sharp": round(p["sharp"], 1),
|
|
544
|
+
"mean": round(p["mean"], 1),
|
|
545
|
+
"std": round(p["std"], 1),
|
|
546
|
+
"usable": p["usable"],
|
|
547
|
+
}
|
|
548
|
+
for p in picks
|
|
549
|
+
],
|
|
550
|
+
},
|
|
551
|
+
ensure_ascii=False,
|
|
552
|
+
indent=2,
|
|
553
|
+
),
|
|
554
|
+
encoding="utf-8",
|
|
555
|
+
)
|
|
556
|
+
if not times:
|
|
557
|
+
raise RuntimeError("local frame selection produced no timestamps")
|
|
558
|
+
return times
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def resolve_candidate_timestamps(
|
|
562
|
+
args: argparse.Namespace, run_dir: Path, duration: float
|
|
563
|
+
) -> list[float]:
|
|
564
|
+
"""Decide which timestamps to extract as candidate evidence frames.
|
|
565
|
+
|
|
566
|
+
Either the explicit --candidate-seconds manual override, or the local ffmpeg
|
|
567
|
+
prefilter. There is no silent quality-degrading fallback: if the prefilter
|
|
568
|
+
cannot produce frames, the run fails loudly so the problem is visible.
|
|
569
|
+
"""
|
|
570
|
+
if args.candidate_seconds.strip():
|
|
571
|
+
return parse_candidate_seconds(args, duration)
|
|
572
|
+
picks = select_timestamps_local(args, run_dir, duration)
|
|
573
|
+
print(
|
|
574
|
+
"Frame selection (local prefilter): " + ", ".join(f"{t:.2f}s" for t in picks),
|
|
575
|
+
file=sys.stderr,
|
|
576
|
+
)
|
|
577
|
+
return picks
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def copy_input_images(args: argparse.Namespace, run_dir: Path) -> list[dict[str, str]]:
|
|
581
|
+
frames_dir = run_dir / "frames"
|
|
582
|
+
frames_dir.mkdir(parents=True, exist_ok=True)
|
|
583
|
+
frames: list[dict[str, str]] = []
|
|
584
|
+
for idx, src in enumerate(args.image or [], start=1):
|
|
585
|
+
if not src.exists():
|
|
586
|
+
fail(f"image does not exist: {src}")
|
|
587
|
+
suffix = src.suffix.lower() or ".png"
|
|
588
|
+
dst = frames_dir / f"input_{idx:02d}{suffix}"
|
|
589
|
+
shutil.copy2(src, dst)
|
|
590
|
+
frames.append({"label": f"input_{idx:02d}", "path": str(dst), "timestamp": ""})
|
|
591
|
+
return frames
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def infer_auto_logo_paths(args: argparse.Namespace, subtitle_text: str) -> list[Path]:
|
|
595
|
+
if args.logo:
|
|
596
|
+
return []
|
|
597
|
+
|
|
598
|
+
primary_text = "\n".join(part for part in [args.title, args.topic] if part)
|
|
599
|
+
fallback_text = subtitle_text[:4000]
|
|
600
|
+
|
|
601
|
+
matched: list[Path] = []
|
|
602
|
+
seen: set[str] = set()
|
|
603
|
+
for text in [primary_text, fallback_text]:
|
|
604
|
+
if not text.strip():
|
|
605
|
+
continue
|
|
606
|
+
for pattern, filename in AUTO_PRODUCT_LOGOS:
|
|
607
|
+
if filename in seen:
|
|
608
|
+
continue
|
|
609
|
+
if re.search(pattern, text, flags=re.I):
|
|
610
|
+
path = PRODUCT_LOGO_DIR / filename
|
|
611
|
+
if path.exists():
|
|
612
|
+
matched.append(path)
|
|
613
|
+
seen.add(filename)
|
|
614
|
+
if matched:
|
|
615
|
+
break
|
|
616
|
+
return matched[:3]
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def convert_svg_to_png(src: Path, dst: Path) -> None:
|
|
620
|
+
if not shutil.which("qlmanage"):
|
|
621
|
+
fail(f"SVG logo requires qlmanage conversion on this system: {src}")
|
|
622
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
623
|
+
result = subprocess.run(
|
|
624
|
+
["qlmanage", "-t", "-s", "1024", "-o", str(dst.parent), str(src)],
|
|
625
|
+
check=True,
|
|
626
|
+
text=True,
|
|
627
|
+
stdout=subprocess.PIPE,
|
|
628
|
+
stderr=subprocess.PIPE,
|
|
629
|
+
)
|
|
630
|
+
generated = dst.parent / f"{src.name}.png"
|
|
631
|
+
if not generated.exists():
|
|
632
|
+
fail(f"failed to convert SVG logo to PNG: {src}\n{result.stdout}\n{result.stderr}")
|
|
633
|
+
generated.replace(dst)
|
|
634
|
+
trim_png_to_content(dst)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def trim_png_to_content(path: Path) -> None:
|
|
638
|
+
try:
|
|
639
|
+
from PIL import Image, ImageChops
|
|
640
|
+
except Exception:
|
|
641
|
+
return
|
|
642
|
+
|
|
643
|
+
with Image.open(path) as image:
|
|
644
|
+
rgba = image.convert("RGBA")
|
|
645
|
+
alpha = rgba.getchannel("A")
|
|
646
|
+
|
|
647
|
+
if alpha.getextrema()[0] < 250:
|
|
648
|
+
bbox = alpha.point(lambda value: 255 if value > 8 else 0).getbbox()
|
|
649
|
+
content = rgba
|
|
650
|
+
mask = alpha
|
|
651
|
+
else:
|
|
652
|
+
background = rgba.getpixel((rgba.width - 1, rgba.height - 1))
|
|
653
|
+
diff = ImageChops.difference(rgba, Image.new("RGBA", rgba.size, background))
|
|
654
|
+
mask = diff.convert("L").point(lambda value: 255 if value > 10 else 0)
|
|
655
|
+
bbox = mask.getbbox()
|
|
656
|
+
content = rgba
|
|
657
|
+
|
|
658
|
+
if not bbox:
|
|
659
|
+
return
|
|
660
|
+
|
|
661
|
+
cropped = content.crop(bbox)
|
|
662
|
+
cropped_mask = mask.crop(bbox)
|
|
663
|
+
cropped.putalpha(cropped_mask)
|
|
664
|
+
padding = max(16, int(max(cropped.size) * 0.08))
|
|
665
|
+
side = max(cropped.width, cropped.height) + padding * 2
|
|
666
|
+
output = Image.new("RGBA", (side, side), (255, 255, 255, 0))
|
|
667
|
+
output.paste(cropped, ((side - cropped.width) // 2, (side - cropped.height) // 2), cropped)
|
|
668
|
+
output.save(path)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def copy_logos(args: argparse.Namespace, run_dir: Path, subtitle_text: str = "") -> list[dict[str, str]]:
|
|
672
|
+
refs_dir = run_dir / "references"
|
|
673
|
+
refs_dir.mkdir(parents=True, exist_ok=True)
|
|
674
|
+
refs: list[dict[str, str]] = []
|
|
675
|
+
logo_sources = list(args.logo or []) + infer_auto_logo_paths(args, subtitle_text)
|
|
676
|
+
seen: set[Path] = set()
|
|
677
|
+
for idx, src in enumerate(logo_sources, start=1):
|
|
678
|
+
src = src.resolve()
|
|
679
|
+
if src in seen:
|
|
680
|
+
continue
|
|
681
|
+
seen.add(src)
|
|
682
|
+
if not src.exists():
|
|
683
|
+
fail(f"logo/reference does not exist: {src}")
|
|
684
|
+
suffix = src.suffix.lower() or ".png"
|
|
685
|
+
if suffix == ".svg":
|
|
686
|
+
dst = refs_dir / f"logo_{idx:02d}_{slugify(src.stem)}.png"
|
|
687
|
+
convert_svg_to_png(src, dst)
|
|
688
|
+
else:
|
|
689
|
+
dst = refs_dir / f"logo_{idx:02d}_{slugify(src.stem)}{suffix}"
|
|
690
|
+
shutil.copy2(src, dst)
|
|
691
|
+
refs.append({"label": f"logo_{idx:02d}_{src.stem}", "path": str(dst)})
|
|
692
|
+
return refs
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def prepare_creator_portrait_overlay(run_dir: Path) -> dict[str, Any]:
|
|
696
|
+
overlays_dir = run_dir / "overlays"
|
|
697
|
+
overlays_dir.mkdir(parents=True, exist_ok=True)
|
|
698
|
+
configured_path = DEFAULT_CREATOR_PORTRAIT_OVERLAY.get("path")
|
|
699
|
+
if not configured_path:
|
|
700
|
+
fail(
|
|
701
|
+
"creator portrait is enabled but creator_portrait.path is missing "
|
|
702
|
+
f"from {USER_CONFIG_FILE}"
|
|
703
|
+
)
|
|
704
|
+
src = Path(configured_path)
|
|
705
|
+
if not src.exists():
|
|
706
|
+
fail(f"default creator portrait overlay is missing: {src}")
|
|
707
|
+
dst = overlays_dir / "creator-portrait.png"
|
|
708
|
+
shutil.copy2(src, dst)
|
|
709
|
+
return {
|
|
710
|
+
"label": str(DEFAULT_CREATOR_PORTRAIT_OVERLAY["label"]),
|
|
711
|
+
"path": str(dst),
|
|
712
|
+
"source_path": str(src),
|
|
713
|
+
"role": str(DEFAULT_CREATOR_PORTRAIT_OVERLAY["role"]),
|
|
714
|
+
"layouts": CREATOR_PORTRAIT_LAYOUTS,
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def creator_portrait_plan(enabled: bool, overlay: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
719
|
+
if not enabled:
|
|
720
|
+
return {
|
|
721
|
+
"enabled": False,
|
|
722
|
+
"mode": "none",
|
|
723
|
+
"placement": "none",
|
|
724
|
+
"reserve_base_area": False,
|
|
725
|
+
}
|
|
726
|
+
return {
|
|
727
|
+
"enabled": True,
|
|
728
|
+
"mode": "local_code_composite",
|
|
729
|
+
"asset": str((overlay or {}).get("path", DEFAULT_CREATOR_PORTRAIT_OVERLAY["path"])),
|
|
730
|
+
"placement": "lower-right, right-edge anchored, bottom-clipped",
|
|
731
|
+
"reserve_base_area": True,
|
|
732
|
+
"layouts": CREATOR_PORTRAIT_LAYOUTS,
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def creator_portrait_prompt_guard(aspect_key: str) -> str:
|
|
737
|
+
layout = CREATOR_PORTRAIT_LAYOUTS[aspect_key]
|
|
738
|
+
right_ratio = float(layout["right_ratio"])
|
|
739
|
+
right_placement = (
|
|
740
|
+
f"extends {abs(right_ratio):.0%} past the right edge"
|
|
741
|
+
if right_ratio < 0
|
|
742
|
+
else f"right offset {right_ratio:.0%}"
|
|
743
|
+
)
|
|
744
|
+
return (
|
|
745
|
+
" Local portrait composite guard: keep the generated base entirely person-free. "
|
|
746
|
+
f"Reserve the bottom-right overlay-safe area {layout['safe_area']}; do not place the title, product "
|
|
747
|
+
"logo, small labels, or primary evidence there. Continue the background and only noncritical screen "
|
|
748
|
+
"detail beneath that area; do not draw a portrait, silhouette, placeholder, empty card, webcam bubble, "
|
|
749
|
+
f"avatar, mascot, or character. After generation, deterministic local code will composite {CREATOR_NAME}'s "
|
|
750
|
+
f"transparent paper-cut portrait at {layout['width_ratio']:.0%} of canvas width, {right_placement}, "
|
|
751
|
+
f"top {layout['top_ratio']:.0%}, with natural bottom/right edge clipping."
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def composite_creator_portrait(
|
|
756
|
+
base_path: Path,
|
|
757
|
+
output_path: Path,
|
|
758
|
+
overlay_path: Path,
|
|
759
|
+
aspect_key: str,
|
|
760
|
+
) -> dict[str, Any]:
|
|
761
|
+
try:
|
|
762
|
+
from PIL import Image
|
|
763
|
+
except ImportError as exc:
|
|
764
|
+
raise RuntimeError("Pillow is required for the default creator portrait composite.") from exc
|
|
765
|
+
|
|
766
|
+
layout = CREATOR_PORTRAIT_LAYOUTS[aspect_key]
|
|
767
|
+
with Image.open(base_path) as base_image, Image.open(overlay_path) as portrait_image:
|
|
768
|
+
base = base_image.convert("RGBA")
|
|
769
|
+
portrait = portrait_image.convert("RGBA")
|
|
770
|
+
if portrait.getchannel("A").getbbox() is None:
|
|
771
|
+
raise RuntimeError(f"creator portrait overlay has no visible alpha content: {overlay_path}")
|
|
772
|
+
|
|
773
|
+
target_width = max(1, round(base.width * float(layout["width_ratio"])))
|
|
774
|
+
target_height = max(1, round(portrait.height * target_width / portrait.width))
|
|
775
|
+
portrait = portrait.resize((target_width, target_height), Image.Resampling.LANCZOS)
|
|
776
|
+
|
|
777
|
+
right_offset = round(base.width * float(layout["right_ratio"]))
|
|
778
|
+
x = base.width - right_offset - target_width
|
|
779
|
+
y = round(base.height * float(layout["top_ratio"]))
|
|
780
|
+
layer = Image.new("RGBA", base.size, (0, 0, 0, 0))
|
|
781
|
+
layer.paste(portrait, (x, y), portrait)
|
|
782
|
+
merged = Image.alpha_composite(base, layer).convert("RGB")
|
|
783
|
+
|
|
784
|
+
temp_path = output_path.with_name(f".{output_path.name}.{uuid.uuid4().hex}.tmp.png")
|
|
785
|
+
merged.save(temp_path, format="PNG", optimize=True)
|
|
786
|
+
os.replace(temp_path, output_path)
|
|
787
|
+
|
|
788
|
+
return {
|
|
789
|
+
"mode": "local_code_composite",
|
|
790
|
+
"asset": str(overlay_path),
|
|
791
|
+
"base_image": str(base_path),
|
|
792
|
+
"output_image": str(output_path),
|
|
793
|
+
"canvas": {"width": base.width, "height": base.height},
|
|
794
|
+
"placement": {
|
|
795
|
+
"x": x,
|
|
796
|
+
"y": y,
|
|
797
|
+
"width": target_width,
|
|
798
|
+
"height": target_height,
|
|
799
|
+
"width_ratio": layout["width_ratio"],
|
|
800
|
+
"top_ratio": layout["top_ratio"],
|
|
801
|
+
"right_ratio": layout["right_ratio"],
|
|
802
|
+
"clipped_bottom_px": max(0, y + target_height - base.height),
|
|
803
|
+
"clipped_right_px": max(0, x + target_width - base.width),
|
|
804
|
+
},
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def infer_creator_portrait_aspect(base_path: Path) -> str:
|
|
809
|
+
try:
|
|
810
|
+
from PIL import Image
|
|
811
|
+
except ImportError as exc:
|
|
812
|
+
raise RuntimeError("Pillow is required for the default creator portrait composite.") from exc
|
|
813
|
+
with Image.open(base_path) as image:
|
|
814
|
+
if image.height >= image.width:
|
|
815
|
+
return "3x4"
|
|
816
|
+
ratio = image.width / image.height
|
|
817
|
+
return "16x9" if abs(ratio - (16 / 9)) < abs(ratio - (4 / 3)) else "4x3"
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def image_to_data_uri(path: Path) -> str:
|
|
821
|
+
mime = mimetypes.guess_type(str(path))[0] or "image/png"
|
|
822
|
+
data = base64.b64encode(path.read_bytes()).decode("ascii")
|
|
823
|
+
return f"data:{mime};base64,{data}"
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def build_analysis_messages(
|
|
827
|
+
args: argparse.Namespace,
|
|
828
|
+
frames: list[dict[str, str]],
|
|
829
|
+
logos: list[dict[str, str]],
|
|
830
|
+
creator_portrait_overlay: dict[str, Any] | None,
|
|
831
|
+
skill_rules: str,
|
|
832
|
+
subtitle_text: str,
|
|
833
|
+
) -> list[dict[str, Any]]:
|
|
834
|
+
jacky_refs = jacky_reference_paths(args)
|
|
835
|
+
frame_list = "\n".join(
|
|
836
|
+
f"- {item['label']}: {item['path']} timestamp={item.get('timestamp', '')}" for item in frames
|
|
837
|
+
)
|
|
838
|
+
logo_list = "\n".join(f"- {item['label']}: {item['path']}" for item in logos) or "None"
|
|
839
|
+
portrait_plan = {
|
|
840
|
+
"enabled": True,
|
|
841
|
+
"mode": "integrated_image_edit",
|
|
842
|
+
"placement": "lower-right, foreground, bottom-edge anchored",
|
|
843
|
+
"reserve_base_area": False,
|
|
844
|
+
"references": [str(path) for path in jacky_refs],
|
|
845
|
+
}
|
|
846
|
+
subtitle_instruction = (
|
|
847
|
+
"A short external subtitle is allowed when it strengthens the cover; keep it clearly smaller "
|
|
848
|
+
"than the main title and aligned on the same editorial grid."
|
|
849
|
+
if args.allow_subtitle
|
|
850
|
+
else "Do not add an external subtitle; keep the outside cover text limited to the main title and the small product mark."
|
|
851
|
+
)
|
|
852
|
+
system_text = (
|
|
853
|
+
f"You are an expert cover art director for {CREATOR_NAME}'s Xiaohongshu and Bilibili AI tool tutorial videos. "
|
|
854
|
+
"Use the supplied Jacky Cover rules as the design spec. "
|
|
855
|
+
"Treat supplied screenshots as evidence sources, not as full images to copy. "
|
|
856
|
+
"Before writing prompts, decide the one-glance subject: what result should be visible "
|
|
857
|
+
"within 0.5 seconds in a phone feed. Make that subject the dominant visual evidence, "
|
|
858
|
+
"and make every other UI element serve or yield to it. "
|
|
859
|
+
"Extract the few UI signals that explain the topic, rebuild them into a clean cover-ready screen, "
|
|
860
|
+
"and remove irrelevant navigation, long transcripts, old subtitles, random avatars, paths, timestamps, and tiny noisy text. "
|
|
861
|
+
+
|
|
862
|
+
"Generate one integrated Jacky portrait in the same final image. Use the supplied Jacky identity references only to preserve identity. "
|
|
863
|
+
"Keep Jacky in the lower-right foreground, wearing a fitted plain black crew-neck short-sleeve T-shirt, naturally crossing a panel edge. "
|
|
864
|
+
"Screens and nested UI must remain free of every other person, avatar, webcam bubble, mascot, or face. "
|
|
865
|
+
+
|
|
866
|
+
"The final image generator is Zenmux openai/gpt-image-2. The local script only extracts frames, "
|
|
867
|
+
"copies files, saves prompts, calls Zenmux APIs"
|
|
868
|
+
+
|
|
869
|
+
". The image model receives the Jacky Cover reference gallery, selected evidence, product logos, and two Jacky identity references. "
|
|
870
|
+
"No portrait compositing or post-generation repair is allowed. "
|
|
871
|
+
+
|
|
872
|
+
"It never adds text, pastes Logos, changes layout, crops the generated cover, or performs visual repairs locally. "
|
|
873
|
+
+
|
|
874
|
+
"IMPORTANT: each prompt you write IS the final and complete instruction sent to the image model; "
|
|
875
|
+
"no extra rules are appended afterwards. So make every prompt fully self-contained and internally "
|
|
876
|
+
"consistent. State the screenshot distillation, the visual-communication priority, the screen crop, "
|
|
877
|
+
"the layout, the text styling, and a short avoid list ONCE each, in plain language. Do not repeat the "
|
|
878
|
+
"same instruction in different words, do not give conflicting numbers or directions, and never rely "
|
|
879
|
+
"on post-processing to fix the prompt. Prefer a tight, unambiguous prompt over a long padded one. "
|
|
880
|
+
"Visual quality bar, fold these naturally into the one prompt without padding: "
|
|
881
|
+
"(1) design an intentional colour scheme with real atmosphere: a clean light base (white, light "
|
|
882
|
+
"gray, or a very pale tinted paper) carrying a soft pastel colour atmosphere of 1-3 neighbouring "
|
|
883
|
+
"hues that blend gently at the edges/corners/behind the screen — name the hues explicitly, for "
|
|
884
|
+
"example dusty periwinkle + soft pink, or cream + pale gold. Sample hues from the frame or logo "
|
|
885
|
+
"but soften them to a creamy/dusty pastel; never the raw high-saturation UI colour (no acid lime, "
|
|
886
|
+
"no neon green, no electric blue, no fluorescent blocks) and never a full-spectrum rainbow. The "
|
|
887
|
+
"atmosphere must be clearly visible — a nearly colorless gray canvas reads as unfinished — yet "
|
|
888
|
+
"stay soft and airy. Pair it with one pastel keyword chip on the title whose hue echoes the "
|
|
889
|
+
"atmosphere; "
|
|
890
|
+
"(2) make the screen/browser object intentionally overflow and get clipped by at least one canvas "
|
|
891
|
+
"edge, showing only about 80%-95% of it while keeping a visible top-left window edge, for a premium "
|
|
892
|
+
"editorial close-up with real depth, never a small fully-centered complete screenshot; "
|
|
893
|
+
"(3) ground the screen with a soft graphite drop shadow plus a subtle contact shadow; "
|
|
894
|
+
"(4) when the evidence is a row of cards or thumbnails, show 3 oversized cards fully plus a 4th "
|
|
895
|
+
"clipped at the edge, not a flat strip of small ones; "
|
|
896
|
+
"(5) place the screen/browser object at a subtle 3D perspective tilt — rotated only a few degrees in "
|
|
897
|
+
"space (about 5-12 degrees) as if seen slightly from one side, with one edge nearer the viewer — for "
|
|
898
|
+
"gentle parallax depth and dimensionality; this intentionally overrides any 'front-facing flat / "
|
|
899
|
+
"0-degree rotation / no diagonal edge' default in the rules; keep all UI text readable and avoid "
|
|
900
|
+
"extreme skew, fisheye, warping, or heavy rotation; "
|
|
901
|
+
"(6) make the main title unmistakably large — the covers live in phone and desktop feeds: in the 3:4 portrait "
|
|
902
|
+
"prompt each title line spans about 90%-96% of the safe-area width with a cap height around 8%-12% "
|
|
903
|
+
"of the canvas height; in the 4:3 landscape prompt each title line's cap height is about 11%-15% of "
|
|
904
|
+
"the canvas height with 3-6 characters per line; treat the 4:3 cover as the Bilibili homepage primary "
|
|
905
|
+
"and the 16:9 cover as a separate personal-space companion; in the 16:9 prompt use the same cap-height "
|
|
906
|
+
"range and keep the title as the first anchor; "
|
|
907
|
+
"when unsure, go bigger and break the title into "
|
|
908
|
+
"two short lines instead of shrinking it. "
|
|
909
|
+
"Return strict JSON only."
|
|
910
|
+
)
|
|
911
|
+
user_text = f"""
|
|
912
|
+
Task:
|
|
913
|
+
Create a complete external ZenMux workflow plan for a Jacky Cover Xiaohongshu and Bilibili cover set.
|
|
914
|
+
|
|
915
|
+
Known title (already distilled by the operator; treat as the final cover headline):
|
|
916
|
+
{args.title or "None"}
|
|
917
|
+
|
|
918
|
+
Extra topic/context:
|
|
919
|
+
{args.topic or "None"}
|
|
920
|
+
|
|
921
|
+
Candidate frames:
|
|
922
|
+
{frame_list}
|
|
923
|
+
|
|
924
|
+
Logo/reference images:
|
|
925
|
+
{logo_list}
|
|
926
|
+
|
|
927
|
+
Integrated Jacky portrait plan:
|
|
928
|
+
{json.dumps(portrait_plan, ensure_ascii=False, indent=2)}
|
|
929
|
+
|
|
930
|
+
Subtitle/transcript/script excerpt:
|
|
931
|
+
{subtitle_text or "None"}
|
|
932
|
+
|
|
933
|
+
Jacky Cover rules:
|
|
934
|
+
{skill_rules}
|
|
935
|
+
|
|
936
|
+
Output strict JSON with this schema:
|
|
937
|
+
{{
|
|
938
|
+
"task_type": "video_cover or image_cover",
|
|
939
|
+
"selected_frame": {{
|
|
940
|
+
"label": "",
|
|
941
|
+
"path": "",
|
|
942
|
+
"timestamp": "",
|
|
943
|
+
"score": 0,
|
|
944
|
+
"reason": ""
|
|
945
|
+
}},
|
|
946
|
+
"backup_frames": [
|
|
947
|
+
{{"label": "", "path": "", "reason": ""}}
|
|
948
|
+
],
|
|
949
|
+
"content_attribution": {{
|
|
950
|
+
"main_topic": "",
|
|
951
|
+
"main_product": "",
|
|
952
|
+
"host_interface": "",
|
|
953
|
+
"supporting_brands": []
|
|
954
|
+
}},
|
|
955
|
+
"title": {{
|
|
956
|
+
"main": "",
|
|
957
|
+
"line_breaks": [],
|
|
958
|
+
"subtitle": ""
|
|
959
|
+
}},
|
|
960
|
+
"logo_plan": {{
|
|
961
|
+
"outside_logo_or_mark": "",
|
|
962
|
+
"source": "",
|
|
963
|
+
"reason": ""
|
|
964
|
+
}},
|
|
965
|
+
"creator_portrait_plan": {{
|
|
966
|
+
"enabled": true,
|
|
967
|
+
"mode": "integrated_image_edit",
|
|
968
|
+
"placement": "lower-right, foreground, bottom-edge anchored",
|
|
969
|
+
"reserve_base_area": false,
|
|
970
|
+
"reason": ""
|
|
971
|
+
}},
|
|
972
|
+
"color_plan": {{
|
|
973
|
+
"base": "",
|
|
974
|
+
"gradient_source": "",
|
|
975
|
+
"accent": "",
|
|
976
|
+
"text_colors": ""
|
|
977
|
+
}},
|
|
978
|
+
"screenshot_distillation": {{
|
|
979
|
+
"keep": [],
|
|
980
|
+
"remove": [],
|
|
981
|
+
"rebuild_as": "",
|
|
982
|
+
"reason": ""
|
|
983
|
+
}},
|
|
984
|
+
"visual_communication": {{
|
|
985
|
+
"one_glance_subject": "",
|
|
986
|
+
"primary_evidence": "",
|
|
987
|
+
"supporting_evidence": [],
|
|
988
|
+
"sacrifice_if_crowded": [],
|
|
989
|
+
"primary_evidence_share": "55%-75% of the screen content area",
|
|
990
|
+
"phone_feed_readability_note": ""
|
|
991
|
+
}},
|
|
992
|
+
"cover_direction_markdown": "",
|
|
993
|
+
"prompts": {{
|
|
994
|
+
"3x4": {{
|
|
995
|
+
"size": "{args.portrait_size}",
|
|
996
|
+
"prompt": ""
|
|
997
|
+
}},
|
|
998
|
+
"4x3": {{
|
|
999
|
+
"size": "{args.landscape_size}",
|
|
1000
|
+
"prompt": ""
|
|
1001
|
+
}},
|
|
1002
|
+
"16x9": {{
|
|
1003
|
+
"size": "{args.bilibili_size}",
|
|
1004
|
+
"prompt": ""
|
|
1005
|
+
}}
|
|
1006
|
+
}},
|
|
1007
|
+
"quality_checklist": []
|
|
1008
|
+
}}
|
|
1009
|
+
|
|
1010
|
+
Important:
|
|
1011
|
+
- When a known title is provided, it is the final cover headline already distilled by the operator from the video content: use it as title.main essentially verbatim — you own only line breaks, typographic emphasis, and dropping a leading filler word if one slipped in. Do not rewrite it, soften it, or revert it to a generic video-title phrasing. Only when the known title is None should you distill title.main yourself from the subtitle/transcript, preferring the strongest concrete verdict in the speaker's own words.
|
|
1012
|
+
- Choosing selected_frame is the single biggest quality lever. The candidate frames have already been locally prefiltered for technical quality (sharpness, brightness, content) and spread across the video, so they should all be reasonably crisp — spend your judgement on WHICH one best represents the subject: prefer the frame that most clearly shows the named tool/product actually in use (its real interface, panel, result, or action), fully visible, clean, and large. Still reject any that slipped through: blurry/motion-blurred, fade/transition, near-empty intros, loading states, mostly-plain-text, or frames where the main evidence is occluded, cropped, or tiny. If several frames are similar, pick the cleanest and most on-topic; list the next best ones in backup_frames.
|
|
1013
|
+
- The three prompts must explicitly mention exact 3:4, exact 4:3, and exact 16:9 respectively.
|
|
1014
|
+
- The prompts must include the mandatory visible background sentence from the rules.
|
|
1015
|
+
- The color_plan must follow the cover colour system from the rules: a clean light base plus a soft pastel atmosphere of 1-3 neighbouring hues, and one keyword-chip accent echoing the atmosphere. Write gradient_source as the named pastel hues (e.g. "dusty periwinkle + soft pink") and accent as the chip colour — creamy/dusty versions, never the raw saturated UI colour, never neon or full-spectrum rainbow.
|
|
1016
|
+
- The prompts must tell gpt-image-2 to create one complete final cover in one image.
|
|
1017
|
+
- The prompts must preserve real tutorial evidence from the selected frame and remove unrelated people/webcam/avatar/subtitles from the source screen and rebuilt UI.
|
|
1018
|
+
- Generate Jacky inside the same final image from the supplied front and casual identity references. Preserve facial identity, use the Jacky paper-cut portrait language from the brand rules, and integrate the portrait into the composition rather than reserving a later overlay area.
|
|
1019
|
+
- Never request or perform local post-generation portrait compositing. The ZenMux image-edit result itself is the finished cover.
|
|
1020
|
+
- The prompts must not copy the selected screenshot as-is. They must specify a screenshot distillation plan: keep only 2-3 essential UI signals, remove noisy sidebars/long text/unrelated details, and rebuild the screen area as a clean real-feeling UI.
|
|
1021
|
+
- The prompts must include a visual communication plan: the one-glance subject, the primary evidence, the maximum size of supporting evidence, and what to delete/crop if the primary evidence becomes too small.
|
|
1022
|
+
- If the primary evidence is a row/grid/gallery/list of result cards, cover thumbnails, generated images, or comparison examples, the prompts must make those results large and readable as the dominant gallery. Do not shrink them into a faithful full-workspace screenshot.
|
|
1023
|
+
- The prompts must include a title decoration plan: the title area cannot be plain text only. Add 1-2 tasteful, content-related title accents such as a subtle keyword highlight, thin underline, small workflow label, cursor mark, bracket, or UI state chip derived from the current title, screenshot, subtitle, topic, or product identity.
|
|
1024
|
+
- The prompts must not ask for local post-processing.
|
|
1025
|
+
- {subtitle_instruction}
|
|
1026
|
+
- For the 4:3 and 16:9 horizontal prompts, the main title must be the first visual anchor, while the selected screen evidence remains large and readable. Treat 4:3 as the Bilibili homepage primary upload asset and 16:9 as a separate personal-space companion. All prompts must state the title size explicitly (portrait: each line spans ~90%-96% of the safe-area width; landscape: cap height ~11%-15% of canvas height) so the title cannot come out small.
|
|
1027
|
+
"""
|
|
1028
|
+
content: list[dict[str, Any]] = [{"type": "text", "text": user_text}]
|
|
1029
|
+
for item in frames:
|
|
1030
|
+
content.append({"type": "text", "text": f"Frame {item['label']} timestamp={item.get('timestamp', '')}"})
|
|
1031
|
+
content.append({"type": "image_url", "image_url": {"url": image_to_data_uri(Path(item["path"]))}})
|
|
1032
|
+
for item in logos:
|
|
1033
|
+
content.append({"type": "text", "text": f"Logo/reference {item['label']}"})
|
|
1034
|
+
content.append({"type": "image_url", "image_url": {"url": image_to_data_uri(Path(item["path"]))}})
|
|
1035
|
+
content.append({"type": "text", "text": f"{STYLE_REFERENCE_LABEL} (Jacky Cover brand)"})
|
|
1036
|
+
content.append({"type": "image_url", "image_url": {"url": image_to_data_uri(OIL_GALLERY)}})
|
|
1037
|
+
for index, path in enumerate(jacky_refs, start=1):
|
|
1038
|
+
content.append({"type": "text", "text": f"Jacky identity reference {index}"})
|
|
1039
|
+
content.append({"type": "image_url", "image_url": {"url": image_to_data_uri(path)}})
|
|
1040
|
+
return [
|
|
1041
|
+
{"role": "system", "content": system_text},
|
|
1042
|
+
{"role": "user", "content": content},
|
|
1043
|
+
]
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def post_json(url: str, payload: dict[str, Any], api_key: str, timeout: int) -> dict[str, Any]:
|
|
1047
|
+
data = json.dumps(payload).encode("utf-8")
|
|
1048
|
+
request = urllib.request.Request(
|
|
1049
|
+
url,
|
|
1050
|
+
data=data,
|
|
1051
|
+
headers={
|
|
1052
|
+
"Content-Type": "application/json",
|
|
1053
|
+
"Authorization": f"Bearer {api_key}",
|
|
1054
|
+
},
|
|
1055
|
+
method="POST",
|
|
1056
|
+
)
|
|
1057
|
+
return read_urlopen_json(request, timeout)
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def post_multipart(
|
|
1061
|
+
url: str,
|
|
1062
|
+
fields: dict[str, str],
|
|
1063
|
+
files: list[tuple[str, Path]],
|
|
1064
|
+
api_key: str,
|
|
1065
|
+
timeout: int,
|
|
1066
|
+
) -> dict[str, Any]:
|
|
1067
|
+
boundary = f"----oilcover-{uuid.uuid4().hex}"
|
|
1068
|
+
body = bytearray()
|
|
1069
|
+
|
|
1070
|
+
def add_line(value: str) -> None:
|
|
1071
|
+
body.extend(value.encode("utf-8"))
|
|
1072
|
+
body.extend(b"\r\n")
|
|
1073
|
+
|
|
1074
|
+
for name, value in fields.items():
|
|
1075
|
+
add_line(f"--{boundary}")
|
|
1076
|
+
add_line(f'Content-Disposition: form-data; name="{name}"')
|
|
1077
|
+
add_line("")
|
|
1078
|
+
add_line(value)
|
|
1079
|
+
|
|
1080
|
+
for field_name, path in files:
|
|
1081
|
+
mime = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
|
|
1082
|
+
add_line(f"--{boundary}")
|
|
1083
|
+
add_line(
|
|
1084
|
+
f'Content-Disposition: form-data; name="{field_name}"; filename="{path.name}"'
|
|
1085
|
+
)
|
|
1086
|
+
add_line(f"Content-Type: {mime}")
|
|
1087
|
+
add_line("")
|
|
1088
|
+
body.extend(path.read_bytes())
|
|
1089
|
+
body.extend(b"\r\n")
|
|
1090
|
+
|
|
1091
|
+
add_line(f"--{boundary}--")
|
|
1092
|
+
|
|
1093
|
+
request = urllib.request.Request(
|
|
1094
|
+
url,
|
|
1095
|
+
data=bytes(body),
|
|
1096
|
+
headers={
|
|
1097
|
+
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
|
1098
|
+
"Authorization": f"Bearer {api_key}",
|
|
1099
|
+
},
|
|
1100
|
+
method="POST",
|
|
1101
|
+
)
|
|
1102
|
+
return read_urlopen_json(request, timeout)
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
def extract_json_from_text(text: str) -> dict[str, Any]:
|
|
1106
|
+
text = text.strip()
|
|
1107
|
+
fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, flags=re.S)
|
|
1108
|
+
if fenced:
|
|
1109
|
+
text = fenced.group(1)
|
|
1110
|
+
else:
|
|
1111
|
+
start = text.find("{")
|
|
1112
|
+
end = text.rfind("}")
|
|
1113
|
+
if start >= 0 and end > start:
|
|
1114
|
+
text = text[start : end + 1]
|
|
1115
|
+
try:
|
|
1116
|
+
return json.loads(text)
|
|
1117
|
+
except json.JSONDecodeError as exc:
|
|
1118
|
+
raise RuntimeError(f"Gemini did not return valid JSON: {exc}\n{text[:2000]}") from exc
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def run_analysis(
|
|
1122
|
+
args: argparse.Namespace,
|
|
1123
|
+
api_key: str,
|
|
1124
|
+
messages: list[dict[str, Any]],
|
|
1125
|
+
run_dir: Path,
|
|
1126
|
+
) -> dict[str, Any]:
|
|
1127
|
+
payload = {
|
|
1128
|
+
"model": args.analysis_model,
|
|
1129
|
+
"messages": messages,
|
|
1130
|
+
"temperature": 0.2,
|
|
1131
|
+
"response_format": {"type": "json_object"},
|
|
1132
|
+
}
|
|
1133
|
+
(run_dir / "analysis_request.json").write_text(
|
|
1134
|
+
json.dumps(redact_payload(payload), ensure_ascii=False, indent=2),
|
|
1135
|
+
encoding="utf-8",
|
|
1136
|
+
)
|
|
1137
|
+
if args.dry_run:
|
|
1138
|
+
analysis = {
|
|
1139
|
+
"task_type": "dry_run",
|
|
1140
|
+
"selected_frame": {},
|
|
1141
|
+
"prompts": {
|
|
1142
|
+
"3x4": {"size": args.portrait_size, "prompt": ""},
|
|
1143
|
+
"4x3": {"size": args.landscape_size, "prompt": ""},
|
|
1144
|
+
"16x9": {"size": args.bilibili_size, "prompt": ""},
|
|
1145
|
+
},
|
|
1146
|
+
"cover_direction_markdown": "Dry run only. No API call was made.",
|
|
1147
|
+
}
|
|
1148
|
+
(run_dir / "analysis.json").write_text(
|
|
1149
|
+
json.dumps(analysis, ensure_ascii=False, indent=2),
|
|
1150
|
+
encoding="utf-8",
|
|
1151
|
+
)
|
|
1152
|
+
return analysis
|
|
1153
|
+
|
|
1154
|
+
last_error: Exception | None = None
|
|
1155
|
+
analysis: dict[str, Any] | None = None
|
|
1156
|
+
for attempt in range(3):
|
|
1157
|
+
response = post_json(
|
|
1158
|
+
f"{args.api_base.rstrip('/')}/chat/completions",
|
|
1159
|
+
payload,
|
|
1160
|
+
api_key,
|
|
1161
|
+
args.timeout,
|
|
1162
|
+
)
|
|
1163
|
+
(run_dir / "analysis_response.raw.json").write_text(
|
|
1164
|
+
json.dumps(response, ensure_ascii=False, indent=2),
|
|
1165
|
+
encoding="utf-8",
|
|
1166
|
+
)
|
|
1167
|
+
text = response["choices"][0]["message"]["content"]
|
|
1168
|
+
try:
|
|
1169
|
+
analysis = extract_json_from_text(text)
|
|
1170
|
+
break
|
|
1171
|
+
except RuntimeError as exc:
|
|
1172
|
+
last_error = exc
|
|
1173
|
+
print(f"Analysis JSON parse failed (attempt {attempt + 1}/3): {exc}", file=sys.stderr)
|
|
1174
|
+
if analysis is None:
|
|
1175
|
+
raise RuntimeError(f"Gemini analysis did not return valid JSON after retries: {last_error}")
|
|
1176
|
+
(run_dir / "analysis.json").write_text(
|
|
1177
|
+
json.dumps(analysis, ensure_ascii=False, indent=2),
|
|
1178
|
+
encoding="utf-8",
|
|
1179
|
+
)
|
|
1180
|
+
return analysis
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
def redact_payload(payload: Any) -> Any:
|
|
1184
|
+
if isinstance(payload, dict):
|
|
1185
|
+
out = {}
|
|
1186
|
+
for key, value in payload.items():
|
|
1187
|
+
if key == "url" and isinstance(value, str) and value.startswith("data:"):
|
|
1188
|
+
out[key] = value[:64] + "...[base64 omitted]"
|
|
1189
|
+
else:
|
|
1190
|
+
out[key] = redact_payload(value)
|
|
1191
|
+
return out
|
|
1192
|
+
if isinstance(payload, list):
|
|
1193
|
+
return [redact_payload(item) for item in payload]
|
|
1194
|
+
return payload
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def requested_aspects(args: argparse.Namespace) -> tuple[str, ...]:
|
|
1198
|
+
if args.aspect == "all":
|
|
1199
|
+
return ("3x4", "4x3", "16x9")
|
|
1200
|
+
if args.aspect == "both":
|
|
1201
|
+
return ("3x4", "4x3")
|
|
1202
|
+
return (args.aspect,)
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def aspect_size(args: argparse.Namespace, aspect_key: str) -> str:
|
|
1206
|
+
return {
|
|
1207
|
+
"3x4": args.portrait_size,
|
|
1208
|
+
"4x3": args.landscape_size,
|
|
1209
|
+
"16x9": args.bilibili_size,
|
|
1210
|
+
}[aspect_key]
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def strip_external_subtitle(prompt: str) -> str:
|
|
1214
|
+
patterns = [
|
|
1215
|
+
r";?\s*optional subtitle\s+['\"“][^'\"”]+['\"”]\.?",
|
|
1216
|
+
r";?\s*subtitle\s+['\"“][^'\"”]+['\"”]\.?",
|
|
1217
|
+
r";?\s*with subtitle\s+['\"“][^'\"”]+['\"”]\.?",
|
|
1218
|
+
r";?\s*副标题\s*[::]\s*['\"“][^'\"”]+['\"”]\.?",
|
|
1219
|
+
]
|
|
1220
|
+
for pattern in patterns:
|
|
1221
|
+
prompt = re.sub(pattern, ".", prompt, flags=re.I)
|
|
1222
|
+
prompt = re.sub(r"\s+\.", ".", prompt)
|
|
1223
|
+
prompt = re.sub(r"\.{2,}", ".", prompt)
|
|
1224
|
+
return prompt.strip()
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
def sanitize_canvas_edge_contact_language(prompt: str) -> str:
|
|
1228
|
+
"""Keep benign body-to-canvas wording out of the hand-contact validator.
|
|
1229
|
+
|
|
1230
|
+
The Jacky Cover validator intentionally rejects affirmative ``may touch``
|
|
1231
|
+
language inside the portrait guard. Gemini can use the same phrase for a
|
|
1232
|
+
shoulder meeting the outer canvas edge, which is visually safe but matches
|
|
1233
|
+
that guard. Preserve the layout intent without using contact wording.
|
|
1234
|
+
"""
|
|
1235
|
+
pattern = re.compile(
|
|
1236
|
+
r"\b((?:the\s+)?(?:outer\s+)?(?:shoulder|body|torso|portrait|figure)"
|
|
1237
|
+
r"(?:\s+or\s+(?:the\s+)?(?:outer\s+)?(?:shoulder|body|torso|portrait|figure))*)"
|
|
1238
|
+
r"\s+(?:may|can|should)\s+(?:naturally\s+)?(?:touch|rest\s+on)\s+(?:the\s+)?"
|
|
1239
|
+
r"(?:(left|right|top|bottom)\s+)?(?:canvas\s+)?edge\b",
|
|
1240
|
+
flags=re.IGNORECASE,
|
|
1241
|
+
)
|
|
1242
|
+
|
|
1243
|
+
def replace(match: re.Match[str]) -> str:
|
|
1244
|
+
subject = match.group(1)
|
|
1245
|
+
side = match.group(2)
|
|
1246
|
+
boundary = f"{side.lower()} canvas boundary" if side else "canvas boundary"
|
|
1247
|
+
return f"{subject} may align with the {boundary}"
|
|
1248
|
+
|
|
1249
|
+
return pattern.sub(replace, prompt)
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
def product_logo_guard(logos: list[dict[str, str]]) -> str:
|
|
1253
|
+
if not logos:
|
|
1254
|
+
return ""
|
|
1255
|
+
names = ", ".join(Path(item.get("path", "")).name for item in logos if item.get("path"))
|
|
1256
|
+
return (
|
|
1257
|
+
" Product identity guard: use the supplied logo reference image"
|
|
1258
|
+
f"{'s' if len(logos) > 1 else ''} ({names}) for the real product mark. "
|
|
1259
|
+
"Preserve the reference logo's actual silhouette, proportions, and mark style. "
|
|
1260
|
+
"Do not invent, simplify, replace, or approximate it with a generic code icon, braces icon, "
|
|
1261
|
+
"random abstract symbol, unrelated app logo, or text-only substitute."
|
|
1262
|
+
)
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
def hard_rule_backfill(
|
|
1266
|
+
prompt: str,
|
|
1267
|
+
aspect_key: str,
|
|
1268
|
+
logos: list[dict[str, str]],
|
|
1269
|
+
analysis: dict[str, Any] | None = None,
|
|
1270
|
+
) -> tuple[str, list[str]]:
|
|
1271
|
+
"""Append a rule ONLY when the model's own prompt omitted it.
|
|
1272
|
+
|
|
1273
|
+
The Gemini prompt is trusted to cover distillation, visual priority, crop,
|
|
1274
|
+
layout and decoration in one self-contained pass (see the system prompt). The
|
|
1275
|
+
script does not re-stack those guards; it enforces the few non-negotiables and
|
|
1276
|
+
backfills the depth/colour quality cues the model skipped, so prompts stay
|
|
1277
|
+
short and never contradict themselves.
|
|
1278
|
+
"""
|
|
1279
|
+
analysis = analysis or {}
|
|
1280
|
+
notes: list[str] = []
|
|
1281
|
+
low = prompt.lower()
|
|
1282
|
+
aspect_phrase = {
|
|
1283
|
+
"3x4": "3:4",
|
|
1284
|
+
"4x3": "4:3",
|
|
1285
|
+
"16x9": "16:9",
|
|
1286
|
+
}[aspect_key]
|
|
1287
|
+
|
|
1288
|
+
if aspect_phrase not in prompt:
|
|
1289
|
+
prompt += f" Output exactly one complete {aspect_phrase} cover in a single image."
|
|
1290
|
+
notes.append(f"{aspect_key}: backfilled exact aspect.")
|
|
1291
|
+
|
|
1292
|
+
if "grid" not in low or "background" not in low:
|
|
1293
|
+
prompt += (
|
|
1294
|
+
" Mandatory visible background: full-canvas clean base, visible fine grid, a soft pastel "
|
|
1295
|
+
"colour atmosphere of 1-3 neighbouring hues (sampled from the selected image, softened to a "
|
|
1296
|
+
"creamy/dusty pastel) glowing gently from the edges or corners, very light grain; the "
|
|
1297
|
+
"atmosphere must be clearly visible yet soft — no neon or acid hues, no full-spectrum "
|
|
1298
|
+
"rainbow, no plain colourless canvas."
|
|
1299
|
+
)
|
|
1300
|
+
notes.append(f"{aspect_key}: backfilled mandatory background.")
|
|
1301
|
+
|
|
1302
|
+
# Specific colour sampling: avoid a vague 'sampled from the image' with no named hue.
|
|
1303
|
+
color_plan = analysis.get("color_plan", {}) if isinstance(analysis.get("color_plan"), dict) else {}
|
|
1304
|
+
gradient_source = str(color_plan.get("gradient_source", "")).strip()
|
|
1305
|
+
accent = str(color_plan.get("accent", "")).strip()
|
|
1306
|
+
hues = ("orange", "blue", "green", "red", "purple", "pink", "cyan", "amber", "teal",
|
|
1307
|
+
"violet", "warm", "cool", "cream", "lime", "indigo", "gold", "yellow")
|
|
1308
|
+
if not any(h in low for h in hues):
|
|
1309
|
+
if gradient_source or accent:
|
|
1310
|
+
detail = gradient_source or "the dominant tones of the selected image"
|
|
1311
|
+
tail = f"; title accent uses {accent}" if accent else ""
|
|
1312
|
+
prompt += (
|
|
1313
|
+
f" Colour sampling: build the background atmosphere from {detail}{tail}; soften every hue "
|
|
1314
|
+
"to a creamy/dusty pastel before use."
|
|
1315
|
+
)
|
|
1316
|
+
notes.append(f"{aspect_key}: backfilled specific colour sampling.")
|
|
1317
|
+
else:
|
|
1318
|
+
prompt += (
|
|
1319
|
+
" Colour sampling: sample 1-3 neighbouring hues of the selected screen for the background "
|
|
1320
|
+
"atmosphere and title accent, softened to creamy/dusty pastels."
|
|
1321
|
+
)
|
|
1322
|
+
notes.append(f"{aspect_key}: backfilled colour-hue instruction.")
|
|
1323
|
+
|
|
1324
|
+
# Colour discipline: a visible pastel atmosphere — neither neon nor colourless.
|
|
1325
|
+
if not any(k in low for k in ("pastel", "creamy", "dusty", "muted", "desaturat", "soft pink",
|
|
1326
|
+
"pale gold", "periwinkle")):
|
|
1327
|
+
prompt += (
|
|
1328
|
+
" Colour discipline: the background must carry a clearly visible soft pastel atmosphere of 1-3 "
|
|
1329
|
+
"neighbouring hues blending gently at the edges or behind the screen, with one pastel keyword "
|
|
1330
|
+
"chip echoing it — never neon or acid hues, fluorescent blocks, or a full-spectrum rainbow; but "
|
|
1331
|
+
"never a plain colourless gray canvas either."
|
|
1332
|
+
)
|
|
1333
|
+
notes.append(f"{aspect_key}: backfilled colour discipline.")
|
|
1334
|
+
|
|
1335
|
+
# Screen depth crop: overflow + edge clip for the premium close-up (立体感).
|
|
1336
|
+
if not any(k in low for k in ("overflow", "clipped by", "cropped by", "clip the", "crop the",
|
|
1337
|
+
"bleed", "off the canvas", "beyond the canvas", "edge of the canvas")):
|
|
1338
|
+
prompt += (
|
|
1339
|
+
" Screen depth crop: the screen/browser object must intentionally overflow and be clipped by at "
|
|
1340
|
+
"least one canvas edge, showing only about 80%-95% of it while keeping a visible top-left window "
|
|
1341
|
+
"edge, for a premium editorial close-up with real depth; never a small fully-centered complete screenshot."
|
|
1342
|
+
)
|
|
1343
|
+
notes.append(f"{aspect_key}: backfilled screen overflow crop.")
|
|
1344
|
+
|
|
1345
|
+
# Subtle 3D perspective tilt for parallax depth (overrides the flat default).
|
|
1346
|
+
if not any(k in low for k in ("perspective tilt", "3d perspective", "parallax", "tilted", "angled view", "isometric")):
|
|
1347
|
+
prompt += (
|
|
1348
|
+
" Perspective override: render the screen object at a subtle 3D perspective tilt of about 5-12 degrees "
|
|
1349
|
+
"(seen slightly from one side, one edge nearer the viewer) for gentle parallax depth; this overrides any "
|
|
1350
|
+
"front-facing-flat or 0-degree wording above. Keep UI text readable; avoid extreme skew or warping."
|
|
1351
|
+
)
|
|
1352
|
+
notes.append(f"{aspect_key}: backfilled subtle 3D perspective tilt.")
|
|
1353
|
+
|
|
1354
|
+
# Shadow with a grounding contact layer for depth.
|
|
1355
|
+
if "shadow" not in low:
|
|
1356
|
+
prompt += (
|
|
1357
|
+
" Shadow: clean light graphite drop shadow close to `0 18px 44px rgba(30,35,40,0.10)` plus a soft "
|
|
1358
|
+
"contact shadow `0 4px 12px rgba(30,35,40,0.06)` under the screen object for grounded depth."
|
|
1359
|
+
)
|
|
1360
|
+
notes.append(f"{aspect_key}: backfilled layered shadow.")
|
|
1361
|
+
elif "contact" not in low:
|
|
1362
|
+
prompt += " Add a soft contact shadow close to `0 4px 12px rgba(30,35,40,0.06)` under the screen for grounded depth."
|
|
1363
|
+
notes.append(f"{aspect_key}: backfilled contact shadow.")
|
|
1364
|
+
|
|
1365
|
+
logo_guard = product_logo_guard(logos)
|
|
1366
|
+
if logo_guard and "Product identity guard:" not in prompt and "logo reference" not in low:
|
|
1367
|
+
prompt += logo_guard
|
|
1368
|
+
notes.append(f"{aspect_key}: backfilled product logo reference.")
|
|
1369
|
+
|
|
1370
|
+
return prompt, notes
|
|
1371
|
+
|
|
1372
|
+
|
|
1373
|
+
def jacky_prompt_contract(
|
|
1374
|
+
prompt: str,
|
|
1375
|
+
aspect_key: str,
|
|
1376
|
+
analysis: dict[str, Any],
|
|
1377
|
+
) -> str:
|
|
1378
|
+
prompt = re.sub(r"(?is)[^.]*\bperson-free\b[^.]*\.", "", prompt)
|
|
1379
|
+
prompt = re.sub(
|
|
1380
|
+
r"(?i)do not add any human[^.]*\.",
|
|
1381
|
+
"",
|
|
1382
|
+
prompt,
|
|
1383
|
+
)
|
|
1384
|
+
prompt = re.sub(r"(?is)[^.]*\bdo not (?:draw|add) (?:the )?creator\b[^.]*\.", "", prompt)
|
|
1385
|
+
prompt = re.sub(r"(?is)[^.]*\blocal (?:code )?(?:portrait )?composite\b[^.]*\.", "", prompt)
|
|
1386
|
+
prompt = re.sub(r"(?is)[^.]*\bcreator portrait is never\b[^.]*\.", "", prompt)
|
|
1387
|
+
title = str((analysis.get("title", {}) or {}).get("main", "")).strip()
|
|
1388
|
+
marker = {
|
|
1389
|
+
"3x4": "exact 3:4 portrait",
|
|
1390
|
+
"4x3": "exact 4:3 horizontal",
|
|
1391
|
+
"16x9": "exact 16:9 horizontal",
|
|
1392
|
+
}.get(aspect_key, f"exact {aspect_key}")
|
|
1393
|
+
safe_title = title or "从逐字稿提炼的短标题"
|
|
1394
|
+
title_guard = (
|
|
1395
|
+
"Place the title block within canvas height 10%-42%; each line spans 90%-96% of the safe-area width."
|
|
1396
|
+
if aspect_key == "3x4"
|
|
1397
|
+
else "Keep the title as the first visual anchor with cap height about 11%-15% of the canvas."
|
|
1398
|
+
)
|
|
1399
|
+
return f"""Use case: Jacky personal-brand tutorial cover.
|
|
1400
|
+
Asset type: one complete {marker} final cover.
|
|
1401
|
+
Input images: {STYLE_REFERENCE_LABEL} (Jacky Cover brand); selected real-screen evidence; product logo when available; two Jacky identity references.
|
|
1402
|
+
Primary request: Preserve the Jacky Cover evidence logic and generate one complete Jacky Cover in a single image.
|
|
1403
|
+
Content attribution: Use the real topic, product and host interface from the selected evidence.
|
|
1404
|
+
Title text: primary title “{safe_title}”. {title_guard}
|
|
1405
|
+
Composition: {prompt}
|
|
1406
|
+
Portrait integration guard: generate Jacky as an integrated part of the same scene, not as a pasted cutout. The entire Jacky figure stays in foreground above every screen, panel, card and window layer. One panel plane continues behind the lower-right torso or shoulder, and the torso visibly crosses that edge with no floating gap; use a narrow localized contact shadow. Default to no hand contact with any panel edge, card, window or screen. If a pointing gesture is essential, use only the inner-side hand nearest the panel, keep a clearly visible air gap between every finger and the panel, and keep the complete hand and forearm in foreground. Never grip, hold, pinch, rest on, hook around or wrap fingers around a panel edge. The natural half-body crop meets the bottom canvas edge.
|
|
1407
|
+
Mandatory visible background: full-canvas clean #E7DBC4 base, visible fine grid, a soft pastel blue atmosphere derived from #0138C4 glowing gently from the edges or corners, very light grain; no neon or acid hues.
|
|
1408
|
+
Colour system: deep ink text; #0138C4 is limited to one keyword and small aligned marks; panels stay warm white, light grey or paper-toned.
|
|
1409
|
+
Screen crop plan: show 80%-95% of the main screen and crop it intentionally at a canvas edge.
|
|
1410
|
+
Screenshot distillation plan: preserve 2-3 real UI signals, remove noise, and rebuild only what supports the topic.
|
|
1411
|
+
Visual communication plan: make the selected real-screen evidence occupy 55%-75% of the screen content area; sacrifice secondary detail before shrinking the primary evidence.
|
|
1412
|
+
Screen refinement plan: keep controls, product identity and result states recognizable without inventing success states.
|
|
1413
|
+
Shadow plan: use a soft graphite screen shadow plus the portrait's narrow localized contact shadow.
|
|
1414
|
+
Text style: large editorial Chinese title, short lines, strong black ink hierarchy.
|
|
1415
|
+
Title decoration: use one restrained #0138C4 underline, cursor mark or state chip tied to the topic.
|
|
1416
|
+
Decoration: fine grid, light paper grain and sparse editorial marks only.
|
|
1417
|
+
Jacky brand patch: #E7DBC4 paper base; restrained #0138C4; casual Jacky at lower right wearing a fitted but not compression-tight plain black crew-neck short-sleeve T-shirt, with a thin white keyline and integrated spatial overlap.
|
|
1418
|
+
Avoid: extra people or avatars inside screens, pasted portrait edges, floating body, hand-panel contact, neon blue blocks, fake product UI, tiny title, crowded card grids.
|
|
1419
|
+
"""
|
|
1420
|
+
|
|
1421
|
+
|
|
1422
|
+
def apply_script_guards(
|
|
1423
|
+
args: argparse.Namespace,
|
|
1424
|
+
analysis: dict[str, Any],
|
|
1425
|
+
run_dir: Path,
|
|
1426
|
+
logos: list[dict[str, str]] | None = None,
|
|
1427
|
+
) -> dict[str, Any]:
|
|
1428
|
+
"""Lean link: trust the model's self-contained prompt; backfill hard rules only.
|
|
1429
|
+
|
|
1430
|
+
Unlike the legacy link, this does NOT append distillation / visual / crop /
|
|
1431
|
+
decoration / layout guards onto every prompt. Those are the model's job now.
|
|
1432
|
+
We only enforce non-negotiables such as exact aspect, mandatory background,
|
|
1433
|
+
depth cues, real logo, and the deterministic creator-overlay safe area when
|
|
1434
|
+
the prompt omitted them.
|
|
1435
|
+
"""
|
|
1436
|
+
if not isinstance(analysis, dict):
|
|
1437
|
+
return analysis
|
|
1438
|
+
logos = logos or []
|
|
1439
|
+
analysis["creator_portrait_plan"] = {
|
|
1440
|
+
"enabled": True,
|
|
1441
|
+
"mode": "integrated_image_edit",
|
|
1442
|
+
"placement": "lower-right, foreground, bottom-edge anchored",
|
|
1443
|
+
"reserve_base_area": False,
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
title = analysis.setdefault("title", {})
|
|
1447
|
+
if isinstance(title, dict) and not args.allow_subtitle:
|
|
1448
|
+
title["subtitle"] = ""
|
|
1449
|
+
|
|
1450
|
+
prompts = analysis.setdefault("prompts", {})
|
|
1451
|
+
if not isinstance(prompts, dict):
|
|
1452
|
+
return analysis
|
|
1453
|
+
|
|
1454
|
+
postprocess_notes: list[str] = []
|
|
1455
|
+
for key, item in prompts.items():
|
|
1456
|
+
if not isinstance(item, dict):
|
|
1457
|
+
continue
|
|
1458
|
+
prompt = str(item.get("prompt", "")).strip()
|
|
1459
|
+
if not prompt:
|
|
1460
|
+
continue
|
|
1461
|
+
|
|
1462
|
+
if not args.allow_subtitle:
|
|
1463
|
+
updated = strip_external_subtitle(prompt)
|
|
1464
|
+
if updated != prompt:
|
|
1465
|
+
postprocess_notes.append(f"{key}: removed external subtitle from prompt.")
|
|
1466
|
+
prompt = updated
|
|
1467
|
+
if "No external subtitle" not in prompt:
|
|
1468
|
+
prompt += " No external subtitle outside the main title; keep the outside cover text limited to the main title and the small product mark."
|
|
1469
|
+
postprocess_notes.append(f"{key}: enforced no-subtitle.")
|
|
1470
|
+
|
|
1471
|
+
prompt, notes = hard_rule_backfill(prompt, key, logos, analysis)
|
|
1472
|
+
postprocess_notes.extend(notes)
|
|
1473
|
+
safe_prompt = sanitize_canvas_edge_contact_language(prompt)
|
|
1474
|
+
if safe_prompt != prompt:
|
|
1475
|
+
postprocess_notes.append(f"{key}: normalized harmless canvas-edge contact wording.")
|
|
1476
|
+
prompt = safe_prompt
|
|
1477
|
+
prompt = jacky_prompt_contract(prompt, key, analysis)
|
|
1478
|
+
postprocess_notes.append(f"{key}: applied Jacky Cover ZenMux contract.")
|
|
1479
|
+
|
|
1480
|
+
if (
|
|
1481
|
+
args.default_creator_portrait
|
|
1482
|
+
and key in CREATOR_PORTRAIT_LAYOUTS
|
|
1483
|
+
and "Local portrait composite guard:" not in prompt
|
|
1484
|
+
):
|
|
1485
|
+
prompt += creator_portrait_prompt_guard(key)
|
|
1486
|
+
postprocess_notes.append(f"{key}: reserved deterministic creator portrait overlay area.")
|
|
1487
|
+
|
|
1488
|
+
item["prompt"] = prompt
|
|
1489
|
+
|
|
1490
|
+
if postprocess_notes:
|
|
1491
|
+
analysis["script_postprocess_notes"] = postprocess_notes
|
|
1492
|
+
(run_dir / "script_postprocess_notes.json").write_text(
|
|
1493
|
+
json.dumps(postprocess_notes, ensure_ascii=False, indent=2),
|
|
1494
|
+
encoding="utf-8",
|
|
1495
|
+
)
|
|
1496
|
+
(run_dir / "analysis.json").write_text(
|
|
1497
|
+
json.dumps(analysis, ensure_ascii=False, indent=2),
|
|
1498
|
+
encoding="utf-8",
|
|
1499
|
+
)
|
|
1500
|
+
return analysis
|
|
1501
|
+
|
|
1502
|
+
|
|
1503
|
+
def write_cover_plan(
|
|
1504
|
+
run_dir: Path,
|
|
1505
|
+
analysis: dict[str, Any],
|
|
1506
|
+
frames: list[dict[str, str]],
|
|
1507
|
+
logos: list[dict[str, str]],
|
|
1508
|
+
creator_portrait_overlay: dict[str, Any] | None,
|
|
1509
|
+
) -> None:
|
|
1510
|
+
title = analysis.get("title", {}) or {}
|
|
1511
|
+
visual = analysis.get("visual_communication", {}) or {}
|
|
1512
|
+
attribution = analysis.get("content_attribution", {}) or {}
|
|
1513
|
+
lines = [
|
|
1514
|
+
"# Jacky Cover ZenMux Plan",
|
|
1515
|
+
"",
|
|
1516
|
+
f"- 标题断行:{json.dumps(title.get('line_breaks', []), ensure_ascii=False)}",
|
|
1517
|
+
f"- 强调词:{title.get('main', '')}",
|
|
1518
|
+
f"- 构图:{visual.get('one_glance_subject', analysis.get('cover_direction_markdown', ''))}",
|
|
1519
|
+
f"- 屏幕裁切:80%-95%,保留真实 {attribution.get('host_interface', '')} 证据",
|
|
1520
|
+
"- 人物层级:Jacky 位于所有屏幕、面板、卡片和窗口之前",
|
|
1521
|
+
"- 人物空间关系:右下落底,躯干或肩部跨过面板边缘,使用窄幅接触阴影",
|
|
1522
|
+
f"- 参考输入:Jacky Cover 风格参考、真实证据、Logo、两张 Jacky 身份锚点",
|
|
1523
|
+
"",
|
|
1524
|
+
"## Selected Frame",
|
|
1525
|
+
"",
|
|
1526
|
+
json.dumps(analysis.get("selected_frame", {}), ensure_ascii=False, indent=2),
|
|
1527
|
+
"",
|
|
1528
|
+
"## Content Attribution",
|
|
1529
|
+
"",
|
|
1530
|
+
json.dumps(analysis.get("content_attribution", {}), ensure_ascii=False, indent=2),
|
|
1531
|
+
"",
|
|
1532
|
+
"## Title",
|
|
1533
|
+
"",
|
|
1534
|
+
json.dumps(analysis.get("title", {}), ensure_ascii=False, indent=2),
|
|
1535
|
+
"",
|
|
1536
|
+
"## Creator Portrait Plan",
|
|
1537
|
+
"",
|
|
1538
|
+
json.dumps(analysis.get("creator_portrait_plan", {}), ensure_ascii=False, indent=2),
|
|
1539
|
+
"",
|
|
1540
|
+
"## Screenshot Distillation",
|
|
1541
|
+
"",
|
|
1542
|
+
json.dumps(analysis.get("screenshot_distillation", {}), ensure_ascii=False, indent=2),
|
|
1543
|
+
"",
|
|
1544
|
+
"## Direction",
|
|
1545
|
+
"",
|
|
1546
|
+
analysis.get("cover_direction_markdown", ""),
|
|
1547
|
+
"",
|
|
1548
|
+
"## Local References",
|
|
1549
|
+
"",
|
|
1550
|
+
"Frames:",
|
|
1551
|
+
*[f"- {item['label']}: {item['path']} timestamp={item.get('timestamp', '')}" for item in frames],
|
|
1552
|
+
"",
|
|
1553
|
+
"Logos:",
|
|
1554
|
+
*([f"- {item['label']}: {item['path']}" for item in logos] or ["- None"]),
|
|
1555
|
+
"",
|
|
1556
|
+
"Creator Portrait Local Overlay (not sent to the image model):",
|
|
1557
|
+
json.dumps(creator_portrait_overlay or {"enabled": False}, ensure_ascii=False, indent=2),
|
|
1558
|
+
]
|
|
1559
|
+
(run_dir / "cover_plan.md").write_text("\n".join(lines), encoding="utf-8")
|
|
1560
|
+
|
|
1561
|
+
|
|
1562
|
+
def write_analysis_markdown(run_dir: Path, analysis: dict[str, Any]) -> None:
|
|
1563
|
+
attribution = analysis.get("content_attribution", {}) or {}
|
|
1564
|
+
visual = analysis.get("visual_communication", {}) or {}
|
|
1565
|
+
title = analysis.get("title", {}) or {}
|
|
1566
|
+
lines = [
|
|
1567
|
+
"# Jacky Cover Analysis",
|
|
1568
|
+
"",
|
|
1569
|
+
"- 证据模式:real-screen",
|
|
1570
|
+
f"- 主主题:{attribution.get('main_topic', '')}",
|
|
1571
|
+
f"- 主产品:{attribution.get('main_product', '')}",
|
|
1572
|
+
f"- 承载界面:{attribution.get('host_interface', '')}",
|
|
1573
|
+
f"- 辅助品牌:{json.dumps(attribution.get('supporting_brands', []), ensure_ascii=False)}",
|
|
1574
|
+
f"- 一眼主语:{visual.get('one_glance_subject', '')}",
|
|
1575
|
+
f"- 主证据:{visual.get('primary_evidence', '')}",
|
|
1576
|
+
f"- 辅助证据:{json.dumps(visual.get('supporting_evidence', []), ensure_ascii=False)}",
|
|
1577
|
+
f"- 可牺牲信息:{json.dumps(visual.get('sacrifice_if_crowded', []), ensure_ascii=False)}",
|
|
1578
|
+
f"- 标题提炼依据:{title.get('main', '')}",
|
|
1579
|
+
"- 标题覆盖:用户指定",
|
|
1580
|
+
]
|
|
1581
|
+
(run_dir / "analysis.md").write_text("\n".join(lines), encoding="utf-8")
|
|
1582
|
+
|
|
1583
|
+
|
|
1584
|
+
def validate_jacky_run(
|
|
1585
|
+
args: argparse.Namespace,
|
|
1586
|
+
run_dir: Path,
|
|
1587
|
+
sidecars: dict[str, Path],
|
|
1588
|
+
refs: list[Path],
|
|
1589
|
+
) -> None:
|
|
1590
|
+
for aspect in requested_aspects(args):
|
|
1591
|
+
result = subprocess.run(
|
|
1592
|
+
[
|
|
1593
|
+
sys.executable,
|
|
1594
|
+
str(JACKY_VALIDATOR),
|
|
1595
|
+
"--prompt",
|
|
1596
|
+
str(sidecars[aspect]),
|
|
1597
|
+
"--analysis",
|
|
1598
|
+
str(run_dir / "analysis.md"),
|
|
1599
|
+
"--plan",
|
|
1600
|
+
str(run_dir / "cover_plan.md"),
|
|
1601
|
+
"--aspect",
|
|
1602
|
+
aspect,
|
|
1603
|
+
"--refs",
|
|
1604
|
+
*[str(path) for path in refs],
|
|
1605
|
+
],
|
|
1606
|
+
check=False,
|
|
1607
|
+
capture_output=True,
|
|
1608
|
+
text=True,
|
|
1609
|
+
)
|
|
1610
|
+
if result.returncode != 0:
|
|
1611
|
+
raise RuntimeError(f"Jacky Cover preflight failed for {aspect}:\n{result.stdout}{result.stderr}")
|
|
1612
|
+
print(result.stdout.strip())
|
|
1613
|
+
|
|
1614
|
+
|
|
1615
|
+
def prompt_sidecar_text(
|
|
1616
|
+
aspect_key: str,
|
|
1617
|
+
size: str,
|
|
1618
|
+
prompt: str,
|
|
1619
|
+
analysis: dict[str, Any],
|
|
1620
|
+
refs: list[Path],
|
|
1621
|
+
result_path: str = "PENDING",
|
|
1622
|
+
status: str = "PENDING",
|
|
1623
|
+
portrait_composite: dict[str, Any] | None = None,
|
|
1624
|
+
) -> str:
|
|
1625
|
+
selected = analysis.get("selected_frame", {})
|
|
1626
|
+
composite_text = json.dumps(portrait_composite, ensure_ascii=False, indent=2) if portrait_composite else "None"
|
|
1627
|
+
return f"""# Jacky Cover Prompt Sidecar
|
|
1628
|
+
|
|
1629
|
+
Use: Xiaohongshu and Bilibili Jacky Cover external ZenMux workflow
|
|
1630
|
+
Aspect: {aspect_key}
|
|
1631
|
+
Size: {size}
|
|
1632
|
+
Selected frame: {selected.get("label", "")} {selected.get("path", "")}
|
|
1633
|
+
Reference images:
|
|
1634
|
+
{chr(10).join(f"- {path}" for path in refs) if refs else "- None"}
|
|
1635
|
+
|
|
1636
|
+
Generation result: {result_path}
|
|
1637
|
+
Status: {status}
|
|
1638
|
+
Creator portrait composite:
|
|
1639
|
+
{composite_text}
|
|
1640
|
+
|
|
1641
|
+
## Final Prompt
|
|
1642
|
+
|
|
1643
|
+
{prompt}
|
|
1644
|
+
"""
|
|
1645
|
+
|
|
1646
|
+
|
|
1647
|
+
def save_prompt_sidecars(
|
|
1648
|
+
args: argparse.Namespace,
|
|
1649
|
+
run_dir: Path,
|
|
1650
|
+
analysis: dict[str, Any],
|
|
1651
|
+
refs: list[Path],
|
|
1652
|
+
) -> dict[str, Path]:
|
|
1653
|
+
prompts = analysis.get("prompts", {})
|
|
1654
|
+
paths: dict[str, Path] = {}
|
|
1655
|
+
for key in requested_aspects(args):
|
|
1656
|
+
item = prompts.get(key, {})
|
|
1657
|
+
prompt = item.get("prompt", "")
|
|
1658
|
+
size = aspect_size(args, key)
|
|
1659
|
+
sidecar = run_dir / f"{key}.prompt.md"
|
|
1660
|
+
sidecar.write_text(
|
|
1661
|
+
prompt_sidecar_text(key, size, prompt, analysis, refs),
|
|
1662
|
+
encoding="utf-8",
|
|
1663
|
+
)
|
|
1664
|
+
paths[key] = sidecar
|
|
1665
|
+
return paths
|
|
1666
|
+
|
|
1667
|
+
|
|
1668
|
+
def find_output_value(value: Any, key: str) -> str:
|
|
1669
|
+
if isinstance(value, dict):
|
|
1670
|
+
if isinstance(value.get(key), str):
|
|
1671
|
+
return value[key]
|
|
1672
|
+
for child in value.values():
|
|
1673
|
+
found = find_output_value(child, key)
|
|
1674
|
+
if found:
|
|
1675
|
+
return found
|
|
1676
|
+
if isinstance(value, list):
|
|
1677
|
+
for child in value:
|
|
1678
|
+
found = find_output_value(child, key)
|
|
1679
|
+
if found:
|
|
1680
|
+
return found
|
|
1681
|
+
return ""
|
|
1682
|
+
|
|
1683
|
+
|
|
1684
|
+
def save_image_response(response: dict[str, Any], output: Path, timeout: int) -> None:
|
|
1685
|
+
b64 = find_output_value(response, "b64_json")
|
|
1686
|
+
if b64:
|
|
1687
|
+
output.write_bytes(base64.b64decode(b64))
|
|
1688
|
+
return
|
|
1689
|
+
url = find_output_value(response, "url")
|
|
1690
|
+
if url:
|
|
1691
|
+
request = urllib.request.Request(url)
|
|
1692
|
+
last_error: BaseException | None = None
|
|
1693
|
+
for attempt in range(3):
|
|
1694
|
+
try:
|
|
1695
|
+
with urllib.request.urlopen(request, timeout=timeout) as resp:
|
|
1696
|
+
output.write_bytes(resp.read())
|
|
1697
|
+
return
|
|
1698
|
+
except (urllib.error.URLError, TimeoutError, ConnectionError) as exc:
|
|
1699
|
+
if attempt == 2:
|
|
1700
|
+
raise RuntimeError(f"Network error while downloading image: {exc}") from exc
|
|
1701
|
+
last_error = exc
|
|
1702
|
+
time.sleep(retry_delay(attempt))
|
|
1703
|
+
raise RuntimeError(f"Image download failed after retries: {last_error}")
|
|
1704
|
+
raise RuntimeError("image response did not include b64_json or url")
|
|
1705
|
+
|
|
1706
|
+
|
|
1707
|
+
def _normalize_label(value: Any) -> str:
|
|
1708
|
+
return re.sub(r"\s+", "", str(value or "")).lower()
|
|
1709
|
+
|
|
1710
|
+
|
|
1711
|
+
def selected_reference_paths(
|
|
1712
|
+
analysis: dict[str, Any],
|
|
1713
|
+
frames: list[dict[str, str]],
|
|
1714
|
+
logos: list[dict[str, str]],
|
|
1715
|
+
jacky_refs: list[Path],
|
|
1716
|
+
) -> list[Path]:
|
|
1717
|
+
selected: list[Path] = [OIL_GALLERY]
|
|
1718
|
+
|
|
1719
|
+
def match(label: str) -> Path | None:
|
|
1720
|
+
target = _normalize_label(label)
|
|
1721
|
+
if not target:
|
|
1722
|
+
return None
|
|
1723
|
+
for item in frames:
|
|
1724
|
+
if _normalize_label(item["label"]) == target:
|
|
1725
|
+
return Path(item["path"])
|
|
1726
|
+
return None
|
|
1727
|
+
|
|
1728
|
+
requested = (analysis.get("selected_frame", {}) or {}).get("label", "")
|
|
1729
|
+
chosen = match(requested)
|
|
1730
|
+
|
|
1731
|
+
if chosen is None:
|
|
1732
|
+
for backup in analysis.get("backup_frames", []) or []:
|
|
1733
|
+
if isinstance(backup, dict):
|
|
1734
|
+
chosen = match(backup.get("label", ""))
|
|
1735
|
+
if chosen is not None:
|
|
1736
|
+
break
|
|
1737
|
+
|
|
1738
|
+
if chosen is None and frames:
|
|
1739
|
+
# Defensive guard for a malformed model label: every candidate is already a
|
|
1740
|
+
# prefiltered, technically-clean frame, so the first one is a safe pick.
|
|
1741
|
+
fallback = frames[0]
|
|
1742
|
+
chosen = Path(fallback["path"])
|
|
1743
|
+
print(
|
|
1744
|
+
f"Warning: selected_frame label '{requested}' did not match any extracted "
|
|
1745
|
+
f"frame; falling back to '{fallback['label']}'.",
|
|
1746
|
+
file=sys.stderr,
|
|
1747
|
+
)
|
|
1748
|
+
|
|
1749
|
+
if chosen is not None:
|
|
1750
|
+
selected.append(chosen)
|
|
1751
|
+
# Keep the edit request at five references: gallery + evidence + main logo + two identities.
|
|
1752
|
+
selected.extend(Path(item["path"]) for item in logos[:1])
|
|
1753
|
+
selected.extend(jacky_refs)
|
|
1754
|
+
return selected
|
|
1755
|
+
|
|
1756
|
+
|
|
1757
|
+
def generate_images(
|
|
1758
|
+
args: argparse.Namespace,
|
|
1759
|
+
api_key: str,
|
|
1760
|
+
work_dir: Path,
|
|
1761
|
+
base_dir: Path,
|
|
1762
|
+
stem: str,
|
|
1763
|
+
analysis: dict[str, Any],
|
|
1764
|
+
refs: list[Path],
|
|
1765
|
+
sidecars: dict[str, Path],
|
|
1766
|
+
) -> dict[str, str]:
|
|
1767
|
+
results: dict[str, str] = {}
|
|
1768
|
+
prompts = analysis.get("prompts", {})
|
|
1769
|
+
|
|
1770
|
+
jobs = []
|
|
1771
|
+
for key in requested_aspects(args):
|
|
1772
|
+
item = prompts.get(key, {})
|
|
1773
|
+
prompt = item.get("prompt", "").strip()
|
|
1774
|
+
size = aspect_size(args, key)
|
|
1775
|
+
if not prompt:
|
|
1776
|
+
print(f"Skip {key}: empty prompt")
|
|
1777
|
+
continue
|
|
1778
|
+
jobs.append((key, prompt, size))
|
|
1779
|
+
|
|
1780
|
+
if not jobs:
|
|
1781
|
+
return results
|
|
1782
|
+
|
|
1783
|
+
def generate_one(key: str, prompt: str, size: str) -> tuple[str, str]:
|
|
1784
|
+
print(f"Generating {key} cover via {args.image_model} at {size}...")
|
|
1785
|
+
|
|
1786
|
+
output = base_dir / f"{stem}_{key}.png"
|
|
1787
|
+
if args.generation_only or not refs:
|
|
1788
|
+
payload = {
|
|
1789
|
+
"model": args.image_model,
|
|
1790
|
+
"prompt": prompt,
|
|
1791
|
+
"n": 1,
|
|
1792
|
+
"size": size,
|
|
1793
|
+
}
|
|
1794
|
+
response = post_json(
|
|
1795
|
+
f"{args.api_base.rstrip('/')}/images/generations",
|
|
1796
|
+
payload,
|
|
1797
|
+
api_key,
|
|
1798
|
+
args.timeout,
|
|
1799
|
+
)
|
|
1800
|
+
else:
|
|
1801
|
+
fields = {
|
|
1802
|
+
"model": args.image_model,
|
|
1803
|
+
"prompt": prompt,
|
|
1804
|
+
"n": "1",
|
|
1805
|
+
"size": size,
|
|
1806
|
+
}
|
|
1807
|
+
files = [("image[]", path) for path in refs]
|
|
1808
|
+
response = post_multipart(
|
|
1809
|
+
f"{args.api_base.rstrip('/')}/images/edits",
|
|
1810
|
+
fields,
|
|
1811
|
+
files,
|
|
1812
|
+
api_key,
|
|
1813
|
+
args.timeout,
|
|
1814
|
+
)
|
|
1815
|
+
|
|
1816
|
+
raw_path = work_dir / f"{key}.response.json"
|
|
1817
|
+
raw_path.write_text(json.dumps(response, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
1818
|
+
save_image_response(response, output, args.timeout)
|
|
1819
|
+
|
|
1820
|
+
sidecars[key].write_text(
|
|
1821
|
+
prompt_sidecar_text(key, size, prompt, analysis, refs, str(output), "GENERATED"),
|
|
1822
|
+
encoding="utf-8",
|
|
1823
|
+
)
|
|
1824
|
+
return key, str(output)
|
|
1825
|
+
|
|
1826
|
+
failures: list[str] = []
|
|
1827
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=len(jobs)) as executor:
|
|
1828
|
+
future_map = {executor.submit(generate_one, *job): job[0] for job in jobs}
|
|
1829
|
+
for future in concurrent.futures.as_completed(future_map):
|
|
1830
|
+
key = future_map[future]
|
|
1831
|
+
try:
|
|
1832
|
+
result_key, path = future.result()
|
|
1833
|
+
results[result_key] = path
|
|
1834
|
+
except Exception as exc:
|
|
1835
|
+
sidecars[key].write_text(
|
|
1836
|
+
prompt_sidecar_text(
|
|
1837
|
+
key,
|
|
1838
|
+
aspect_size(args, key),
|
|
1839
|
+
prompts.get(key, {}).get("prompt", ""),
|
|
1840
|
+
analysis,
|
|
1841
|
+
refs,
|
|
1842
|
+
"FAILED",
|
|
1843
|
+
f"FAILED: {exc}",
|
|
1844
|
+
),
|
|
1845
|
+
encoding="utf-8",
|
|
1846
|
+
)
|
|
1847
|
+
print(f"Error: {key} generation failed: {exc}", file=sys.stderr)
|
|
1848
|
+
failures.append(key)
|
|
1849
|
+
|
|
1850
|
+
# A single failed aspect must not discard the others that already succeeded.
|
|
1851
|
+
if failures and not results:
|
|
1852
|
+
raise RuntimeError(f"All image generations failed: {failures}")
|
|
1853
|
+
if failures:
|
|
1854
|
+
print(
|
|
1855
|
+
f"Warning: {len(failures)} aspect(s) failed ({failures}); delivered {list(results)}.",
|
|
1856
|
+
file=sys.stderr,
|
|
1857
|
+
)
|
|
1858
|
+
return results
|
|
1859
|
+
|
|
1860
|
+
|
|
1861
|
+
def apply_creator_portrait_composites(
|
|
1862
|
+
args: argparse.Namespace,
|
|
1863
|
+
work_dir: Path,
|
|
1864
|
+
analysis: dict[str, Any],
|
|
1865
|
+
refs: list[Path],
|
|
1866
|
+
sidecars: dict[str, Path],
|
|
1867
|
+
results: dict[str, str],
|
|
1868
|
+
creator_portrait_overlay: dict[str, Any] | None,
|
|
1869
|
+
) -> dict[str, Any]:
|
|
1870
|
+
if not args.default_creator_portrait or not results:
|
|
1871
|
+
return {}
|
|
1872
|
+
if not creator_portrait_overlay:
|
|
1873
|
+
raise RuntimeError("default creator portrait is enabled but no overlay asset was prepared")
|
|
1874
|
+
|
|
1875
|
+
records: dict[str, Any] = {}
|
|
1876
|
+
overlay_path = Path(str(creator_portrait_overlay["path"]))
|
|
1877
|
+
prompts = analysis.get("prompts", {})
|
|
1878
|
+
for key, output_value in results.items():
|
|
1879
|
+
output_path = Path(output_value)
|
|
1880
|
+
base_path = work_dir / f"{key}.generated-base.png"
|
|
1881
|
+
shutil.copy2(output_path, base_path)
|
|
1882
|
+
record = composite_creator_portrait(base_path, output_path, overlay_path, key)
|
|
1883
|
+
records[key] = record
|
|
1884
|
+
|
|
1885
|
+
prompt = str((prompts.get(key, {}) or {}).get("prompt", ""))
|
|
1886
|
+
size = aspect_size(args, key)
|
|
1887
|
+
sidecars[key].write_text(
|
|
1888
|
+
prompt_sidecar_text(
|
|
1889
|
+
key,
|
|
1890
|
+
size,
|
|
1891
|
+
prompt,
|
|
1892
|
+
analysis,
|
|
1893
|
+
refs,
|
|
1894
|
+
str(output_path),
|
|
1895
|
+
"GENERATED_AND_CODE_COMPOSITED",
|
|
1896
|
+
record,
|
|
1897
|
+
),
|
|
1898
|
+
encoding="utf-8",
|
|
1899
|
+
)
|
|
1900
|
+
|
|
1901
|
+
(work_dir / "portrait_composite.json").write_text(
|
|
1902
|
+
json.dumps(records, ensure_ascii=False, indent=2),
|
|
1903
|
+
encoding="utf-8",
|
|
1904
|
+
)
|
|
1905
|
+
return records
|
|
1906
|
+
|
|
1907
|
+
|
|
1908
|
+
def main() -> None:
|
|
1909
|
+
args = parse_args()
|
|
1910
|
+
# This project-local adapter always performs integrated image editing.
|
|
1911
|
+
# Never let the upstream cover config switch it back to local portrait compositing.
|
|
1912
|
+
args.default_creator_portrait = False
|
|
1913
|
+
if args.composite_base:
|
|
1914
|
+
fail("Jacky Cover adapter does not support post-generation portrait compositing")
|
|
1915
|
+
|
|
1916
|
+
api_key = api_key_from_args(args)
|
|
1917
|
+
|
|
1918
|
+
if args.video:
|
|
1919
|
+
stem = args.video.stem
|
|
1920
|
+
elif args.image:
|
|
1921
|
+
stem = slugify(args.title) if args.title.strip() else args.image[0].stem
|
|
1922
|
+
else:
|
|
1923
|
+
stem = "oil-cover"
|
|
1924
|
+
|
|
1925
|
+
if args.output_root is not None:
|
|
1926
|
+
base_dir = args.output_root
|
|
1927
|
+
elif args.video:
|
|
1928
|
+
base_dir = args.video.parent
|
|
1929
|
+
elif args.image:
|
|
1930
|
+
base_dir = args.image[0].parent
|
|
1931
|
+
else:
|
|
1932
|
+
base_dir = Path.cwd()
|
|
1933
|
+
base_dir = base_dir.expanduser()
|
|
1934
|
+
|
|
1935
|
+
work_dir = base_dir / f"{stem}.jacky-cover"
|
|
1936
|
+
work_dir.mkdir(parents=True, exist_ok=True)
|
|
1937
|
+
|
|
1938
|
+
skill_rules = (
|
|
1939
|
+
read_text(args.rules_file)
|
|
1940
|
+
+ "\n\n# Jacky Cover runtime brand patch\n"
|
|
1941
|
+
+ read_text(JACKY_VISUAL_SYSTEM)
|
|
1942
|
+
)
|
|
1943
|
+
subtitle_text = read_text(args.subtitle, limit=20000)
|
|
1944
|
+
frames = extract_video_frames(args, work_dir) if args.video else copy_input_images(args, work_dir)
|
|
1945
|
+
logos = copy_logos(args, work_dir, subtitle_text)
|
|
1946
|
+
creator_portrait_overlay = None
|
|
1947
|
+
jacky_refs = jacky_reference_paths(args)
|
|
1948
|
+
|
|
1949
|
+
manifest = {
|
|
1950
|
+
"created_at": datetime.now().isoformat(timespec="seconds"),
|
|
1951
|
+
"api_base": args.api_base,
|
|
1952
|
+
"analysis_model": args.analysis_model,
|
|
1953
|
+
"image_model": args.image_model,
|
|
1954
|
+
"video": str(args.video) if args.video else "",
|
|
1955
|
+
"images": [str(path) for path in args.image or []],
|
|
1956
|
+
"title": args.title,
|
|
1957
|
+
"topic": args.topic,
|
|
1958
|
+
"subtitle": str(args.subtitle) if args.subtitle else "",
|
|
1959
|
+
"frames": frames,
|
|
1960
|
+
"logos": logos,
|
|
1961
|
+
"creator_portrait_overlay": creator_portrait_overlay,
|
|
1962
|
+
"dry_run": args.dry_run,
|
|
1963
|
+
"skip_generate": args.skip_generate,
|
|
1964
|
+
"aspect": args.aspect,
|
|
1965
|
+
"allow_subtitle": args.allow_subtitle,
|
|
1966
|
+
"default_creator_portrait": args.default_creator_portrait,
|
|
1967
|
+
"jacky_references": [str(path) for path in jacky_refs],
|
|
1968
|
+
}
|
|
1969
|
+
(work_dir / "manifest.json").write_text(
|
|
1970
|
+
json.dumps(manifest, ensure_ascii=False, indent=2),
|
|
1971
|
+
encoding="utf-8",
|
|
1972
|
+
)
|
|
1973
|
+
|
|
1974
|
+
messages = build_analysis_messages(args, frames, logos, creator_portrait_overlay, skill_rules, subtitle_text)
|
|
1975
|
+
analysis = run_analysis(args, api_key, messages, work_dir)
|
|
1976
|
+
analysis = apply_script_guards(args, analysis, work_dir, logos)
|
|
1977
|
+
write_cover_plan(work_dir, analysis, frames, logos, creator_portrait_overlay)
|
|
1978
|
+
write_analysis_markdown(work_dir, analysis)
|
|
1979
|
+
|
|
1980
|
+
refs = selected_reference_paths(analysis, frames, logos, jacky_refs)
|
|
1981
|
+
sidecars = save_prompt_sidecars(args, work_dir, analysis, refs)
|
|
1982
|
+
results: dict[str, str] = {}
|
|
1983
|
+
portrait_composites: dict[str, Any] = {}
|
|
1984
|
+
if not args.skip_generate and not args.dry_run:
|
|
1985
|
+
validate_jacky_run(args, work_dir, sidecars, refs)
|
|
1986
|
+
results = generate_images(args, api_key, work_dir, base_dir, stem, analysis, refs, sidecars)
|
|
1987
|
+
|
|
1988
|
+
final_manifest = {
|
|
1989
|
+
**manifest,
|
|
1990
|
+
"analysis_path": str(work_dir / "analysis.json"),
|
|
1991
|
+
"cover_plan_path": str(work_dir / "cover_plan.md"),
|
|
1992
|
+
"creator_portrait_overlay": creator_portrait_overlay,
|
|
1993
|
+
"portrait_composites": portrait_composites,
|
|
1994
|
+
"prompt_sidecars": {key: str(value) for key, value in sidecars.items()},
|
|
1995
|
+
"generated_images": results,
|
|
1996
|
+
}
|
|
1997
|
+
(work_dir / "manifest.final.json").write_text(
|
|
1998
|
+
json.dumps(final_manifest, ensure_ascii=False, indent=2),
|
|
1999
|
+
encoding="utf-8",
|
|
2000
|
+
)
|
|
2001
|
+
|
|
2002
|
+
print(json.dumps(final_manifest, ensure_ascii=False, indent=2))
|
|
2003
|
+
|
|
2004
|
+
|
|
2005
|
+
if __name__ == "__main__":
|
|
2006
|
+
try:
|
|
2007
|
+
main()
|
|
2008
|
+
except KeyboardInterrupt:
|
|
2009
|
+
fail("interrupted")
|