#!/usr/bin/env python3 """T02 v2 process B: Alice AI ART background (text-to-image) + layout of process C (text + whole product photo). The product photo is never cut out or changed. One background call per input; retry only on error without image. Never overwrites runs.""" import csv, hashlib, json, platform, subprocess, time from datetime import datetime, timezone from pathlib import Path from PIL import Image, ImageDraw, ImageFont, ImageCms, __version__ HERE = Path(__file__).resolve().parent V2 = HERE.parent FONT = V2.parent / 'control-v1/assets/GolosText.ttf' ICC = ImageCms.ImageCmsProfile(ImageCms.createProfile('sRGB')).tobytes() ART = Path.home() / '.openclaw/data/ecomai/guard/yandex-art' BAND, W, H, PAD, FRAME = 360, 900, 1200, 30, 8 SCENE = { # fixed before the first call (protocol change 4) 'дом и кухня': 'светлая кухонная столешница из дерева, размытая кухня на заднем плане', 'домашний текстиль': 'уютная светлая гостиная, мягкий плед и размытый диван на заднем плане', 'одежда и аксессуары': 'светлая студийная поверхность в тёплых пастельных тонах, мягкие тени', 'обувь': 'светлый песок пляжа и размытое море на заднем плане, летний свет', 'электроника и аксессуары': 'чистый светлый рабочий стол, размытый ноутбук на заднем плане', 'детские товары': 'светлая детская комната, мягкий ковёр, размытые полки на заднем плане', } TEMPLATE = ('Фон для фотографии товара в карточке маркетплейса: {scene}. Пустая поверхность в центре кадра, ' 'мягкий рассеянный свет, спокойные светлые цвета. Без товаров в центре, без людей, без текста, без букв, без логотипов.') def sha(p): return hashlib.sha256(Path(p).read_bytes()).hexdigest() def stamp(): return datetime.now(timezone.utc).isoformat() def cover(im): k = max(W / im.width, H / im.height); im = im.resize((round(im.width * k), round(im.height * k)), Image.Resampling.LANCZOS) x, y = (im.width - W) // 2, (im.height - H) // 2 return im.crop((x, y, x + W, y + H)), round(k, 4) def main(): out = HERE / 'out'; out.mkdir(exist_ok=False); pr = HERE / 'prompts'; pr.mkdir(exist_ok=True); raw = HERE / 'raw'; raw.mkdir(exist_ok=True) items = json.loads((V2 / 'inputs-frozen.json').read_text())['inputs'] for it in items: # prompts first, all saved before any call (pr / (it['id'] + '.txt')).write_text(TEMPLATE.format(scene=SCENE[it['category']]) + '\n') (pr / 'SHA256SUMS').write_text(''.join(f"{sha(pr / (i['id'] + '.txt'))} {i['id']}.txt\n" for i in items)) rows = [] for it in items: started = stamp(); tick = time.monotonic() src = V2 / it['file']; assert sha(src) == it['sha256'], src prompt = (pr / (it['id'] + '.txt')).read_text().strip(); bg_path = raw / (it['id'] + '-B-bg.jpg') attempts = [] for n in range(2): r = subprocess.run([str(ART), prompt, '--out', str(bg_path), '--ratio', '2:3'], capture_output=True, text=True, timeout=300) attempts.append({'rc': r.returncode, 'stdout': r.stdout.strip()[-400:]}) if r.returncode == 0 and bg_path.exists(): break time.sleep(5) wait = round(time.monotonic() - tick, 1) if not bg_path.exists(): rows.append({'run_id': it['id'] + '-B', 'phase': 'primary', 'process_id': 'B', 'sku_id': it['id'], 'repeat': 1, 'started_at': started, 'finished_at': stamp(), 'status': 'error', 'model_version': 'aliceai-image-art-3.0', 'input_sha256': it['sha256'], 'prompt_sha256': sha(pr / (it['id'] + '.txt')), 'wait_seconds': wait, 'cost_status': f'{len(attempts)} calls, no image', 'accepted': 'not-checked', 'notes': json.dumps(attempts, ensure_ascii=False)}) continue bg0 = Image.open(bg_path).convert('RGB'); im, kbg = cover(bg0) panel = Image.new('RGBA', (W, H), (0, 0, 0, 0)); ImageDraw.Draw(panel).rounded_rectangle((PAD, PAD, W - PAD, BAND - 10), 24, fill=(255, 255, 255, 230)) im = Image.alpha_composite(im.convert('RGBA'), panel).convert('RGB'); d = ImageDraw.Draw(im) boxes, sizes = [], [] for i, line in enumerate(it['passport_lines']): size = 70 if i == 0 else 52 while True: f = ImageFont.truetype(str(FONT), size); box = d.textbbox((55, 65 + i * 100), line, font=f) if box[2] < 845: break size -= 2 assert box[3] < BAND - 10, (line, box) d.text((55, 65 + i * 100), line, font=f, fill='#172438'); boxes.append(box); sizes.append(size) photo = Image.open(src).convert('RGB') aw, ah = W - 2 * PAD - 2 * FRAME, H - BAND - PAD - 2 * FRAME k = min(aw / photo.width, ah / photo.height); pw, ph = round(photo.width * k), round(photo.height * k) x, y = (W - pw) // 2, BAND + FRAME + (ah - ph) // 2 d.rectangle((x - FRAME, y - FRAME, x + pw + FRAME - 1, y + ph + FRAME - 1), fill='white') im.paste(photo.resize((pw, ph), Image.Resampling.LANCZOS), (x, y)) o = out / (it['id'] + '-B.png'); im.save(o, icc_profile=ICC) prev = out / (it['id'] + '-B-300.png'); im.resize((300, 400), Image.Resampling.LANCZOS).save(prev, icc_profile=ICC) params = out / (it['id'] + '-B.json') params.write_text(json.dumps({'lines': it['passport_lines'], 'font_px': sizes, 'text_boxes': boxes, 'photo_box': [x, y, x + pw, y + ph], 'scale': round(k, 4), 'bg_raw': str(bg_path.relative_to(V2)), 'bg_raw_sha256': sha(bg_path), 'bg_raw_px': list(bg0.size), 'bg_scale': kbg, 'prompt': prompt, 'attempts': attempts, 'source': it['file'], 'source_sha256': it['sha256'], 'renderer_sha256': sha(__file__), 'font_sha256': sha(FONT), 'pillow': __version__, 'python': platform.python_version(), 'product_transform': 'uniform scale (LANCZOS), whole photo, no crop, no cutout, photo background kept, white 8 px frame', 'output_sha256': sha(o), 'preview_sha256': sha(prev)}, ensure_ascii=False, indent=1) + '\n') rows.append({'run_id': it['id'] + '-B', 'phase': 'primary', 'process_id': 'B', 'sku_id': it['id'], 'repeat': 1, 'started_at': started, 'finished_at': stamp(), 'status': 'generated', 'model_version': 'aliceai-image-art-3.0 (AI Studio Images API) + Pillow ' + __version__, 'input_sha256': it['sha256'], 'prompt_sha256': sha(pr / (it['id'] + '.txt')), 'parameters_path': str(params.relative_to(V2)), 'output_path': str(o.relative_to(V2)), 'output_sha256': sha(o), 'wait_seconds': wait, 'charged_rub': round(2.23 * len(attempts), 2), 'cost_status': f'{len(attempts)} call(s) × 2,23 ₽ by tariff; billing not exported', 'accepted': 'not-checked', 'notes': 'font_px ' + '/'.join(map(str, sizes))}) fields = next(csv.reader((V2 / 'runs.csv').open())) with (out / 'runs-B.csv').open('w') as f: w = csv.DictWriter(f, fieldnames=fields); w.writeheader(); w.writerows(rows) print(json.dumps({'ok': sum(r['status'] == 'generated' for r in rows), 'errors': [r['sku_id'] for r in rows if r['status'] != 'generated'], 'calls': sum(len(json.loads((out / (r['sku_id'] + '-B.json')).read_text())['attempts']) for r in rows if r['status'] == 'generated')}, ensure_ascii=False)) if __name__ == '__main__': main()