feat: add a separate watch thumbnail gallery and logical device
This commit is contained in:
@@ -601,7 +601,8 @@ def create_app(settings=None, start_worker=True):
|
||||
return [
|
||||
assets.info(r["id"])
|
||||
for r in db.all(
|
||||
"SELECT assets.id FROM assets LEFT JOIN asset_metadata ON asset_metadata.asset_id=assets.id WHERE kind!='cache' AND "
|
||||
"SELECT assets.id FROM assets LEFT JOIN asset_metadata ON asset_metadata.asset_id=assets.id WHERE kind!='cache' "
|
||||
"AND NOT EXISTS (SELECT 1 FROM asset_sources s WHERE s.asset_id=assets.id AND json_extract(s.payload,'$.type')='thumbnail') AND "
|
||||
"(?='all' OR (?='uploaded' AND kind='images') OR (?='generated' AND kind='generated')) "
|
||||
"AND (?='all' OR COALESCE(origin,'unknown')=?) AND (? IS NULL OR job_id=?) "
|
||||
"ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
@@ -642,6 +643,16 @@ def create_app(settings=None, start_worker=True):
|
||||
db.execute("DELETE FROM asset_metadata WHERE asset_id=?", (asset_id,))
|
||||
db.execute("DELETE FROM asset_sources WHERE asset_id=?", (asset_id,))
|
||||
db.execute("DELETE FROM assets WHERE id=?", (asset_id,))
|
||||
for row in db.all(
|
||||
"SELECT key,payload FROM app_config WHERE key LIKE 'wrist-thumbnail:%'"
|
||||
):
|
||||
import json
|
||||
|
||||
if json.loads(row["payload"]).get("asset_id") == asset_id:
|
||||
db.execute(
|
||||
"DELETE FROM app_config WHERE key=? AND payload=?",
|
||||
(row["key"], row["payload"]),
|
||||
)
|
||||
return Response(status_code=204)
|
||||
|
||||
@app.put(
|
||||
@@ -1095,6 +1106,14 @@ def create_app(settings=None, start_worker=True):
|
||||
or bool(settings.api_key)
|
||||
or any(registry.keys),
|
||||
),
|
||||
GalleryDestination(
|
||||
id="watch",
|
||||
adapter="watch-thumbnails",
|
||||
name="Watch",
|
||||
storage="local",
|
||||
connected=True,
|
||||
supports_upload=True,
|
||||
),
|
||||
GalleryDestination(
|
||||
id="local",
|
||||
adapter="local-assets",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import json
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi import Depends, Query
|
||||
from fastapi.responses import Response
|
||||
from pydantic import Field
|
||||
|
||||
@@ -24,6 +24,50 @@ class ThumbnailRequest(StrictModel):
|
||||
|
||||
|
||||
def install_thumbnails(app, service):
|
||||
from pigway_frame_flow.display.thumbnail_gallery import record, recover
|
||||
|
||||
recover(service)
|
||||
|
||||
@app.get(
|
||||
"/api/v1/thumbnail-devices", dependencies=[Depends(require("images:read"))]
|
||||
)
|
||||
def devices():
|
||||
return [
|
||||
{
|
||||
"id": "watch",
|
||||
"name": "Watch",
|
||||
"width": 410,
|
||||
"height": 502,
|
||||
"transport_connected": False,
|
||||
"status": "generation_only",
|
||||
}
|
||||
]
|
||||
|
||||
@app.get("/api/v1/thumbnails", dependencies=[Depends(require("images:read"))])
|
||||
def gallery(offset: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200)):
|
||||
rows = service.db.all(
|
||||
"SELECT assets.id,asset_sources.payload FROM assets JOIN asset_sources ON asset_sources.asset_id=assets.id "
|
||||
"WHERE json_extract(asset_sources.payload,'$.type')='thumbnail' "
|
||||
"ORDER BY assets.created_at DESC,assets.id DESC LIMIT ? OFFSET ?",
|
||||
(limit, offset),
|
||||
)
|
||||
total = service.db.one(
|
||||
"SELECT COUNT(*) AS count FROM assets JOIN asset_sources ON asset_sources.asset_id=assets.id "
|
||||
"WHERE json_extract(asset_sources.payload,'$.type')='thumbnail'"
|
||||
)["count"]
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
**service.assets.info(row["id"]),
|
||||
"thumbnail": json.loads(row["payload"]),
|
||||
}
|
||||
for row in rows
|
||||
],
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
@app.get("/api/v1/thumbnail-plans", dependencies=[Depends(require("images:read"))])
|
||||
def plans():
|
||||
return [
|
||||
@@ -113,6 +157,18 @@ def install_thumbnails(app, service):
|
||||
|
||||
@app.post("/api/v1/thumbnails/generate", dependencies=[Depends(require("admin"))])
|
||||
def generate(request: ThumbnailRequest):
|
||||
return service.assets.put(
|
||||
asset = service.assets.put(
|
||||
image(request), request.plan.name + ".jpg", "generated"
|
||||
)
|
||||
record(
|
||||
service,
|
||||
asset["asset_id"],
|
||||
{
|
||||
"width": request.plan.width,
|
||||
"height": request.plan.height,
|
||||
"plan": request.plan.model_dump(),
|
||||
"generated_at": asset["created_at"],
|
||||
"source": {"source_id": request.source_id, "item_id": request.item_id},
|
||||
},
|
||||
)
|
||||
return asset
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Separate watch gallery metadata; image files retain existing asset access rules."""
|
||||
|
||||
import json
|
||||
|
||||
from pigway_frame_flow.storage.database import encode
|
||||
|
||||
TARGET_ID = "watch"
|
||||
|
||||
|
||||
def record(service, asset_id, metadata):
|
||||
service.db.execute(
|
||||
"INSERT OR REPLACE INTO asset_sources VALUES(?,?)",
|
||||
(asset_id, encode({**metadata, "type": "thumbnail", "target_id": TARGET_ID})),
|
||||
)
|
||||
|
||||
|
||||
def recover(service):
|
||||
# Earlier automatic thumbnails have authoritative operation receipts. Never
|
||||
# guess that an unrelated image is a thumbnail from its name or dimensions.
|
||||
rows = service.db.all("SELECT result FROM operations WHERE result IS NOT NULL")
|
||||
receipts = [json.loads(row["result"]).get("wrist_thumbnail") for row in rows]
|
||||
receipts += [
|
||||
json.loads(row["payload"])
|
||||
for row in service.db.all(
|
||||
"SELECT payload FROM app_config WHERE key LIKE 'wrist-thumbnail:%'"
|
||||
)
|
||||
]
|
||||
for receipt in receipts:
|
||||
if not isinstance(receipt, dict) or not receipt.get("asset_id"):
|
||||
continue
|
||||
asset_id = receipt["asset_id"]
|
||||
if service.db.one(
|
||||
"SELECT id FROM assets WHERE id=?", (asset_id,)
|
||||
) and not service.db.one(
|
||||
"SELECT asset_id FROM asset_sources WHERE asset_id=?", (asset_id,)
|
||||
):
|
||||
record(service, asset_id, receipt)
|
||||
@@ -54,6 +54,9 @@ def publish(service, device_id, asset_id, data=None, plan_id=None):
|
||||
if service.photo_sources.get(source["source_id"]).kind == "synology-photos"
|
||||
else "source",
|
||||
}
|
||||
from pigway_frame_flow.display.thumbnail_gallery import record
|
||||
|
||||
record(service, asset["asset_id"], {**result, "source_frame_id": device_id})
|
||||
service.db.execute(
|
||||
"INSERT OR REPLACE INTO app_config VALUES(?,?)",
|
||||
("wrist-thumbnail:" + device_id, encode(result)),
|
||||
|
||||
@@ -48,16 +48,16 @@ function operationResultHTML(o){
|
||||
}
|
||||
function operationErrorLabel(error){if(!error?.code)return '';const label=t(error.code);return label===error.code?t('error'):label;}
|
||||
function deviceDisplayName(device){const name=device.name||device.id;return device.id==='living-room'&&['living-room','Living room'].includes(name)?t('livingRoom'):name;}
|
||||
function shell(){document.documentElement.lang=state.lang==='zh'?'zh-CN':'en';$('#lang').textContent=state.lang==='zh'?'EN':'中文';$('#theme').title=t('theme');$('#nav').innerHTML=Object.entries(icons).map(([id,icon])=>`<button data-page="${id}" class="${id===state.page?'active':''}" ${id===state.page?'aria-current="page"':''}><span class="nav-icon">${icon}</span><span>${t(id)}</span></button>`).join('');document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));$('#page-title').textContent=t(state.page);$('#device').innerHTML=(state.devices.length?state.devices:[{id:state.device,name:state.device}]).map(d=>`<option value="${esc(d.id)}" ${d.id===state.device?'selected':''}>${esc(deviceDisplayName(d))}</option>`).join('');revealCurrentNavigation();}
|
||||
function shell(){document.documentElement.lang=state.lang==='zh'?'zh-CN':'en';$('#lang').textContent=state.lang==='zh'?'EN':'中文';$('#theme').title=t('theme');$('#nav').innerHTML=Object.entries(icons).map(([id,icon])=>`<button data-page="${id}" class="${id===state.page?'active':''}" ${id===state.page?'aria-current="page"':''}><span class="nav-icon">${icon}</span><span>${t(id)}</span></button>`).join('');document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));$('#page-title').textContent=t(state.page);const watchSelected=state.page==='gallery'&&state.tab==='watch';$('#device').innerHTML=(state.devices.length?state.devices:[{id:state.device,name:state.device}]).map(d=>`<option value="${esc(d.id)}" ${!watchSelected&&d.id===state.device?'selected':''}>${esc(deviceDisplayName(d))}</option>`).join('')+`<option value="@watch" ${watchSelected?'selected':''}>${tr('手表 · 410×502','Watch · 410×502')}</option>`;revealCurrentNavigation();}
|
||||
// A destination appears only when its connection and read/write adapter are available.
|
||||
// Future cloud-drive destinations must implement their adapter before registration here.
|
||||
const galleryDestinationAdapters=new Set(['kokonna','local-assets']);
|
||||
const galleryDestinationAdapters=new Set(['kokonna','local-assets','watch-thumbnails']);
|
||||
function galleryDestinations(){
|
||||
const configured=state.settings?.gallery_destinations;
|
||||
const destinations=Array.isArray(configured)?configured:[{id:'cloud',adapter:'kokonna',name:'KoKonna',storage:'cloud',connected:true,supports_upload:true},{id:'local',adapter:'local-assets',name:state.settings?.storage_name||'NAS',storage:'local',connected:true,supports_upload:true}];
|
||||
return destinations.filter(d=>d.connected&&d.supports_upload&&galleryDestinationAdapters.has(d.adapter));
|
||||
}
|
||||
function galleryDestinationName(destination){return `${destination.name} ${t(destination.storage==='local'?'local':'cloudGallery')}`;}
|
||||
function galleryDestinationName(destination){if(destination.id==='watch')return tr('手表 · 小图图库','Watch · Thumbnail gallery');return `${destination.name} ${t(destination.storage==='local'?'local':'cloudGallery')}`;}
|
||||
function imageLoading(img){
|
||||
if(img.parentElement?.classList.contains('image-loading'))return img.parentElement;
|
||||
const box=document.createElement('div');box.className='image-loading';
|
||||
@@ -134,9 +134,10 @@ const views={
|
||||
async dashboard(epoch){const d=await api('/api/v1/devices/'+state.device);if(epoch!==state.epoch)return;state.devices=state.devices.map(x=>x.id===d.id?{...x,name:d.name}:x);shell();const s=d.slideshow;$('#main').innerHTML=notice()+`${d.power.battery_percent!==null&&d.power.battery_percent<20?`<div class="notice warning">${t('lowBattery')}</div>`:''}<section class="card hero"><div class="hero-info"><span class="badge ${d.status==='offline'?'bad':''}">● ${t(d.status).toUpperCase()}</span><h2>${esc(d.name)}</h2><p>${esc(d.model)} · ${esc(d.model_code)} · ${d.display.width} × ${d.display.height}</p><div class="actions">${buttons('go-upload','upload',true)}${buttons('restore','restore')}${buttons('refresh-device','refresh')}</div><p class="hint" style="margin-top:28px">${t('refreshInterval')}</p></div><div class="frame-demo"></div></section><div class="stats">${[['battery',d.power.battery_percent===null?'—':d.power.battery_percent+'%'],['currentImageId',d.current_content.id??'—'],['sync',t(d.current_content.synced?'synced':'syncing')],['firmware',d.firmware]].map(([l,v])=>`<div class="card stat"><div class="label">${t(l)}</div><div class="value">${esc(v)}</div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('device')}</h3><div class="detail-grid">${detail('heartbeat',fmt(d.last_seen_at))}${detail('rotation',d.display.rotation+'°')}${detail('timezone',d.timezone)}${detail('coverId',d.current_content.cover_id??'—')}${detail('storage',d.storage_used_bytes===null?'—':(d.storage_used_bytes/1024/1024).toFixed(1)+' MiB')}${detail('modelSource',t(d.model_verified?'modelVerified':'modelConfigured'))}</div></section><section class="card"><h3>${t('cloudRotation')}</h3><div class="detail-grid">${detail('playbackMode',s.mode?t(s.mode):'—')}${detail('switchInterval',s.interval_minutes==null?'—':s.interval_minutes+' '+t('minutes'))}</div><p class="hint">${t('rotationNotice')}</p><a href="#playlists" id="go-playlists">${t('playlists')} →</a></section></div>`;bind('go-upload',()=>navigate('upload'));bind('refresh-device',async()=>{await api('/api/v1/devices/'+state.device+'?refresh=true');await navigate();});$('#restore').disabled=!d.can_restore;bind('restore',()=>accepted('/api/v1/devices/'+state.device+'/restore',{}));$('#go-playlists').onclick=e=>{e.preventDefault();navigate('playlists');};},
|
||||
async gallery(epoch){
|
||||
const destinations=galleryDestinations();if(!destinations.some(d=>d.id===state.tab))state.tab=destinations[0]?.id||'local';
|
||||
if(state.tab==='watch')return watchGalleryPage(epoch,destinations);
|
||||
const key=JSON.stringify([state.device,state.tab,state.assetOffset||0,state.assetOrigin||'all',state.assetJob||'']);
|
||||
const cache=state.galleryCache||(state.galleryCache=new Map());const cached=cache.get(key);
|
||||
const render=(items,pending=false)=>{if(epoch!==state.epoch)return;state.gallery=items;$('#main').innerHTML=notice()+`<div class="section-heading"><div class="tabs gallery-tabs">${destinations.map(d=>`<button id="${esc(d.id)}-tab" data-gallery-destination="${esc(d.id)}" class="${state.tab===d.id?'active':''}">${esc(galleryDestinationName(d))}</button>`).join('')}</div><div class="actions">${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}<span id="gallery-list-status" class="hint" role="status" ${pending?'':'hidden'}>${pending?t('galleryListLoading'):''}</span></div></div>${state.tab==='local'?`<div class="notice">${t('uploadNotice')}</div>`:''}${`<div class="gallery" aria-busy="${pending}">${items.map((x,i)=>`<article class="card image-card"><button class="image-preview" data-original="${i}" aria-label="${t('original')} · ${esc(x.name)}"><img class="thumb" alt="${esc(x.name)}" data-src="${esc(x.url+(state.tab==='cloud'?'?thumbnail=true':''))}"></button><h3 class="${state.tab==='local'?'source-path':''}" title="${esc(x.source_path||x.name)}">${esc(state.tab==='local'?(x.source_path||x.name):x.name)}</h3><small>${state.tab==='cloud'?'#'+x.image_id:t('local')} · ${esc(fmt(x.created_at))}</small><div class="actions"><button class="primary" data-show="${i}" ${x.current?'disabled':''}>${t(x.current?'current':'display')}</button><button class="danger" data-delete="${i}">${t('delete')}</button></div></article>`).join('')}</div>`}${!pending&&!items.length?`<div class="card empty"><strong>${t('noImages')}</strong>${t('emptyHint')}</div>`:''}`;bind('cloud-tab',()=>{state.tab='cloud';return navigate();});bind('local-tab',()=>{state.tab='local';return navigate();});bind('gallery-refresh',()=>navigate());bind('gallery-upload',()=>navigate('upload'));bind('show-name',()=>{showModal(`<h2>${t('displayByName')}</h2><div class="field"><label for="image-name">${t('name')}</label><input id="image-name" maxlength="120"></div><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}</div>`);bind('show-name-confirm',async()=>{const name=$('#image-name').value;$('#modal').close();displayDialog({type:'image',image_name:name});});});enhanceGallery();guardDeviceButtons();hydrateImages();document.querySelectorAll('[data-show]').forEach(b=>b.onclick=busy(()=>{const x=items[b.dataset.show];if(x.current)return;return displayDialog(state.tab==='cloud'?{type:'image',image_id:x.image_id}:{type:'image',asset_id:x.asset_id});}));document.querySelectorAll('[data-original]').forEach(b=>b.onclick=busy(async()=>{const x=items[b.dataset.original];await openImageViewer(x.url,x.name);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(state.tab==='local'?t('localDeleteConfirm'):x.current?t('deleteCurrent'):t('cloudDeleteConfirm'),async()=>{if(state.tab==='local'){await api('/api/v1/assets/'+x.asset_id,{method:'DELETE'});}else{const result=await api(`/api/v1/devices/${state.device}/images/${x.image_id}?confirm_current=${x.current}`,{method:'DELETE',headers:{'Idempotency-Key':'web-'+clientUUID()}});await waitOperation(result.operation_id);}await navigate();});});};
|
||||
const render=(items,pending=false)=>{if(epoch!==state.epoch)return;state.gallery=items;$('#main').innerHTML=notice()+`<div class="section-heading"><div class="tabs gallery-tabs">${destinations.map(d=>`<button id="${esc(d.id)}-tab" data-gallery-destination="${esc(d.id)}" class="${state.tab===d.id?'active':''}">${esc(galleryDestinationName(d))}</button>`).join('')}</div><div class="actions">${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}<span id="gallery-list-status" class="hint" role="status" ${pending?'':'hidden'}>${pending?t('galleryListLoading'):''}</span></div></div>${state.tab==='local'?`<div class="notice">${t('uploadNotice')}</div>`:''}${`<div class="gallery" aria-busy="${pending}">${items.map((x,i)=>`<article class="card image-card"><button class="image-preview" data-original="${i}" aria-label="${t('original')} · ${esc(x.name)}"><img class="thumb" alt="${esc(x.name)}" data-src="${esc(x.url+(state.tab==='cloud'?'?thumbnail=true':''))}"></button><h3 class="${state.tab==='local'?'source-path':''}" title="${esc(x.source_path||x.name)}">${esc(state.tab==='local'?(x.source_path||x.name):x.name)}</h3><small>${state.tab==='cloud'?'#'+x.image_id:t('local')} · ${esc(fmt(x.created_at))}</small><div class="actions"><button class="primary" data-show="${i}" ${x.current?'disabled':''}>${t(x.current?'current':'display')}</button><button class="danger" data-delete="${i}">${t('delete')}</button></div></article>`).join('')}</div>`}${!pending&&!items.length?`<div class="card empty"><strong>${t('noImages')}</strong>${t('emptyHint')}</div>`:''}`;bind('cloud-tab',()=>{state.tab='cloud';return navigate();});bind('local-tab',()=>{state.tab='local';return navigate();});bind('watch-tab',()=>{state.tab='watch';return navigate();});bind('gallery-refresh',()=>navigate());bind('gallery-upload',()=>navigate('upload'));bind('show-name',()=>{showModal(`<h2>${t('displayByName')}</h2><div class="field"><label for="image-name">${t('name')}</label><input id="image-name" maxlength="120"></div><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}</div>`);bind('show-name-confirm',async()=>{const name=$('#image-name').value;$('#modal').close();displayDialog({type:'image',image_name:name});});});enhanceGallery();guardDeviceButtons();hydrateImages();document.querySelectorAll('[data-show]').forEach(b=>b.onclick=busy(()=>{const x=items[b.dataset.show];if(x.current)return;return displayDialog(state.tab==='cloud'?{type:'image',image_id:x.image_id}:{type:'image',asset_id:x.asset_id});}));document.querySelectorAll('[data-original]').forEach(b=>b.onclick=busy(async()=>{const x=items[b.dataset.original];await openImageViewer(x.url,x.name);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(state.tab==='local'?t('localDeleteConfirm'):x.current?t('deleteCurrent'):t('cloudDeleteConfirm'),async()=>{if(state.tab==='local'){await api('/api/v1/assets/'+x.asset_id,{method:'DELETE'});}else{const result=await api(`/api/v1/devices/${state.device}/images/${x.image_id}?confirm_current=${x.current}`,{method:'DELETE',headers:{'Idempotency-Key':'web-'+clientUUID()}});await waitOperation(result.operation_id);}await navigate();});});};
|
||||
render(cached||[],!cached);
|
||||
try{
|
||||
const items=state.tab==='cloud'?await api('/api/v1/devices/'+state.device+'/images'):await api('/api/v1/assets?limit=200&offset='+(state.assetOffset||0)+'&origin='+encodeURIComponent(state.assetOrigin||'all')+(state.assetJob?'&job_id='+encodeURIComponent(state.assetJob):''));
|
||||
@@ -156,7 +157,7 @@ const views={
|
||||
};
|
||||
function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,cleanup_unstarred:j.cleanup_unstarred||false,generate_thumbnail:j.generate_thumbnail||false,thumbnail_plan_id:j.thumbnail_plan_id||null,...changes};}
|
||||
async function playlistEditor(p){p=p||{name:'',image_ids:[],interval_minutes:60,mode:'queue',enabled:false};showModal(`<h2>${t('playlists')}</h2><form id="playlist-form"><div class="field"><label for="playlist-name">${t('name')}</label><input id="playlist-name" required maxlength="120" value="${esc(p.name)}"></div><div class="field"><label for="playlist-images">${t('imageIds')}</label><input id="playlist-images" required value="${esc(p.image_ids.join(','))}" placeholder="1,2,3"></div><div class="form-grid"><div class="field"><label for="playlist-interval">${t('interval')}</label><input id="playlist-interval" type="number" min="5" max="10080" value="${p.interval_minutes}"></div><div class="field"><label for="playlist-mode">${t('playbackMode')}</label><select id="playlist-mode"><option value="queue" ${p.mode==='queue'?'selected':''}>${t('queue')}</option><option value="random" ${p.mode==='random'?'selected':''}>${t('random')}</option></select></div></div><label class="check-row"><input id="playlist-enabled" type="checkbox" ${p.enabled?'checked':''}>${t('enabled')}</label><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);$('#playlist-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/playlists'+(p.id?'/'+p.id:''),{method:p.id?'PUT':'POST',body:{name:$('#playlist-name').value,device_id:p.device_id||state.device,image_ids:$('#playlist-images').value.split(',').map(x=>Number(x.trim())),interval_minutes:Number($('#playlist-interval').value),mode:$('#playlist-mode').value,enabled:$('#playlist-enabled').checked}});$('#modal').close();await navigate();});await playlistImagePicker(p);}
|
||||
$('#lang').onclick=()=>{state.lang=state.lang==='zh'?'en':'zh';localStorage.setItem('ff-language',state.lang);navigate();};$('#theme').onclick=()=>{state.theme=document.documentElement.dataset.theme==='dark'?'light':'dark';localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#device').onchange=()=>{state.device=$('#device').value;navigate();};window.addEventListener('hashchange',()=>{const page=location.hash.slice(1);if(page!==state.page)navigate(page);});matchMedia('(prefers-color-scheme:dark)').addEventListener('change',applyTheme);
|
||||
$('#lang').onclick=()=>{state.lang=state.lang==='zh'?'en':'zh';localStorage.setItem('ff-language',state.lang);navigate();};$('#theme').onclick=()=>{state.theme=document.documentElement.dataset.theme==='dark'?'light':'dark';localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#device').onchange=()=>{if($('#device').value==='@watch'){state.tab='watch';state.watchOffset=0;return navigate('gallery');}state.device=$('#device').value;if(state.page==='gallery'&&state.tab==='watch')state.tab='cloud';navigate();};window.addEventListener('hashchange',()=>{const page=location.hash.slice(1);if(page!==state.page)navigate(page);});matchMedia('(prefers-color-scheme:dark)').addEventListener('change',applyTheme);
|
||||
applyTheme();shell();localStorage.removeItem('ff-login');(async()=>{try{const account=await api('/api/v1/auth/me');state.token=account.username;state.csrf=account.csrf_token;await loadConnection();}catch{forgetLogin();}await navigate();})();api('/api/v1/version').then(v=>$('#footer').innerHTML=`<span>PIGWay Frame Flow · ${t('footer')}</span><span>v${esc(v.version)} · ${esc(v.commit.slice(0,8))} · ${esc(v.build_time)}</span>`).catch(()=>{});
|
||||
setInterval(()=>{if(state.token&&state.page==='dashboard'&&!$('#modal').open)navigate();},60000);
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@ Object.assign(MESSAGES.en, {thumbnails:'Thumbnail plans',thumbnailPlanInUse:'Thi
|
||||
views.thumbnails=async function(epoch){
|
||||
const [plans,sources]=await Promise.all([api('/api/v1/thumbnail-plans'),api('/api/v1/photo-sources')]);
|
||||
if(epoch!==state.epoch)return;
|
||||
$('#main').innerHTML=`<section class="card"><div class="actions"><select id="thumb-plan"><option value="">${tr('新建方案','New plan')}</option>${plans.map(p=>`<option value="${esc(p.id)}">${esc(p.name)} · ${p.width}×${p.height}</option>`).join('')}</select><button id="thumb-delete" class="danger">${t('delete')}</button></div><form id="thumb-form"><div class="field"><label for="thumb-name">${t('name')}</label><input id="thumb-name" required maxlength="120"></div><div class="form-grid">${[['width',tr('宽度(像素)','Width (px)'),410,16,2048,1],['height',tr('高度(像素)','Height (px)'),502,16,2048,1],['center_x',tr('水平裁切位置(0 左 / 1 右)','Crop X (0 left / 1 right)'),0.5,0,1,0.01],['center_y',tr('垂直裁切位置(0 上 / 1 下)','Crop Y (0 top / 1 bottom)'),0.5,0,1,0.01],['quality',tr('JPEG 质量','JPEG quality'),85,40,95,1]].map(([key,label,value,min,max,step])=>`<div class="field"><label for="thumb-${key}">${label}</label><input id="thumb-${key}" type="number" required min="${min}" max="${max}" step="${step}" value="${value}"></div>`).join('')}<div class="field"><label for="thumb-fit">${tr('适配方式','Fit')}</label><select id="thumb-fit"><option value="cover">${tr('等比裁切铺满(默认)','Fill with proportional crop (default)')}</option><option value="contain">${tr('完整画面(必要时补白)','Full picture (white padding if needed)')}</option></select></div></div><p class="hint">${tr('不拉伸、不叠加文字;裁切位置仅在铺满时生效。Photos 使用可取得的源预览。','No stretching or text. Crop positions apply to fill mode. Photos uses available source previews.')}</p><button type="submit" class="primary">${tr('保存方案','Save plan')}</button></form></section><section class="card"><h2>${tr('预览与生成','Preview and generate')}</h2><div class="field"><label for="thumb-source">${t('source')}</label><select id="thumb-source"><option value="">${tr('选择照片数据源','Choose photo source')}</option>${sources.map(s=>`<option value="${esc(s.id)}">${esc(s.name)}</option>`).join('')}</select></div><div class="field"><label for="thumb-item">${tr('源图片','Source image')}</label><select id="thumb-item"></select></div><div class="actions"><button id="thumb-prev">${tr('上一页','Previous page')}</button><button id="thumb-next">${tr('下一页','Next page')}</button><button id="thumb-preview">${tr('预览小图','Preview thumbnail')}</button><button id="thumb-generate" class="primary">${tr('生成并保存到图库','Generate and save to gallery')}</button></div><p id="thumb-result" class="hint" aria-live="polite"></p><img id="thumb-image" alt="${tr('小图预览','Thumbnail preview')}" hidden style="max-width:100%;max-height:502px;width:auto;height:auto"></section>`;
|
||||
$('#main').innerHTML=`<section class="card"><div class="actions"><select id="thumb-plan"><option value="">${tr('新建方案','New plan')}</option>${plans.map(p=>`<option value="${esc(p.id)}">${esc(p.name)} · ${p.width}×${p.height}</option>`).join('')}</select><button id="thumb-delete" class="danger">${t('delete')}</button></div><form id="thumb-form"><div class="field"><label for="thumb-name">${t('name')}</label><input id="thumb-name" required maxlength="120"></div><div class="form-grid">${[['width',tr('宽度(像素)','Width (px)'),410,16,2048,1],['height',tr('高度(像素)','Height (px)'),502,16,2048,1],['center_x',tr('水平裁切位置(0 左 / 1 右)','Crop X (0 left / 1 right)'),0.5,0,1,0.01],['center_y',tr('垂直裁切位置(0 上 / 1 下)','Crop Y (0 top / 1 bottom)'),0.5,0,1,0.01],['quality',tr('JPEG 质量','JPEG quality'),85,40,95,1]].map(([key,label,value,min,max,step])=>`<div class="field"><label for="thumb-${key}">${label}</label><input id="thumb-${key}" type="number" required min="${min}" max="${max}" step="${step}" value="${value}"></div>`).join('')}<div class="field"><label for="thumb-fit">${tr('适配方式','Fit')}</label><select id="thumb-fit"><option value="cover">${tr('等比裁切铺满(默认)','Fill with proportional crop (default)')}</option><option value="contain">${tr('完整画面(必要时补白)','Full picture (white padding if needed)')}</option></select></div></div><p class="hint">${tr('不拉伸、不叠加文字;裁切位置仅在铺满时生效。Photos 使用可取得的源预览。','No stretching or text. Crop positions apply to fill mode. Photos uses available source previews.')}</p><button type="submit" class="primary">${tr('保存方案','Save plan')}</button></form></section><section class="card"><h2>${tr('预览与生成','Preview and generate')}</h2><div class="field"><label for="thumb-source">${t('source')}</label><select id="thumb-source"><option value="">${tr('选择照片数据源','Choose photo source')}</option>${sources.map(s=>`<option value="${esc(s.id)}">${esc(s.name)}</option>`).join('')}</select></div><div class="field"><label for="thumb-item">${tr('源图片','Source image')}</label><select id="thumb-item"></select></div><div class="actions"><button id="thumb-prev">${tr('上一页','Previous page')}</button><button id="thumb-next">${tr('下一页','Next page')}</button><button id="thumb-preview">${tr('预览小图','Preview thumbnail')}</button><button id="thumb-generate" class="primary">${tr('生成并保存到小图图库','Generate and save to thumbnail gallery')}</button></div><button id="thumb-gallery" type="button">${tr('查看手表小图图库','Open watch thumbnail gallery')}</button><p id="thumb-result" class="hint" aria-live="polite"></p><img id="thumb-image" alt="${tr('小图预览','Thumbnail preview')}" hidden style="max-width:100%;max-height:502px;width:auto;height:auto"></section>`;
|
||||
const defaults={name:'',width:410,height:502,center_x:0.5,center_y:0.5,quality:85,fit:'cover'};
|
||||
const fill=p=>{for(const key of Object.keys(defaults))$('#thumb-'+key).value=p[key];};
|
||||
const read=()=>{const p={};for(const key of Object.keys(defaults))p[key]=['name','fit'].includes(key)?$('#thumb-'+key).value:Number($('#thumb-'+key).value);return p;};
|
||||
fill(defaults);$('#thumb-delete').disabled=true;
|
||||
bind('thumb-gallery',()=>{state.tab='watch';state.watchOffset=0;return navigate('gallery');});
|
||||
$('#thumb-form').oninput=()=>{$('#thumb-image').hidden=true;$('#thumb-result').textContent=tr('参数已修改,请重新预览','Parameters changed; preview again');};
|
||||
$('#thumb-plan').onchange=()=>{fill(plans.find(p=>p.id===$('#thumb-plan').value)||defaults);$('#thumb-delete').disabled=!$('#thumb-plan').value;$('#thumb-image').hidden=true;};
|
||||
$('#thumb-form').onsubmit=busy(async e=>{e.preventDefault();const id=$('#thumb-plan').value;await api('/api/v1/thumbnail-plans'+(id?'/'+id:''),{method:id?'PUT':'POST',body:read()});toast(t('save'));await navigate();});
|
||||
@@ -20,7 +21,7 @@ views.thumbnails=async function(epoch){
|
||||
bind('thumb-prev',async()=>{offset=Math.max(0,offset-50);await images();});bind('thumb-next',async()=>{offset+=50;await images();});
|
||||
function payload(){if(!$('#thumb-form').reportValidity())throw new Error(tr('请补全方案参数','Complete the plan parameters'));if(!$('#thumb-item').value)throw new Error(tr('请选择源图片','Choose a source image'));return {source_id:$('#thumb-source').value,item_id:$('#thumb-item').value,plan:read()};}
|
||||
bind('thumb-preview',async()=>{const blob=await api('/api/v1/thumbnails/preview',{method:'POST',body:payload(),binary:true});if(epoch!==state.epoch)return;const url=URL.createObjectURL(blob);state.blobs.push(url);$('#thumb-image').src=url;$('#thumb-image').hidden=false;$('#thumb-result').textContent=tr('预览未保存','Preview is not saved');});
|
||||
bind('thumb-generate',async()=>{const asset=await api('/api/v1/thumbnails/generate',{method:'POST',body:payload()});if(epoch!==state.epoch)return;$('#thumb-image').hidden=false;await blobImage($('#thumb-image'),asset.url);$('#thumb-result').textContent=tr('已保存到本地图库:','Saved to local gallery: ')+asset.name;});
|
||||
bind('thumb-generate',async()=>{const asset=await api('/api/v1/thumbnails/generate',{method:'POST',body:payload()});if(epoch!==state.epoch)return;$('#thumb-image').hidden=false;await blobImage($('#thumb-image'),asset.url);$('#thumb-result').textContent=tr('已保存到手表小图图库:','Saved to watch thumbnail gallery: ')+asset.name;});
|
||||
};
|
||||
|
||||
async function thumbnailJobFields(job){
|
||||
@@ -30,3 +31,18 @@ async function thumbnailJobFields(job){
|
||||
const update=()=>{const supported=$('#job-content-type')?.value==='photo_source';$('#job-thumbnail').disabled=!supported;if(!supported)$('#job-thumbnail').checked=false;$('#job-thumbnail-plan').disabled=!$('#job-thumbnail').checked;$('#job-thumbnail-plan').required=$('#job-thumbnail').checked;};
|
||||
$('#job-thumbnail').onchange=update;$('#job-content-type')?.addEventListener('change',update);update();
|
||||
}
|
||||
|
||||
async function watchGalleryPage(epoch,destinations){
|
||||
const offset=state.watchOffset||0;
|
||||
const [page,devices]=await Promise.all([api('/api/v1/thumbnails?limit=50&offset='+offset),api('/api/v1/thumbnail-devices')]);
|
||||
if(epoch!==state.epoch)return;
|
||||
const watch=devices.find(d=>d.id==='watch');
|
||||
$('#main').innerHTML=`<div class="section-heading"><div class="tabs gallery-tabs">${destinations.map(d=>`<button data-watch-tab="${esc(d.id)}" class="${d.id==='watch'?'active':''}">${esc(galleryDestinationName(d))}</button>`).join('')}</div><button id="watch-refresh">${t('refresh')}</button></div><section class="card"><div class="section-heading"><div><h2>${tr('手表','Watch')} · ${watch.width} × ${watch.height}</h2><p class="hint">${tr('独立小图设备 · 图片已保存在服务器;设备传输尚未接入。','Separate thumbnail device · Images are saved on the server; device transfer is not connected.')}</p></div><button id="watch-plans" class="primary">${tr('管理小图方案 / 生成图片','Manage plans / Generate images')}</button></div><div class="actions"><button id="watch-prev" ${offset===0?'disabled':''}>${tr('上一页','Previous page')}</button><button id="watch-next" ${offset+page.limit>=page.total?'disabled':''}>${tr('下一页','Next page')}</button><span class="hint">${tr('共','Total')} ${page.total} ${tr('张小图','thumbnails')}</span></div></section><div class="gallery">${page.items.map((x,i)=>`<article class="card image-card"><button class="image-preview" data-watch-preview="${i}" aria-label="${tr('预览','Preview')} · ${esc(x.name)}"><img class="thumb" data-src="${esc(x.url)}" alt="${esc(x.name)}" style="aspect-ratio:${x.thumbnail.width}/${x.thumbnail.height};object-fit:contain"></button><h3>${esc(x.name)}</h3><small>${x.thumbnail.width} × ${x.thumbnail.height} · ${esc(x.thumbnail.plan?.name||tr('默认手表方案','Default watch plan'))}</small><small>${esc(fmt(x.thumbnail.generated_at||x.created_at))}</small><div class="actions"><button data-watch-preview="${i}">${tr('预览','Preview')}</button><button data-watch-download="${i}">${tr('下载图片','Download image')}</button><button class="danger" data-watch-delete="${i}">${t('delete')}</button></div></article>`).join('')}</div>${page.items.length?'':`<section class="card empty"><strong>${tr('还没有小图','No thumbnails yet')}</strong><p>${tr('在“小图方案”中生成,或为照片任务勾选生成小图。旧版手动生成的小图仍在原本地图库。','Generate one in Thumbnail plans or enable thumbnails on a photo task. Older manually generated images remain in the original local gallery.')}</p></section>`}`;
|
||||
document.querySelectorAll('[data-watch-tab]').forEach(b=>b.onclick=()=>{state.tab=b.dataset.watchTab;return navigate('gallery');});
|
||||
bind('watch-refresh',()=>navigate());bind('watch-plans',()=>navigate('thumbnails'));
|
||||
bind('watch-prev',()=>{state.watchOffset=Math.max(0,offset-50);return navigate();});bind('watch-next',()=>{state.watchOffset=offset+50;return navigate();});
|
||||
document.querySelectorAll('[data-watch-preview]').forEach(b=>b.onclick=busy(()=>{const x=page.items[b.dataset.watchPreview];return openImageViewer(x.url,x.name);}));
|
||||
document.querySelectorAll('[data-watch-download]').forEach(b=>b.onclick=busy(()=>{const x=page.items[b.dataset.watchDownload];return downloadImage(x.url,x.name);}));
|
||||
document.querySelectorAll('[data-watch-delete]').forEach(b=>b.onclick=()=>{const x=page.items[b.dataset.watchDelete];return confirmDialog(tr('删除这张手表小图?不会删除源照片。','Delete this watch thumbnail? The source photo is kept.'),async()=>{await api('/api/v1/assets/'+x.asset_id,{method:'DELETE'});state.watchOffset=Math.max(0,offset-(page.items.length===1?50:0));await navigate();});});
|
||||
hydrateImages();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user