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

@ -14,6 +14,7 @@ import { FolderMissing } from '../ui/FolderMissing';
import { AppIcon, Chip, Notice, PhaseChip, StoreBadge, StoreOnlyTag, StudioMark } from '../ui/kit';
import { AppInfoTab } from './tabs/AppInfo';
import { ArtTab } from './tabs/Art';
import { LiveArt } from './tabs/LiveArt';
import { CatalogTab } from './tabs/Catalog';
import { ChecksTab } from './tabs/Checks';
import { HealthTab } from './tabs/Health';
@ -163,6 +164,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) {
{tab === 'listings' && app.project && <><TabEditor tab={tab} project={app.project} /><ListingsTab project={app.project} /></>}
{tab === 'listings' && !app.project && <StoreListingEditor app={app} />}
{tab === 'art' && app.project && <ArtTab project={app.project} />}
{tab === 'art' && !app.project && <LiveArt target={app.target} />}
{tab === 'health' && <HealthTab app={app} />}
{tab === 'reviews' && <ReviewsTab app={app} />}
{tab === 'testflight' && <TestFlightTab app={app} />}

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>

View file

@ -11,6 +11,7 @@ import { useData, useRead, useWrite } from '../../store';
import { Icon } from '../../ui/icons';
import { Card, Chip, Empty, ErrorNotice, Kpi, Seg, Skeleton } from '../../ui/kit';
import { WriteButton } from '../../ui/WriteButton';
import { LiveArt } from './LiveArt';
// loaded images by project + file + render time, three at a time
const cache = new Map<string, string | null>();
@ -68,7 +69,7 @@ function Thumb({ projectId, tile, stamp, onOpen }: { projectId: string; tile: Re
);
}
function Lightbox({ projectId, tiles, index, stamp, onClose, onMove }: { projectId: string; tiles: RenderTile[]; index: number; stamp: string; onClose: () => void; onMove: (i: number) => void }) {
function Lightbox({ projectId, tiles, index, stamp, onClose, onMove, onReplace }: { projectId: string; tiles: RenderTile[]; index: number; stamp: string; onClose: () => void; onMove: (i: number) => void; onReplace?: (tile: RenderTile) => void }) {
const tile = tiles[index]!;
const file = tile.kind === 'video' ? tile.poster : tile.src;
const [src, setSrc] = useState<string | null>(null);
@ -87,6 +88,7 @@ function Lightbox({ projectId, tiles, index, stamp, onClose, onMove }: { project
<div className="lb-foot">
<div className="grow"><b className="mono-id">{tile.src}</b><div className="small muted">{tile.w}×{tile.h} · {bytes(tile.bytes)} · {num(index + 1)} / {num(tiles.length)}</div>
{tile.issues.map((i, k) => <div key={k} className={`small ${i.level === 'error' ? 'bad-ink' : 'warn-ink'}`}>{i.msg}</div>)}</div>
{onReplace && tile.kind === 'image' && <button className="btn btn-sm" onClick={() => onReplace(tile)} data-action="replace-image"><Icon name="upload" />{t('art.replace')}</button>}
<button className="btn btn-sm" onClick={() => onMove((index - 1 + tiles.length) % tiles.length)} aria-label={t('art.prev')}><Icon name="arrowLeft" /></button>
<button className="btn btn-sm" onClick={() => onMove((index + 1) % tiles.length)} aria-label={t('art.next')}><Icon name="arrowRight" /></button>
<button className="btn btn-sm btn-ghost" onClick={onClose}><Icon name="x" />{t('common.close')}</button>
@ -113,8 +115,17 @@ export function ArtTab({ project }: { project: Project }) {
const dir = data?.dir ?? 'store/renders';
const reveal = useCallback((rel: string, openIt = false) => { api.reveal(project.id, rel, openIt).catch(e => d.toast({ kind: 'info', title: t('art.notThere'), detail: errorText(e) })); }, [project.id, d]);
const stamp = data?.generated ?? '';
// replace one image with a file the owner picks: native open dialog, then art.replace (dry run with the store
// checks, the native dialog, the copy into the app folder)
const replace = useCallback(async (tile: RenderTile) => {
let from: string | null = null;
try { from = await api.pickImage(project.id); } catch (e) { d.toast({ kind: 'bad', title: t('art.replace'), detail: errorText(e) }); return; }
if (!from) return;
await w.run({ method: 'art.replace', args: { from, to: `${data?.dir ?? 'store/renders'}/${tile.src}` }, title: t('art.replaceTitle', { name: tile.name }), done: t('art.replaced'), after: r.refresh });
}, [project.id, d, w, data?.dir, r.refresh]);
if (!data) return r.error ? <ErrorNotice error={r.error} tone="bad" /> : <Card><Skeleton lines={6} block /></Card>;
// the live art stays the first child in both branches, so it keeps its state when the local renders arrive
if (!data) return <><LiveArt target={project.id} project={project} />{r.error ? <ErrorNotice error={r.error} tone="bad" /> : <Card><Skeleton lines={6} block /></Card>}</>;
const total = data.totals.images + data.totals.videos;
const toolbar = (
<>
@ -126,6 +137,7 @@ export function ArtTab({ project }: { project: Project }) {
);
return (
<>
<LiveArt target={project.id} project={project} />
<div className="kpis" style={{ gridTemplateColumns: 'repeat(4, minmax(0, 1fr))' }}>
<Kpi icon="image" label={t('art.images')} value={num(data.totals.images)} sub={t('art.imagesSub', { count: langs.length })} />
<Kpi icon="play" label={t('art.videos')} value={num(data.totals.videos)} sub={t('art.videosSub')} />
@ -162,7 +174,7 @@ export function ArtTab({ project }: { project: Project }) {
)}
<div className="card-foot"><Icon name="info" />{t('art.foot', { when: ago(r.at) })}</div>
</Card>
{open && <Lightbox projectId={project.id} tiles={open.tiles} index={open.i} stamp={stamp} onClose={() => setOpen(null)} onMove={i => setOpen(o => (o ? { ...o, i } : o))} />}
{open && <Lightbox projectId={project.id} tiles={open.tiles} index={open.i} stamp={stamp} onClose={() => setOpen(null)} onMove={i => setOpen(o => (o ? { ...o, i } : o))} onReplace={tile => { setOpen(null); replace(tile); }} />}
</>
);
}

View file

@ -0,0 +1,133 @@
// The store art that is live on App Store Connect and Google Play (art.live), per store, language and set, with
// thumbnails (art.thumb: one data: URL each, loaded as they scroll into view; the window loads no remote images
// itself) and, for an app folder, whether the folder has the same file. "Pull from stores" runs art.pull (dry run,
// native dialog, then the files are written: local only, read-only on the stores). Store-only apps: read-only.
import { useEffect, useMemo, useRef, useState } from 'react';
import { api, type ArtThumb, type LiveArtResult, type LiveArtTile, type Project, type ToolResult } from '../../api';
import { t } from '../../i18n';
import { num } from '../../lib/format';
import { useRead, useWrite } from '../../store';
import { Icon } from '../../ui/icons';
import { Card, Chip, Empty, ErrorNotice, Notice, ReadControl, Skeleton } from '../../ui/kit';
import { WriteButton } from '../../ui/WriteButton';
// thumbnails by target + URL, three at a time
const cache = new Map<string, string | null>();
const waiting: (() => Promise<void>)[] = [];
let active = 0;
function pump() {
while (active < 3 && waiting.length) {
const job = waiting.shift()!;
active++;
job().finally(() => { active--; pump(); });
}
}
export function loadThumb(target: string, url: string): Promise<string | null> {
const key = `${target}|${url}`;
if (cache.has(key)) return Promise.resolve(cache.get(key)!);
return new Promise(resolve => {
waiting.push(async () => {
try {
const r = await api.call<ToolResult & { data?: ArtThumb }>('art.thumb', target, { args: { url } });
cache.set(key, r.data?.dataUrl ?? null);
} catch { cache.set(key, null); }
resolve(cache.get(key) ?? null);
});
pump();
});
}
function LiveThumb({ target, tile, name }: { target: string; tile: LiveArtTile; name: string }) {
const ref = useRef<HTMLDivElement>(null);
const [src, setSrc] = useState<string | null | undefined>(tile.thumb ? cache.get(`${target}|${tile.thumb}`) : null);
useEffect(() => {
if (!tile.thumb || src !== undefined) return;
const el = ref.current;
if (!el) return;
let alive = true;
const io = new IntersectionObserver(es => {
if (!es.some(x => x.isIntersecting)) return;
io.disconnect();
loadThumb(target, tile.thumb).then(u => { if (alive) setSrc(u); });
}, { rootMargin: '300px' });
io.observe(el);
return () => { alive = false; io.disconnect(); };
}, [target, tile.thumb, src]);
const w = tile.w ?? 9, h = tile.h ?? 16;
const state = tile.state === 'n/a' ? null : tile.state;
return (
<figure className={`art-tile${state === 'differs' ? ' warn' : ''}`} style={{ ['--ar' as string]: `${w} / ${h}` }} data-live-tile={tile.state}>
<div className="art-pic" ref={ref} role="img" aria-label={name}>
{src ? <img src={src} alt="" draggable={false} /> : <span className="art-ph">{src === null && tile.thumb ? <><Icon name="image" />{t('art.liveNoThumb')}</> : <span className="sk sk-block" />}</span>}
</div>
<figcaption><span className="mono-id">{name}</span><span className="muted">{tile.w && tile.h ? `${tile.w}×${tile.h}` : ''}</span></figcaption>
{state && <span className={`small ${state === 'same' ? 'ok-ink' : state === 'differs' ? 'warn-ink' : 'muted'}`}>{t(state === 'same' ? 'art.liveSame' : state === 'differs' ? 'art.liveDiffers' : 'art.liveMissing')}</span>}
</figure>
);
}
/** Live store art of one app: `target` is the project id, or a store-only app's store target. */
export function LiveArt({ target, project }: { target: string; project?: Project }) {
const r = useRead<LiveArtResult>(target, 'art.live', { network: true, title: t('art.liveTitle') });
const w = useWrite(project?.id ?? null);
const [store, setStore] = useState<'apple' | 'play' | ''>('');
const [locale, setLocale] = useState('');
const data = r.data;
const stores = useMemo(() => [...new Set((data?.sets ?? []).map(s => s.store))], [data]);
const st = stores.includes(store as 'apple') ? store : stores[0] ?? '';
const locales = useMemo(() => [...new Set((data?.sets ?? []).filter(s => s.store === st).map(s => s.locale))].sort(), [data, st]);
const loc = locales.includes(locale) ? locale : locales[0] ?? '';
const sets = (data?.sets ?? []).filter(s => s.store === st && s.locale === loc);
const pull = project ? (
<WriteButton run={w} primary icon="download" w={{ method: 'art.pull', args: {}, title: t('art.pull'), done: t('art.pulled'), after: () => { r.refresh(); } }} hint={t('art.pullHint')}>{t('art.pull')}</WriteButton>
) : null;
const actions = <>{pull}<ReadControl r={r} /></>;
if (!data) {
return (
<Card title={t('art.liveTitle')} icon="cloud" actions={actions} id="live-art" pad={false}>
{r.loading && !r.error ? <div className="card-body"><Skeleton lines={4} block /></div>
: <Empty compact illo="text" title={t('art.liveEmpty')} actions={<button className="btn btn-primary btn-sm" onClick={() => { r.refresh(); }}><Icon name="refresh" />{t('art.liveRead')}</button>}>{r.error ?? t('art.liveEmptyText')}</Empty>}
</Card>
);
}
const total = data.sets.reduce((n, s) => n + s.tiles.length, 0);
const differs = data.sets.reduce((n, s) => n + s.tiles.filter(x => x.state === 'differs').length, 0);
const missing = data.sets.reduce((n, s) => n + s.tiles.filter(x => x.state === 'missing').length, 0);
const sub = data.stores.map(s => `${s.store === 'apple' ? 'App Store' : 'Google Play'}: ${s.ok ? t('art.liveCount', { count: s.images }) : t('art.liveFailed')}${s.version ? ` · ${s.version}` : ''}`).join(' · ');
return (
<Card title={t('art.liveTitle')} icon="cloud" sub={sub} actions={actions} id="live-art" pad={false}>
{data.stores.filter(s => !s.ok).map(s => <div className="card-body" key={s.store}><ErrorNotice error={`${s.store === 'apple' ? 'App Store' : 'Google Play'}: ${s.error ?? ''}`} /></div>)}
<div className="card-body stack" style={{ gap: 10 }}>
{!project && <Notice tone="info" icon="info">{t('art.liveReadOnly')}</Notice>}
{project && total > 0 && (missing > 0 || differs > 0) && <Notice tone={differs ? 'warn' : 'info'} icon="info">{t('art.liveSummary', { missing: num(missing), differs: num(differs) })}</Notice>}
{project && total > 0 && !missing && !differs && <Notice tone="ok" icon="check">{t('art.liveAllSame')}</Notice>}
{data.icons.length > 0 && (
<div className="row" style={{ gap: 16, flexWrap: 'wrap', alignItems: 'flex-start' }} data-live-icons>
{data.icons.map(i => <div key={i.store} style={{ width: 96 }}><LiveThumb target={target} tile={{ ...i, w: 1, h: 1 }} name={i.store === 'apple' ? t('art.iconApple') : t('art.iconPlay')} /></div>)}
</div>
)}
{stores.length > 1 && (
<div className="row" style={{ gap: 8 }} role="tablist" aria-label={t('art.liveStore')}>
{stores.map(s => <button key={s} role="tab" aria-selected={s === st} className={`btn btn-sm${s === st ? ' btn-primary' : ''}`} onClick={() => setStore(s)}>{s === 'apple' ? 'App Store' : 'Google Play'}</button>)}
</div>
)}
<div className="fchips" role="group" aria-label={t('col.language')}>
{locales.map(l => <button key={l} className="fchip" aria-pressed={l === loc} onClick={() => setLocale(l)} data-live-locale={l}>{l}</button>)}
</div>
</div>
{!total ? <Empty compact illo="text" title={t('art.liveNone')}>{t('art.liveNoneText')}</Empty> : (
<div className="art-rows">
{sets.map(s => (
<section className="art-row" key={`${s.store}-${s.type}`} data-live-set={`${s.store}:${s.locale}:${s.type}`}>
<h3><span>{s.label}</span>{s.lang && <code>{s.lang}/{s.size}</code>}<Chip tone="outline" dot={false}>{s.store === 'apple' ? 'App Store' : 'Google Play'}</Chip><span className="muted small">{t('art.count', { count: s.tiles.length })}</span></h3>
<div className="art-tiles" style={{ ['--tw' as string]: s.size === 'feature' ? '300px' : s.tiles[0] && (s.tiles[0].w ?? 0) > (s.tiles[0].h ?? 1) ? '260px' : '150px' }}>
{s.tiles.map(tile => <LiveThumb key={`${tile.index}-${tile.thumb}`} target={target} tile={tile} name={String(tile.index + 1).padStart(2, '0')} />)}
</div>
</section>
))}
</div>
)}
<div className="card-foot"><Icon name="info" />{project ? t('art.liveFoot') : t('art.liveFootStoreOnly')}</div>
</Card>
);
}