store art from the live stores: storebound art pull (App Store screenshot sets and previews per locale and display type from imageAsset.templateUrl at the original size, the App Store icon from the newest build or the public artwork; per Play language icon, feature graphic, phone / 7" / 10" / TV / Wear screenshots as the =s0 originals checked against Play's sha256) into store/renders with pulled.json (sha256, store, URL host, store checksum); upsert (a re-run changes nothing, differing local files kept unless --force, the upload's file of a slot compared: Play .jpg, App Store .png, the App Store icon as a picture); onboard runs it; apple screenshots / previews and play images see unchanged pulled files as the store's own; check: render sources are notes with pulled art; local renders check: pulled files pass, store-empty sets not demanded; art live / thumb / replace; desktop Store art tab: live thumbnails per store, language and set (same / differs / not pulled), Pull from stores, store-only apps read-only, Replace with a file (image_pick, art.replace) in the lightbox and the art editor; boot check store art case; tests/art-pull.test.ts; console parity 76 rows (57 complete, 19 with the reason); store-screenshots skill (pull first for live apps), pitfall 59

This commit is contained in:
CRTkafa 2026-10-14 02:18:38 +08:00
parent 49a73c90e4
commit 00be838b76
40 changed files with 1669 additions and 58 deletions

View file

@ -2,7 +2,8 @@
// raw frame each scene shows (replace a screenshot by pointing the scene at another frame). Saved into the config;
// then render, and upload to each store with the dry run in the window (uploads replace the store's sets).
import { useState } from 'react';
import type { EditableResult, Project } from '../../api';
import { api, errorText, type EditableResult, type Project } from '../../api';
import { useData, useWrite } from '../../store';
import { t } from '../../i18n';
import { ApplyPanel, EditBar, TextField, useEdits } from '../../ui/Editor';
import { Icon, StoreGlyph } from '../../ui/icons';
@ -13,6 +14,20 @@ type Scene = { id: string; palette?: unknown; frames?: { src: string }[] } & Rec
export function ArtEditor({ project, data, onSaved }: { project: Project; data: EditableResult; onSaved: () => void }) {
const e = useEdits();
const w = useWrite(project.id);
const d = useData();
const langs = data.languages.map(l => l.id);
const [frameLang, setFrameLang] = useState(langs[0] ?? '');
const fl = langs.includes(frameLang) ? frameLang : langs[0] ?? '';
const framesDir = ((data.config as unknown as { shots?: { frames?: string } | null }).shots?.frames ?? 'store/shots/frames').replace(/\/+$/, '');
// replace the frame a scene shows (this language, phone) with a file the owner picks: native open dialog, then
// art.replace (dry run with the checks, the native dialog, the copy into the app folder); render again afterwards
const replaceFrame = async (src: string) => {
let from: string | null = null;
try { from = await api.pickImage(project.id); } catch (err) { d.toast({ kind: 'bad', title: t('art.replace'), detail: errorText(err) }); return; }
if (!from) return;
await w.run({ method: 'art.replace', args: { from, to: `${framesDir}/${fl}/phone/${src}` }, title: t('art.replaceTitle', { name: src }), done: t('art.replaced') });
};
const saved = ((data.config as unknown as { shots?: { scenes?: Scene[] } | null }).shots?.scenes ?? []) as Scene[];
const op = { kind: 'config' as const, path: 'shots.scenes' };
const p = e.get(project.id, keyOf(op));
@ -28,7 +43,8 @@ export function ArtEditor({ project, data, onSaved }: { project: Project; data:
};
return (
<div className="stack" id="art-editor" style={{ gap: 16 }}>
<Card title={t('ed.artTitle')} icon="image" sub={t('ed.artSub')} pad={false}>
<Card title={t('ed.artTitle')} icon="image" sub={t('ed.artSub')} pad={false}
actions={langs.length > 1 ? <label className="row small" style={{ gap: 6 }}>{t('ed.frameLang')}<select value={fl} onChange={ev => setFrameLang(ev.target.value)} data-frame-lang>{langs.map(l => <option key={l} value={l}>{l}</option>)}</select></label> : undefined}>
<ol className="ed-scenes">
{scenes.map((s, i) => (
<li key={s.id} data-scene={s.id}>
@ -38,6 +54,7 @@ export function ArtEditor({ project, data, onSaved }: { project: Project; data:
<TextField project={project} area="art" op={{ kind: 'config', path: `shots.scenes[id=${s.id}].frames[0].src` }} saved={saved.find(x => x.id === s.id)?.frames?.[0]?.src ?? ''} label={t('ed.frame')} mono />
</div>
<div className="row" style={{ gap: 4 }}>
{s.frames?.[0]?.src && <button className="btn btn-xs" disabled={!!w.busy} onClick={() => { replaceFrame(s.frames![0]!.src); }} title={`${framesDir}/${fl}/phone/${s.frames[0].src}`} data-action="replace-frame"><Icon name="upload" />{t('art.replace')}</button>}
<button className="btn btn-xs btn-ghost" disabled={i === 0} onClick={() => move(i, -1)} aria-label={t('ed.moveUp', { id: s.id })}><Icon name="arrowLeft" className="rot90" />{t('ed.up')}</button>
<button className="btn btn-xs btn-ghost" disabled={i === scenes.length - 1} onClick={() => move(i, 1)} aria-label={t('ed.moveDown', { id: s.id })}><Icon name="arrowRight" className="rot90" />{t('ed.down')}</button>
</div>