feat: unify source libraries, generation plans and terminal bindings
This commit is contained in:
@@ -144,6 +144,9 @@ def create_app(settings=None, start_worker=True):
|
||||
from pigway_frame_flow.api.thumbnails import install_thumbnails
|
||||
|
||||
install_thumbnails(app, service)
|
||||
from pigway_frame_flow.api.libraries import install_libraries
|
||||
|
||||
install_libraries(app, service)
|
||||
app.state.service = service
|
||||
app.state.worker_enabled = start_worker
|
||||
app.state.scheduler = scheduler
|
||||
@@ -601,7 +604,7 @@ 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' "
|
||||
"SELECT assets.id FROM assets LEFT JOIN asset_metadata ON asset_metadata.asset_id=assets.id WHERE kind NOT IN ('cache','source-images','renditions') "
|
||||
"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=?) "
|
||||
@@ -618,6 +621,13 @@ def create_app(settings=None, start_worker=True):
|
||||
)
|
||||
def delete_asset(asset_id: str):
|
||||
path = assets.path(asset_id)
|
||||
if db.one(
|
||||
"SELECT id FROM assets WHERE id=? AND kind IN ('source-images','renditions')",
|
||||
(asset_id,),
|
||||
):
|
||||
raise FlowError(
|
||||
E.CONFLICT, "Delete library images through their source library", 409
|
||||
)
|
||||
reference = "%" + asset_id + "%"
|
||||
if (
|
||||
db.one(
|
||||
@@ -799,7 +809,9 @@ def create_app(settings=None, start_worker=True):
|
||||
dependencies=[Depends(require("admin"))],
|
||||
)
|
||||
def delete_photo_source(source_id: str):
|
||||
service.photo_sources.delete(source_id)
|
||||
with service.lock:
|
||||
service.libraries.check_source_delete(source_id)
|
||||
service.photo_sources.delete(source_id)
|
||||
return Response(status_code=204)
|
||||
|
||||
@app.get(
|
||||
@@ -1391,6 +1403,7 @@ def create_app(settings=None, start_worker=True):
|
||||
if filename not in [
|
||||
"app.js",
|
||||
"thumbnails.js",
|
||||
"libraries.js",
|
||||
"features.js",
|
||||
"extensions.js",
|
||||
"style.css",
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
from fastapi import Depends, Query, Request
|
||||
from fastapi.responses import Response
|
||||
from pydantic import Field
|
||||
|
||||
from pigway_frame_flow.core.auth import require
|
||||
from pigway_frame_flow.core.photo_preferences import set_flags
|
||||
from pigway_frame_flow.display.libraries import GenerationPlan
|
||||
from pigway_frame_flow.errors import ErrorCode as E
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.models import ImageExclusion, ImageStar, StrictModel
|
||||
|
||||
|
||||
class BindingInput(StrictModel):
|
||||
source_id: str = Field(min_length=1, max_length=120)
|
||||
plan_id: str = Field(pattern=r"^[a-f0-9]{32}$")
|
||||
|
||||
|
||||
class ImportInput(StrictModel):
|
||||
item_id: str = Field(min_length=1, max_length=2048)
|
||||
|
||||
|
||||
class RunInput(StrictModel):
|
||||
item_id: str | None = Field(default=None, min_length=1, max_length=2048)
|
||||
deliver: bool = False
|
||||
|
||||
|
||||
class LibraryModeInput(StrictModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class GenerateInput(StrictModel):
|
||||
plan_id: str | None = Field(default=None, pattern=r"^[a-f0-9]{32}$")
|
||||
|
||||
|
||||
def install_libraries(app, service):
|
||||
libraries, db = service.libraries, service.db
|
||||
|
||||
@app.put(
|
||||
"/api/v1/jobs/{job_id}/library-mode",
|
||||
dependencies=[Depends(require("jobs:write"))],
|
||||
)
|
||||
def set_mode(job_id: str, payload: LibraryModeInput):
|
||||
return app.state.scheduler.set_library_mode(job_id, payload.enabled)
|
||||
|
||||
@app.get("/api/v1/source-libraries", dependencies=[Depends(require("images:read"))])
|
||||
def sources():
|
||||
return [
|
||||
{
|
||||
**source,
|
||||
"image_count": db.one(
|
||||
"SELECT COUNT(*) AS n FROM source_images WHERE source_id=?",
|
||||
(source["id"],),
|
||||
)["n"],
|
||||
}
|
||||
for source in service.photo_sources.list()
|
||||
]
|
||||
|
||||
@app.get(
|
||||
"/api/v1/source-libraries/{source_id}/images",
|
||||
dependencies=[Depends(require("images:read"))],
|
||||
)
|
||||
def pictures(
|
||||
source_id: str,
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
):
|
||||
service.photo_sources.get(source_id)
|
||||
rows = db.all(
|
||||
"SELECT id FROM source_images WHERE source_id=? ORDER BY created_at DESC,id DESC LIMIT ? OFFSET ?",
|
||||
(source_id, limit, offset),
|
||||
)
|
||||
return {
|
||||
"items": [libraries.picture(row["id"]) for row in rows],
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"total": db.one(
|
||||
"SELECT COUNT(*) AS n FROM source_images WHERE source_id=?",
|
||||
(source_id,),
|
||||
)["n"],
|
||||
}
|
||||
|
||||
@app.post(
|
||||
"/api/v1/source-libraries/{source_id}/import",
|
||||
dependencies=[Depends(require("admin"))],
|
||||
)
|
||||
def import_picture(source_id: str, payload: ImportInput):
|
||||
return libraries.import_picture(source_id, payload.item_id)
|
||||
|
||||
@app.post(
|
||||
"/api/v1/source-libraries/{source_id}/run",
|
||||
dependencies=[Depends(require("admin"))],
|
||||
)
|
||||
def run(source_id: str, payload: RunInput, request: Request):
|
||||
with service.lock:
|
||||
return libraries.pipeline(
|
||||
source_id,
|
||||
payload.item_id,
|
||||
deliver=payload.deliver,
|
||||
caller=request.state.client_id,
|
||||
)
|
||||
|
||||
@app.get("/api/v1/generation-plans", dependencies=[Depends(require("images:read"))])
|
||||
def plans(source_id: str):
|
||||
return libraries.plans(source_id)
|
||||
|
||||
@app.post("/api/v1/generation-plans", dependencies=[Depends(require("admin"))])
|
||||
def create_plan(payload: GenerationPlan):
|
||||
return libraries.save_plan(payload)
|
||||
|
||||
@app.put(
|
||||
"/api/v1/generation-plans/{plan_id}", dependencies=[Depends(require("admin"))]
|
||||
)
|
||||
def update_plan(plan_id: str, payload: GenerationPlan):
|
||||
return libraries.save_plan(payload, plan_id)
|
||||
|
||||
@app.delete(
|
||||
"/api/v1/generation-plans/{plan_id}",
|
||||
status_code=204,
|
||||
dependencies=[Depends(require("admin"))],
|
||||
)
|
||||
def delete_plan(plan_id: str):
|
||||
libraries.archive(plan_id)
|
||||
|
||||
@app.post(
|
||||
"/api/v1/source-images/{picture_id}/generate",
|
||||
dependencies=[Depends(require("admin"))],
|
||||
)
|
||||
def generate(picture_id: str, payload: GenerateInput):
|
||||
with service.lock:
|
||||
picture = libraries.picture(picture_id)
|
||||
ids = (
|
||||
[payload.plan_id]
|
||||
if payload.plan_id
|
||||
else [
|
||||
p["id"]
|
||||
for p in libraries.plans(picture["source_id"])
|
||||
if not p["archived"]
|
||||
]
|
||||
)
|
||||
return [libraries.generate(picture_id, plan_id) for plan_id in ids]
|
||||
|
||||
@app.post(
|
||||
"/api/v1/source-images/{picture_id}/preview",
|
||||
dependencies=[Depends(require("images:read"))],
|
||||
)
|
||||
def preview(picture_id: str, payload: GenerationPlan):
|
||||
picture = libraries.picture(picture_id)
|
||||
if picture["source_id"] != payload.source_id:
|
||||
raise FlowError(
|
||||
E.CONFLICT, "Picture and plan must share a source library", 409
|
||||
)
|
||||
data = libraries.render(
|
||||
service.assets.path(picture_id).read_bytes(), picture["caption"], payload
|
||||
)
|
||||
return Response(
|
||||
data, media_type="image/jpeg", headers={"Cache-Control": "no-store"}
|
||||
)
|
||||
|
||||
@app.put(
|
||||
"/api/v1/source-images/{picture_id}/star",
|
||||
dependencies=[Depends(require("admin"))],
|
||||
)
|
||||
def star(picture_id: str, payload: ImageStar):
|
||||
picture = libraries.picture(picture_id)
|
||||
return set_flags(
|
||||
db, picture["source_id"], picture["item_id"], starred=payload.starred
|
||||
)
|
||||
|
||||
@app.put(
|
||||
"/api/v1/source-images/{picture_id}/exclusion",
|
||||
dependencies=[Depends(require("admin"))],
|
||||
)
|
||||
def exclude(picture_id: str, payload: ImageExclusion):
|
||||
picture = libraries.picture(picture_id)
|
||||
return set_flags(
|
||||
db, picture["source_id"], picture["item_id"], excluded=payload.excluded
|
||||
)
|
||||
|
||||
@app.delete(
|
||||
"/api/v1/source-images/{picture_id}",
|
||||
status_code=204,
|
||||
dependencies=[Depends(require("images:delete"))],
|
||||
)
|
||||
def delete_picture(picture_id: str):
|
||||
libraries.delete_picture(picture_id)
|
||||
|
||||
@app.delete(
|
||||
"/api/v1/library-versions/{asset_id}",
|
||||
status_code=204,
|
||||
dependencies=[Depends(require("images:delete"))],
|
||||
)
|
||||
def delete_version(asset_id: str):
|
||||
with service.lock:
|
||||
if not db.one(
|
||||
"SELECT asset_id FROM library_versions WHERE asset_id=?", (asset_id,)
|
||||
):
|
||||
raise FlowError(E.NOT_FOUND, "Generated image not found", 404)
|
||||
libraries.check_asset_references(asset_id)
|
||||
libraries.remove_asset(asset_id)
|
||||
|
||||
@app.get(
|
||||
"/api/v1/library-terminals", dependencies=[Depends(require("images:read"))]
|
||||
)
|
||||
def terminals():
|
||||
return libraries.terminals()
|
||||
|
||||
@app.put(
|
||||
"/api/v1/library-terminals/{terminal_id}/binding",
|
||||
dependencies=[Depends(require("admin"))],
|
||||
)
|
||||
def bind(terminal_id: str, payload: BindingInput):
|
||||
return libraries.bind(terminal_id, payload.source_id, payload.plan_id)
|
||||
|
||||
@app.delete(
|
||||
"/api/v1/library-terminals/{terminal_id}/binding",
|
||||
status_code=204,
|
||||
dependencies=[Depends(require("admin"))],
|
||||
)
|
||||
def unbind(terminal_id: str):
|
||||
with service.lock:
|
||||
db.execute(
|
||||
"DELETE FROM terminal_bindings WHERE terminal_id=?", (terminal_id,)
|
||||
)
|
||||
|
||||
@app.get(
|
||||
"/api/v1/library-terminals/{terminal_id}/images",
|
||||
dependencies=[Depends(require("images:read"))],
|
||||
)
|
||||
def terminal_images(
|
||||
terminal_id: str,
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
):
|
||||
if not any(t["id"] == terminal_id for t in libraries.terminals()):
|
||||
raise FlowError(E.NOT_FOUND, "Terminal not found", 404)
|
||||
binding = libraries.binding(terminal_id)
|
||||
if not binding:
|
||||
return {"items": [], "total": 0, "offset": offset, "limit": limit}
|
||||
where = "FROM library_versions v JOIN source_images s ON s.id=v.source_image_id WHERE v.plan_id=? AND s.source_id=?"
|
||||
args = (binding["plan_id"], binding["source_id"])
|
||||
rows = db.all(
|
||||
"SELECT v.asset_id,v.source_image_id "
|
||||
+ where
|
||||
+ " ORDER BY v.created_at DESC,v.asset_id LIMIT ? OFFSET ?",
|
||||
(*args, limit, offset),
|
||||
)
|
||||
items = []
|
||||
for row in rows:
|
||||
version = next(
|
||||
v
|
||||
for v in libraries.versions(row["source_image_id"])
|
||||
if v["asset_id"] == row["asset_id"]
|
||||
)
|
||||
delivery = db.one(
|
||||
"SELECT operation_id FROM terminal_deliveries WHERE terminal_id=? AND asset_id=? ORDER BY created_at DESC,operation_id DESC LIMIT 1",
|
||||
(terminal_id, row["asset_id"]),
|
||||
)
|
||||
operation = (
|
||||
db.one(
|
||||
"SELECT id AS operation_id,status,created_at,updated_at FROM operations WHERE id=?",
|
||||
(delivery["operation_id"],),
|
||||
)
|
||||
if delivery and delivery["operation_id"]
|
||||
else None
|
||||
)
|
||||
items.append(
|
||||
{
|
||||
**version,
|
||||
"operation": operation,
|
||||
"delivery_status": operation["status"]
|
||||
if operation
|
||||
else "GENERATED",
|
||||
}
|
||||
)
|
||||
return {
|
||||
"items": items,
|
||||
"total": db.one("SELECT COUNT(*) AS n " + where, args)["n"],
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
@app.post(
|
||||
"/api/v1/library-terminals/{terminal_id}/images/{asset_id}/send",
|
||||
dependencies=[Depends(require("display:write"))],
|
||||
)
|
||||
def send(terminal_id: str, asset_id: str, request: Request):
|
||||
return {
|
||||
"operation_id": libraries.send(
|
||||
terminal_id, asset_id, client_id=request.state.client_id
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,11 @@ class CloudLibrary:
|
||||
job = self.db.one(
|
||||
"SELECT job_id FROM job_runs WHERE operation_id=?", (operation_id,)
|
||||
)
|
||||
if not job and operation["client_id"] == "library-terminal":
|
||||
job = self.db.one(
|
||||
"SELECT job_runs.job_id FROM terminal_deliveries JOIN job_runs ON job_runs.operation_id=terminal_deliveries.parent_operation_id WHERE terminal_deliveries.operation_id=?",
|
||||
(operation_id,),
|
||||
)
|
||||
if not job and operation["client_id"] == "scheduler-retry":
|
||||
job = self.db.one(
|
||||
"SELECT job_id FROM asset_metadata WHERE asset_id=? AND job_id IS NOT NULL",
|
||||
|
||||
@@ -0,0 +1,659 @@
|
||||
"""Source pictures, source-scoped render plans, and single terminal bindings."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from io import BytesIO
|
||||
from uuid import uuid4
|
||||
|
||||
from PIL import Image
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from pigway_frame_flow.core.photo_preferences import flags
|
||||
from pigway_frame_flow.display.thumbnail_plans import ThumbnailPlan
|
||||
from pigway_frame_flow.errors import ErrorCode as E
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.models import PhotoSourceContent
|
||||
from pigway_frame_flow.renderers.images import MAX_IMAGE_PIXELS, convert
|
||||
from pigway_frame_flow.renderers.photo_caption import render_captioned_photo
|
||||
from pigway_frame_flow.storage.database import encode, now_iso
|
||||
|
||||
|
||||
class GenerationPlan(ThumbnailPlan):
|
||||
source_id: str = Field(min_length=1, max_length=120)
|
||||
caption: bool = False
|
||||
profile: str = Field(default="color", pattern="^(color|grayscale)$")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def caption_size(self):
|
||||
if self.caption and min(self.width, self.height) < 128:
|
||||
raise ValueError(
|
||||
"Caption output requires both dimensions to be at least 128 pixels"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class Libraries:
|
||||
def __init__(self, service):
|
||||
self.service, self.db = service, service.db
|
||||
for kind in ("source-images", "renditions"):
|
||||
(service.settings.data_dir / kind).mkdir(exist_ok=True)
|
||||
for sql in [
|
||||
"CREATE TABLE IF NOT EXISTS library_plans(id TEXT PRIMARY KEY,source_id TEXT NOT NULL,payload TEXT NOT NULL,archived INTEGER NOT NULL DEFAULT 0)",
|
||||
"CREATE TABLE IF NOT EXISTS source_images(id TEXT PRIMARY KEY,source_id TEXT NOT NULL,item_id TEXT NOT NULL,digest TEXT NOT NULL,caption TEXT NOT NULL,created_at TEXT NOT NULL,UNIQUE(source_id,item_id,digest))",
|
||||
"CREATE TABLE IF NOT EXISTS library_versions(asset_id TEXT PRIMARY KEY,source_image_id TEXT NOT NULL,plan_id TEXT NOT NULL,revision TEXT NOT NULL,plan TEXT NOT NULL,created_at TEXT NOT NULL,UNIQUE(source_image_id,plan_id,revision))",
|
||||
"CREATE TABLE IF NOT EXISTS terminal_bindings(terminal_id TEXT PRIMARY KEY,source_id TEXT NOT NULL,plan_id TEXT NOT NULL)",
|
||||
"CREATE TABLE IF NOT EXISTS terminal_deliveries(terminal_id TEXT NOT NULL,asset_id TEXT NOT NULL,operation_id TEXT PRIMARY KEY,parent_operation_id TEXT,created_at TEXT NOT NULL)",
|
||||
]:
|
||||
self.db.execute(sql)
|
||||
service.operation_handlers["library-generate"] = self.execute
|
||||
|
||||
def terminals(self):
|
||||
result = []
|
||||
for terminal_id, driver in self.service.registry.drivers.items():
|
||||
cached = getattr(driver, "cached", None)
|
||||
result.append(
|
||||
{
|
||||
"id": terminal_id,
|
||||
"name": getattr(cached, "name", terminal_id),
|
||||
"kind": "frame",
|
||||
"transport_supported": True,
|
||||
"binding": self.binding(terminal_id),
|
||||
}
|
||||
)
|
||||
result.append(
|
||||
{
|
||||
"id": "watch",
|
||||
"name": "Watch",
|
||||
"kind": "watch",
|
||||
"width": 410,
|
||||
"height": 502,
|
||||
"transport_supported": False,
|
||||
"binding": self.binding("watch"),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
def binding(self, terminal_id):
|
||||
return self.db.one(
|
||||
"SELECT * FROM terminal_bindings WHERE terminal_id=?", (terminal_id,)
|
||||
)
|
||||
|
||||
def plan(self, plan_id, active=True):
|
||||
row = self.db.one("SELECT * FROM library_plans WHERE id=?", (plan_id,))
|
||||
if not row or (active and row["archived"]):
|
||||
raise FlowError(E.NOT_FOUND, "Generation plan not found", 404)
|
||||
return GenerationPlan.model_validate_json(row["payload"])
|
||||
|
||||
def plans(self, source_id):
|
||||
self.service.photo_sources.get(source_id)
|
||||
return [
|
||||
{**json.loads(r["payload"]), "id": r["id"], "archived": bool(r["archived"])}
|
||||
for r in self.db.all(
|
||||
"SELECT * FROM library_plans WHERE source_id=? ORDER BY archived,id",
|
||||
(source_id,),
|
||||
)
|
||||
]
|
||||
|
||||
def save_plan(self, plan, plan_id=None):
|
||||
with self.service.lock:
|
||||
self.service.photo_sources.get(plan.source_id)
|
||||
if plan_id:
|
||||
previous = self.plan(plan_id)
|
||||
if previous.source_id != plan.source_id:
|
||||
raise FlowError(
|
||||
E.CONFLICT,
|
||||
"A generation plan cannot move between source libraries",
|
||||
409,
|
||||
)
|
||||
for row in self.db.all(
|
||||
"SELECT terminal_id FROM terminal_bindings WHERE plan_id=?",
|
||||
(plan_id,),
|
||||
):
|
||||
self.validate_size(row["terminal_id"], plan)
|
||||
plan_id = plan_id or uuid4().hex
|
||||
self.db.execute(
|
||||
"INSERT INTO library_plans VALUES(?,?,?,0) ON CONFLICT(id) DO UPDATE SET payload=excluded.payload",
|
||||
(plan_id, plan.source_id, encode(plan.model_dump())),
|
||||
)
|
||||
return {"id": plan_id, **plan.model_dump(), "archived": False}
|
||||
|
||||
def archive(self, plan_id):
|
||||
with self.service.lock:
|
||||
self.plan(plan_id)
|
||||
if self.db.one(
|
||||
"SELECT terminal_id FROM terminal_bindings WHERE plan_id=?", (plan_id,)
|
||||
):
|
||||
raise FlowError(
|
||||
E.CONFLICT,
|
||||
"Unbind terminals before deleting this generation plan",
|
||||
409,
|
||||
{"reason": "libraryPlanBound"},
|
||||
)
|
||||
self.db.execute(
|
||||
"UPDATE library_plans SET archived=1 WHERE id=?", (plan_id,)
|
||||
)
|
||||
|
||||
def validate_size(self, terminal_id, plan):
|
||||
if terminal_id == "watch":
|
||||
allowed = {(410, 502)}
|
||||
else:
|
||||
display = self.service.registry.get(terminal_id).device().display
|
||||
allowed = {(display.width, display.height), (display.height, display.width)}
|
||||
if (plan.width, plan.height) not in allowed:
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST,
|
||||
"Generation dimensions must match the terminal screen",
|
||||
422,
|
||||
{"reason": "librarySizeMismatch"},
|
||||
)
|
||||
|
||||
def bind(self, terminal_id, source_id, plan_id):
|
||||
with self.service.lock:
|
||||
self.service.photo_sources.get(source_id)
|
||||
plan = self.plan(plan_id)
|
||||
if plan.source_id != source_id:
|
||||
raise FlowError(
|
||||
E.CONFLICT, "Generation plan belongs to another source library", 409
|
||||
)
|
||||
self.validate_size(terminal_id, plan)
|
||||
self.db.execute(
|
||||
"INSERT INTO terminal_bindings VALUES(?,?,?) ON CONFLICT(terminal_id) DO UPDATE SET source_id=excluded.source_id,plan_id=excluded.plan_id",
|
||||
(terminal_id, source_id, plan_id),
|
||||
)
|
||||
return self.binding(terminal_id)
|
||||
|
||||
def ingest(self, source_id, item_id, data, name, caption):
|
||||
digest = hashlib.sha256(
|
||||
data + b"\0" + encode(caption or {}).encode()
|
||||
).hexdigest()
|
||||
row = self.db.one(
|
||||
"SELECT id FROM source_images WHERE source_id=? AND item_id=? AND digest=?",
|
||||
(source_id, item_id, digest),
|
||||
)
|
||||
if row:
|
||||
return self.picture(row["id"])
|
||||
# Normalize EXIF and JPEG storage without resizing or adding decorations.
|
||||
try:
|
||||
with Image.open(BytesIO(data)) as image:
|
||||
if (
|
||||
image.format not in {"JPEG", "PNG", "WEBP"}
|
||||
or image.width * image.height > MAX_IMAGE_PIXELS
|
||||
):
|
||||
raise ValueError("Unsupported source image")
|
||||
size = image.size
|
||||
if image.getexif().get(274, 1) in (5, 6, 7, 8):
|
||||
size = (image.height, image.width)
|
||||
normalized = convert(data, "cover", size=size, quality=95)
|
||||
except (OSError, ValueError, Image.DecompressionBombError):
|
||||
raise FlowError(E.INVALID_REQUEST, "Invalid source image") from None
|
||||
asset = self.service.assets.put(normalized, name, "source-images")
|
||||
self.db.execute(
|
||||
"INSERT INTO source_images VALUES(?,?,?,?,?,?)",
|
||||
(
|
||||
asset["asset_id"],
|
||||
source_id,
|
||||
item_id,
|
||||
digest,
|
||||
encode(caption or {}),
|
||||
now_iso(),
|
||||
),
|
||||
)
|
||||
self.db.execute(
|
||||
"INSERT INTO asset_sources VALUES(?,?)",
|
||||
(
|
||||
asset["asset_id"],
|
||||
encode(
|
||||
{
|
||||
"type": "library_source",
|
||||
"source_id": source_id,
|
||||
"item_id": item_id,
|
||||
"source_rendition": "preview"
|
||||
if self.service.photo_sources.get(source_id).kind
|
||||
== "synology-photos"
|
||||
else "source",
|
||||
}
|
||||
),
|
||||
),
|
||||
)
|
||||
return self.picture(asset["asset_id"])
|
||||
|
||||
def import_picture(self, source_id, item_id):
|
||||
with self.service.lock:
|
||||
data, name, caption = self.service.photo_sources.read(source_id, item_id)
|
||||
return self.ingest(source_id, item_id, data, name, caption)
|
||||
|
||||
def picture(self, picture_id):
|
||||
row = self.db.one("SELECT * FROM source_images WHERE id=?", (picture_id,))
|
||||
if not row:
|
||||
raise FlowError(E.NOT_FOUND, "Source picture not found", 404)
|
||||
return {
|
||||
**self.service.assets.info(picture_id),
|
||||
**row,
|
||||
"caption": json.loads(row["caption"]),
|
||||
**flags(self.db, row["source_id"], row["item_id"]),
|
||||
"versions": self.versions(picture_id),
|
||||
}
|
||||
|
||||
def versions(self, picture_id):
|
||||
return [
|
||||
{
|
||||
**self.service.assets.info(r["asset_id"]),
|
||||
"source_image_id": r["source_image_id"],
|
||||
"plan_id": r["plan_id"],
|
||||
"revision": r["revision"],
|
||||
"plan": json.loads(r["plan"]),
|
||||
"terminal_uses": self.terminal_uses(r["plan_id"], r["asset_id"]),
|
||||
}
|
||||
for r in self.db.all(
|
||||
"SELECT * FROM library_versions WHERE source_image_id=? ORDER BY created_at DESC,asset_id",
|
||||
(picture_id,),
|
||||
)
|
||||
]
|
||||
|
||||
def terminal_uses(self, plan_id, asset_id):
|
||||
return self.db.all(
|
||||
"SELECT b.terminal_id,d.operation_id,COALESCE(o.status,'GENERATED') AS status "
|
||||
"FROM terminal_bindings b LEFT JOIN terminal_deliveries d ON d.operation_id=(SELECT operation_id FROM terminal_deliveries latest "
|
||||
"WHERE latest.terminal_id=b.terminal_id AND latest.asset_id=? ORDER BY latest.created_at DESC,latest.operation_id DESC LIMIT 1) "
|
||||
"LEFT JOIN operations o ON o.id=d.operation_id WHERE b.plan_id=? ORDER BY b.terminal_id",
|
||||
(asset_id, plan_id),
|
||||
)
|
||||
|
||||
def render(self, data, caption, plan):
|
||||
if plan.caption and caption:
|
||||
return render_captioned_photo(
|
||||
data,
|
||||
caption,
|
||||
self.service.renderer.font,
|
||||
plan.fit,
|
||||
plan.profile,
|
||||
size=(plan.width, plan.height),
|
||||
centering=(plan.center_x, plan.center_y),
|
||||
quality=plan.quality,
|
||||
respect_fit=True,
|
||||
)
|
||||
return convert(
|
||||
data,
|
||||
plan.fit,
|
||||
plan.profile,
|
||||
size=(plan.width, plan.height),
|
||||
centering=(plan.center_x, plan.center_y),
|
||||
quality=plan.quality,
|
||||
)
|
||||
|
||||
def generate(self, picture_id, plan_id, snapshot=None):
|
||||
with self.service.lock:
|
||||
picture = self.picture(picture_id)
|
||||
plan = (
|
||||
GenerationPlan.model_validate(snapshot)
|
||||
if snapshot
|
||||
else self.plan(plan_id)
|
||||
)
|
||||
if picture["source_id"] != plan.source_id:
|
||||
raise FlowError(
|
||||
E.CONFLICT,
|
||||
"Picture and generation plan must share a source library",
|
||||
409,
|
||||
)
|
||||
revision = hashlib.sha256(encode(plan.model_dump()).encode()).hexdigest()
|
||||
row = self.db.one(
|
||||
"SELECT asset_id FROM library_versions WHERE source_image_id=? AND plan_id=? AND revision=?",
|
||||
(picture_id, plan_id, revision),
|
||||
)
|
||||
if row:
|
||||
return next(
|
||||
v
|
||||
for v in self.versions(picture_id)
|
||||
if v["asset_id"] == row["asset_id"]
|
||||
)
|
||||
data = self.service.assets.path(picture_id).read_bytes()
|
||||
asset = self.service.assets.put(
|
||||
self.render(data, picture["caption"], plan),
|
||||
picture["name"].removesuffix(".jpg") + "-" + plan.name,
|
||||
"renditions",
|
||||
)
|
||||
self.db.execute(
|
||||
"INSERT INTO library_versions VALUES(?,?,?,?,?,?)",
|
||||
(
|
||||
asset["asset_id"],
|
||||
picture_id,
|
||||
plan_id,
|
||||
revision,
|
||||
encode(plan.model_dump()),
|
||||
now_iso(),
|
||||
),
|
||||
)
|
||||
self.db.execute(
|
||||
"INSERT INTO asset_sources VALUES(?,?)",
|
||||
(
|
||||
asset["asset_id"],
|
||||
encode(
|
||||
{
|
||||
"type": "library_version",
|
||||
"source_image_id": picture_id,
|
||||
"source_id": plan.source_id,
|
||||
"plan_id": plan_id,
|
||||
"revision": revision,
|
||||
}
|
||||
),
|
||||
),
|
||||
)
|
||||
return next(
|
||||
v
|
||||
for v in self.versions(picture_id)
|
||||
if v["asset_id"] == asset["asset_id"]
|
||||
)
|
||||
|
||||
def validate_asset_binding(self, terminal_id, asset_id):
|
||||
if self.db.one("SELECT id FROM source_images WHERE id=?", (asset_id,)):
|
||||
raise FlowError(
|
||||
E.CONFLICT,
|
||||
"Generate a terminal version before sending source pixels",
|
||||
409,
|
||||
{"reason": "libraryGenerateFirst"},
|
||||
)
|
||||
version = self.db.one(
|
||||
"SELECT v.plan_id,s.source_id FROM library_versions v JOIN source_images s ON s.id=v.source_image_id WHERE v.asset_id=?",
|
||||
(asset_id,),
|
||||
)
|
||||
if version:
|
||||
binding = self.binding(terminal_id)
|
||||
if (
|
||||
not binding
|
||||
or binding["source_id"] != version["source_id"]
|
||||
or binding["plan_id"] != version["plan_id"]
|
||||
):
|
||||
raise FlowError(
|
||||
E.CONFLICT, "Image no longer matches the terminal binding", 409
|
||||
)
|
||||
|
||||
def send(
|
||||
self,
|
||||
terminal_id,
|
||||
asset_id,
|
||||
options=None,
|
||||
client_id="admin",
|
||||
parent_operation_id=None,
|
||||
):
|
||||
with self.service.lock:
|
||||
binding = self.binding(terminal_id)
|
||||
version = self.db.one(
|
||||
"SELECT v.*,s.source_id FROM library_versions v JOIN source_images s ON s.id=v.source_image_id WHERE v.asset_id=?",
|
||||
(asset_id,),
|
||||
)
|
||||
if (
|
||||
not binding
|
||||
or not version
|
||||
or binding["source_id"] != version["source_id"]
|
||||
or binding["plan_id"] != version["plan_id"]
|
||||
):
|
||||
raise FlowError(
|
||||
E.CONFLICT,
|
||||
"Image does not match the terminal source and plan binding",
|
||||
409,
|
||||
)
|
||||
if terminal_id == "watch":
|
||||
raise FlowError(
|
||||
E.UNSUPPORTED_CAPABILITY, "Watch transfer is not connected", 422
|
||||
)
|
||||
request = {
|
||||
"device_id": terminal_id,
|
||||
"content": {"type": "image", "asset_id": asset_id},
|
||||
"options": {
|
||||
**(options or {}),
|
||||
"cleanup_unstarred": False,
|
||||
"generate_thumbnail": False,
|
||||
"thumbnail_plan_id": None,
|
||||
},
|
||||
}
|
||||
from pigway_frame_flow.models import DisplayRequest
|
||||
|
||||
request_model = DisplayRequest.model_validate(request)
|
||||
request = request_model.model_dump(mode="json")
|
||||
previous = self.db.one(
|
||||
"SELECT operation_id FROM terminal_deliveries WHERE terminal_id=? AND asset_id=? "
|
||||
+ ("AND parent_operation_id=? " if parent_operation_id else "")
|
||||
+ "ORDER BY created_at DESC,operation_id DESC LIMIT 1",
|
||||
(terminal_id, asset_id, parent_operation_id)
|
||||
if parent_operation_id
|
||||
else (terminal_id, asset_id),
|
||||
)
|
||||
if previous:
|
||||
op = self.db.one(
|
||||
"SELECT id,status,payload FROM operations WHERE id=?",
|
||||
(previous["operation_id"],),
|
||||
)
|
||||
if op and (
|
||||
parent_operation_id
|
||||
or (
|
||||
op["status"]
|
||||
in ("QUEUED", "RENDERING", "UPLOADING", "SYNCING", "UNKNOWN")
|
||||
and json.loads(op["payload"]) == request
|
||||
)
|
||||
):
|
||||
return op["id"]
|
||||
self.service.validate(request_model)
|
||||
# Reuse the queue's durable idempotency receipt if a restart occurs
|
||||
# between submitting a child operation and linking its delivery row.
|
||||
key = (
|
||||
"library-output-"
|
||||
+ hashlib.sha256(
|
||||
encode([parent_operation_id, terminal_id, asset_id]).encode()
|
||||
).hexdigest()
|
||||
if parent_operation_id
|
||||
else None
|
||||
)
|
||||
operation = self.service.submit(
|
||||
"display",
|
||||
request,
|
||||
terminal_id,
|
||||
client_id,
|
||||
"library-" + uuid4().hex,
|
||||
key=key,
|
||||
)
|
||||
self.db.execute(
|
||||
"INSERT INTO terminal_deliveries VALUES(?,?,?,?,?)",
|
||||
(terminal_id, asset_id, operation, parent_operation_id, now_iso()),
|
||||
)
|
||||
return operation
|
||||
|
||||
def pipeline(
|
||||
self,
|
||||
source_id,
|
||||
item_id=None,
|
||||
options=None,
|
||||
deliver=False,
|
||||
operation_id=None,
|
||||
caller="admin",
|
||||
):
|
||||
root_operation = (
|
||||
(self.db.one("SELECT * FROM operations WHERE id=?", (operation_id,)) or {})
|
||||
if operation_id
|
||||
else {}
|
||||
)
|
||||
previous = (
|
||||
json.loads(root_operation["result"] or "{}") if root_operation else {}
|
||||
)
|
||||
bindings = previous.get("binding_snapshot")
|
||||
if bindings is None:
|
||||
bindings = self.db.all(
|
||||
"SELECT * FROM terminal_bindings WHERE source_id=? ORDER BY terminal_id",
|
||||
(source_id,),
|
||||
)
|
||||
bindings = [
|
||||
{**b, "plan_snapshot": self.plan(b["plan_id"]).model_dump()}
|
||||
for b in bindings
|
||||
]
|
||||
if not bindings:
|
||||
raise FlowError(
|
||||
E.CONFLICT,
|
||||
"Bind at least one terminal to this source library first",
|
||||
409,
|
||||
{"reason": "libraryBindFirst"},
|
||||
)
|
||||
if operation_id:
|
||||
self.service.update(operation_id, "RENDERING")
|
||||
if previous.get("source_image_id"):
|
||||
selected = self.picture(previous["source_image_id"])
|
||||
if selected["source_id"] != source_id:
|
||||
raise FlowError(
|
||||
E.CONFLICT, "Saved selection belongs to another source library", 409
|
||||
)
|
||||
else:
|
||||
selected = self.service.content_asset(
|
||||
PhotoSourceContent(
|
||||
type="photo_source", source_id=source_id, item_id=item_id
|
||||
),
|
||||
operation_id=operation_id,
|
||||
source_only=True,
|
||||
source_size=max(
|
||||
(
|
||||
(b["plan_snapshot"]["width"], b["plan_snapshot"]["height"])
|
||||
for b in bindings
|
||||
),
|
||||
key=lambda size: size[0] * size[1],
|
||||
),
|
||||
)
|
||||
result = {
|
||||
"source_image_id": selected["asset_id"],
|
||||
"binding_snapshot": bindings,
|
||||
"outputs": [],
|
||||
"errors": [],
|
||||
}
|
||||
if operation_id:
|
||||
self.service.assets.record_origin(selected["asset_id"], root_operation)
|
||||
self.service.update(operation_id, "RENDERING", result)
|
||||
versions = {}
|
||||
for binding in bindings:
|
||||
terminal_id, plan_id = binding["terminal_id"], binding["plan_id"]
|
||||
try:
|
||||
if plan_id not in versions:
|
||||
versions[plan_id] = self.generate(
|
||||
selected["asset_id"], plan_id, binding["plan_snapshot"]
|
||||
)
|
||||
version = versions[plan_id]
|
||||
if operation_id:
|
||||
self.service.assets.record_origin(
|
||||
version["asset_id"], root_operation
|
||||
)
|
||||
output = {
|
||||
"terminal_id": terminal_id,
|
||||
"asset_id": version["asset_id"],
|
||||
"plan_id": plan_id,
|
||||
"status": "GENERATED",
|
||||
}
|
||||
if deliver and terminal_id != "watch":
|
||||
output["operation_id"] = self.send(
|
||||
terminal_id,
|
||||
version["asset_id"],
|
||||
options,
|
||||
"library-terminal" if operation_id else caller,
|
||||
parent_operation_id=operation_id,
|
||||
)
|
||||
output["status"] = "QUEUED"
|
||||
result["outputs"].append(output)
|
||||
if operation_id:
|
||||
self.service.update(operation_id, "RENDERING", result)
|
||||
except (FlowError, OSError) as exc:
|
||||
result["errors"].append(
|
||||
{
|
||||
"terminal_id": terminal_id,
|
||||
"plan_id": plan_id,
|
||||
"message": str(exc),
|
||||
}
|
||||
)
|
||||
if operation_id:
|
||||
self.service.update(
|
||||
operation_id,
|
||||
"COMPLETED" if not result["errors"] else "FAILED",
|
||||
result,
|
||||
error={
|
||||
"code": E.INVALID_REQUEST,
|
||||
"message": "Some terminal outputs failed",
|
||||
"request_id": root_operation["request_id"],
|
||||
"details": {"reason": "libraryOutputPartial"},
|
||||
}
|
||||
if result["errors"]
|
||||
else None,
|
||||
)
|
||||
return result
|
||||
|
||||
def execute(self, operation, payload):
|
||||
result = self.pipeline(
|
||||
payload["content"]["source_id"],
|
||||
payload["content"].get("item_id"),
|
||||
payload.get("options"),
|
||||
deliver=True,
|
||||
operation_id=operation["id"],
|
||||
)
|
||||
self.db.history(operation, "COMPLETED" if not result["errors"] else "FAILED")
|
||||
|
||||
def check_asset_references(self, asset_id):
|
||||
reference = "%" + asset_id + "%"
|
||||
if (
|
||||
self.db.one(
|
||||
"SELECT id FROM operations WHERE (payload LIKE ? OR result LIKE ?) AND status IN ('QUEUED','RENDERING','UPLOADING','SYNCING','UNKNOWN')",
|
||||
(reference, reference),
|
||||
)
|
||||
or self.db.one("SELECT id FROM jobs WHERE payload LIKE ?", (reference,))
|
||||
or self.db.one(
|
||||
"SELECT template_id FROM template_config WHERE payload LIKE ?",
|
||||
(reference,),
|
||||
)
|
||||
):
|
||||
raise FlowError(
|
||||
E.CONFLICT,
|
||||
"Image is used by a task, template or pending operation",
|
||||
409,
|
||||
)
|
||||
|
||||
def remove_asset(self, asset_id):
|
||||
self.service.assets.path(asset_id).unlink(missing_ok=True)
|
||||
self.db.execute("DELETE FROM library_versions WHERE asset_id=?", (asset_id,))
|
||||
self.db.execute("DELETE FROM terminal_deliveries WHERE asset_id=?", (asset_id,))
|
||||
self.db.execute("DELETE FROM source_images WHERE id=?", (asset_id,))
|
||||
for table, key in [
|
||||
("asset_sources", "asset_id"),
|
||||
("asset_metadata", "asset_id"),
|
||||
("assets", "id"),
|
||||
]:
|
||||
self.db.execute(f"DELETE FROM {table} WHERE {key}=?", (asset_id,))
|
||||
self.db.execute(
|
||||
"DELETE FROM photo_preferences WHERE source_id='@assets' AND item_id=?",
|
||||
(asset_id,),
|
||||
)
|
||||
|
||||
def delete_picture(self, picture_id):
|
||||
with self.service.lock:
|
||||
self.picture(picture_id)
|
||||
ids = [
|
||||
r["asset_id"]
|
||||
for r in self.db.all(
|
||||
"SELECT asset_id FROM library_versions WHERE source_image_id=?",
|
||||
(picture_id,),
|
||||
)
|
||||
] + [picture_id]
|
||||
for asset_id in ids:
|
||||
self.check_asset_references(asset_id)
|
||||
for asset_id in ids:
|
||||
self.remove_asset(asset_id)
|
||||
|
||||
def check_source_delete(self, source_id):
|
||||
if (
|
||||
self.db.one(
|
||||
"SELECT terminal_id FROM terminal_bindings WHERE source_id=?",
|
||||
(source_id,),
|
||||
)
|
||||
or self.db.one(
|
||||
"SELECT id FROM library_plans WHERE source_id=? AND archived=0",
|
||||
(source_id,),
|
||||
)
|
||||
or self.db.one(
|
||||
"SELECT id FROM source_images WHERE source_id=?", (source_id,)
|
||||
)
|
||||
):
|
||||
raise FlowError(
|
||||
E.CONFLICT,
|
||||
"Remove terminal bindings, generation plans and local source pictures first",
|
||||
409,
|
||||
)
|
||||
@@ -54,6 +54,9 @@ class DisplayService:
|
||||
self.cloud_library = CloudLibrary(self)
|
||||
self.recovery = RecoveryMonitor(self)
|
||||
self.upload_reconciliation = UploadReconciliation(self)
|
||||
from pigway_frame_flow.display.libraries import Libraries
|
||||
|
||||
self.libraries = Libraries(self)
|
||||
self.expiration_checked: dict[str, float] = {}
|
||||
self.unknown_checked: dict[str, float] = {}
|
||||
# A killed write cannot be replayed safely; pre-write work can resume.
|
||||
@@ -67,6 +70,14 @@ class DisplayService:
|
||||
|
||||
def validate(self, request, writing=True):
|
||||
self.registry.get(request.device_id)
|
||||
if (
|
||||
writing
|
||||
and isinstance(request.content, ImageContent)
|
||||
and request.content.asset_id
|
||||
):
|
||||
self.libraries.validate_asset_binding(
|
||||
request.device_id, request.content.asset_id
|
||||
)
|
||||
if request.options.thumbnail_plan_id:
|
||||
from pigway_frame_flow.display.thumbnail_plans import get_plan
|
||||
|
||||
@@ -120,7 +131,14 @@ class DisplayService:
|
||||
)
|
||||
return existing["operation_id"]
|
||||
if (
|
||||
action not in ("display", "render", "render-ai", "local-upload")
|
||||
action
|
||||
not in (
|
||||
"display",
|
||||
"render",
|
||||
"render-ai",
|
||||
"local-upload",
|
||||
"library-generate",
|
||||
)
|
||||
and tx.execute(
|
||||
"SELECT id FROM operations WHERE device_id=? AND status='UNKNOWN' AND write_started=1 AND action!='render-ai' LIMIT 1",
|
||||
(device_id,),
|
||||
@@ -281,6 +299,8 @@ class DisplayService:
|
||||
*,
|
||||
staging=False,
|
||||
source_image=None,
|
||||
source_only=False,
|
||||
source_size=None,
|
||||
):
|
||||
if isinstance(content, AutoCollageContent):
|
||||
from pigway_frame_flow.extensions.auto_collage import prepare
|
||||
@@ -323,10 +343,15 @@ class DisplayService:
|
||||
return asset
|
||||
if isinstance(content, PhotoSourceContent):
|
||||
source = self.photo_sources.get(content.source_id)
|
||||
display = (
|
||||
self.registry.get(device_id or self.settings.device_id).device().display
|
||||
)
|
||||
size = frame_canvas(display, source.orientation)
|
||||
if source_only:
|
||||
size = source_size or (1200, 1600)
|
||||
else:
|
||||
display = (
|
||||
self.registry.get(device_id or self.settings.device_id)
|
||||
.device()
|
||||
.display
|
||||
)
|
||||
size = frame_canvas(display, source.orientation)
|
||||
import random
|
||||
|
||||
items = self.photo_sources.catalog(content.source_id, target_size=size)
|
||||
@@ -406,6 +431,17 @@ class DisplayService:
|
||||
),
|
||||
),
|
||||
)
|
||||
if source_only:
|
||||
selected = self.libraries.ingest(
|
||||
content.source_id, item["id"], data, name, caption
|
||||
)
|
||||
attempt["status"] = "PREPARED"
|
||||
if operation_id:
|
||||
self.db.execute(
|
||||
"UPDATE operations SET result=? WHERE id=?",
|
||||
(encode({"photo_attempts": attempts}), operation_id),
|
||||
)
|
||||
return selected
|
||||
rendered = (
|
||||
render_captioned_photo(
|
||||
data,
|
||||
@@ -565,13 +601,20 @@ class DisplayService:
|
||||
"render",
|
||||
"render-ai",
|
||||
"local-upload",
|
||||
"library-generate",
|
||||
) and self.blocked(op["device_id"]):
|
||||
continue
|
||||
payload = json.loads(op["payload"])
|
||||
if (
|
||||
op["action"] in ("display", "delete", "restore")
|
||||
and op["client_id"]
|
||||
in ("scheduler", "scheduler-retry", "cloud-library", "retention")
|
||||
in (
|
||||
"scheduler",
|
||||
"scheduler-retry",
|
||||
"library-terminal",
|
||||
"cloud-library",
|
||||
"retention",
|
||||
)
|
||||
and self.db.one(
|
||||
"SELECT id FROM operations WHERE device_id=? AND action='display' "
|
||||
"AND status='SYNCING' AND write_started=1 AND updated_at>? LIMIT 1",
|
||||
@@ -633,6 +676,9 @@ class DisplayService:
|
||||
|
||||
def _execute(self, op, payload):
|
||||
id, device_id, action = op["id"], op["device_id"], op["action"]
|
||||
# A source run can generate watch versions without any frame driver.
|
||||
if action == "library-generate":
|
||||
return self.operation_handlers[action](op, payload)
|
||||
driver = self.registry.get(device_id)
|
||||
if action in self.operation_handlers:
|
||||
return self.operation_handlers[action](op, payload)
|
||||
@@ -787,6 +833,8 @@ class DisplayService:
|
||||
|
||||
get_plan(self, request.options.thumbnail_plan_id)
|
||||
content = request.content
|
||||
if isinstance(content, ImageContent) and content.asset_id:
|
||||
self.libraries.validate_asset_binding(device_id, content.asset_id)
|
||||
self.update(id, "RENDERING")
|
||||
asset = None
|
||||
source_image = {}
|
||||
|
||||
@@ -170,6 +170,10 @@ class ErrorResponse(BaseModel):
|
||||
|
||||
|
||||
class OperationResult(BaseModel):
|
||||
source_image_id: str | None = None
|
||||
outputs: list[dict[str, Any]] = Field(default_factory=list)
|
||||
errors: list[dict[str, Any]] = Field(default_factory=list)
|
||||
wrist_thumbnail: dict[str, Any] | None = None
|
||||
image_name: str | None = None
|
||||
preview_url: str | None = None
|
||||
cover_id: int | None = None
|
||||
@@ -283,6 +287,7 @@ class RestoreContent(StrictModel):
|
||||
|
||||
|
||||
class JobInput(StrictModel):
|
||||
library_pipeline: bool = False
|
||||
generate_thumbnail: bool = False
|
||||
thumbnail_plan_id: str | None = Field(default=None, pattern=r"^[a-f0-9]{32}$")
|
||||
cleanup_unstarred: bool = False
|
||||
@@ -297,6 +302,14 @@ class JobInput(StrictModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_options(self):
|
||||
if self.library_pipeline and (
|
||||
not isinstance(self.source, PhotoSourceContent)
|
||||
or self.generate_thumbnail
|
||||
or self.cleanup_unstarred
|
||||
):
|
||||
raise ValueError(
|
||||
"Source-library tasks require a photo source and use terminal-bound generation plans"
|
||||
)
|
||||
if self.generate_thumbnail and (
|
||||
not self.thumbnail_plan_id
|
||||
or not isinstance(self.source, PhotoSourceContent)
|
||||
|
||||
@@ -156,7 +156,9 @@ class Assets:
|
||||
"job_id": meta["job_id"] if meta else None,
|
||||
"job_name": meta["job_name"] if meta else None,
|
||||
"asset_id": row["id"],
|
||||
"source": "generated" if row["kind"] == "generated" else "uploaded",
|
||||
"source": "generated"
|
||||
if row["kind"] in ("generated", "renditions")
|
||||
else "uploaded",
|
||||
"name": row["name"],
|
||||
"url": f"/api/v1/assets/{row['id']}",
|
||||
"created_at": row["created_at"],
|
||||
|
||||
@@ -17,6 +17,9 @@ def render_captioned_photo(
|
||||
*,
|
||||
size=(1200, 1600),
|
||||
layout_provider=None,
|
||||
centering=(0.5, 0.5),
|
||||
quality=93,
|
||||
respect_fit=False,
|
||||
):
|
||||
width, height = size
|
||||
layout = (layout_provider or WatermarkLayout()).layout(size, caption)
|
||||
@@ -31,9 +34,11 @@ def render_captioned_photo(
|
||||
# keeping aspect ratio and cropping rather than stretching or letterboxing.
|
||||
photo_data = convert(
|
||||
data,
|
||||
"smart" if fit == "smart" else "cover",
|
||||
fit if respect_fit else ("smart" if fit == "smart" else "cover"),
|
||||
profile,
|
||||
size=(width, photo_height),
|
||||
centering=centering,
|
||||
quality=quality,
|
||||
)
|
||||
canvas = Image.new("RGB", size, "white")
|
||||
with Image.open(BytesIO(photo_data)) as photo:
|
||||
@@ -64,5 +69,5 @@ def render_captioned_photo(
|
||||
anchor="rm",
|
||||
)
|
||||
out = BytesIO()
|
||||
canvas.save(out, "JPEG", quality=93, optimize=True)
|
||||
canvas.save(out, "JPEG", quality=quality, optimize=True)
|
||||
return out.getvalue()
|
||||
|
||||
@@ -15,6 +15,7 @@ from pigway_frame_flow.models import (
|
||||
ImageContent,
|
||||
JobInput,
|
||||
Options,
|
||||
PhotoSourceContent,
|
||||
PlaylistInput,
|
||||
RestoreContent,
|
||||
)
|
||||
@@ -86,17 +87,64 @@ class Scheduler:
|
||||
row["last_result"] = operation["status"]
|
||||
return rows
|
||||
|
||||
def save_job(self, job, id=None):
|
||||
self.service.registry.get(job.device_id)
|
||||
def validate_job(self, job):
|
||||
if not job.library_pipeline:
|
||||
self.service.registry.get(job.device_id)
|
||||
if job.library_pipeline and not self.db.one(
|
||||
"SELECT terminal_id FROM terminal_bindings WHERE source_id=?",
|
||||
(job.source.source_id,),
|
||||
):
|
||||
raise FlowError(
|
||||
E.CONFLICT,
|
||||
"Bind terminals to the source library before saving this task",
|
||||
409,
|
||||
{"reason": "libraryBindFirst"},
|
||||
)
|
||||
if job.generate_thumbnail:
|
||||
from pigway_frame_flow.display.thumbnail_plans import get_plan
|
||||
|
||||
get_plan(self.service, job.thumbnail_plan_id)
|
||||
if job.source.type != "restore":
|
||||
if job.library_pipeline:
|
||||
self.service.photo_sources.get(job.source.source_id)
|
||||
elif job.source.type != "restore":
|
||||
self.service.validate(
|
||||
DisplayRequest(device_id=job.device_id, content=job.source),
|
||||
writing=False,
|
||||
)
|
||||
|
||||
def set_library_mode(self, job_id, enabled):
|
||||
with self.service.lock:
|
||||
row = self.db.one("SELECT payload FROM jobs WHERE id=?", (job_id,))
|
||||
if not row:
|
||||
raise FlowError(E.NOT_FOUND, "Job not found", 404)
|
||||
values = json.loads(row["payload"])
|
||||
if enabled and values.get("source", {}).get("type") != "photo_source":
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST,
|
||||
"Only photo-source tasks support source-library mode",
|
||||
422,
|
||||
)
|
||||
values["library_pipeline"] = enabled
|
||||
if enabled:
|
||||
values.update(
|
||||
generate_thumbnail=False,
|
||||
thumbnail_plan_id=None,
|
||||
cleanup_unstarred=False,
|
||||
)
|
||||
job = JobInput.model_validate(values)
|
||||
self.validate_job(job)
|
||||
self.db.execute(
|
||||
"UPDATE jobs SET payload=?,updated_at=? WHERE id=?",
|
||||
(
|
||||
encode(self.service.settings.sanitize(job.model_dump(mode="json"))),
|
||||
now_iso(),
|
||||
job_id,
|
||||
),
|
||||
)
|
||||
return next(j for j in self.jobs() if j["id"] == job_id)
|
||||
|
||||
def save_job(self, job, id=None):
|
||||
self.validate_job(job)
|
||||
upcoming = next_run(job.schedule)
|
||||
if job.enabled and upcoming is None:
|
||||
raise FlowError(E.INVALID_REQUEST, "One-time task must be in the future")
|
||||
@@ -192,6 +240,8 @@ class Scheduler:
|
||||
),
|
||||
).model_dump(mode="json")
|
||||
)
|
||||
if job.library_pipeline:
|
||||
action = "library-generate"
|
||||
if action == "display":
|
||||
payload = self.upload_retries.scheduled_payload(
|
||||
row["id"], payload
|
||||
@@ -199,7 +249,10 @@ class Scheduler:
|
||||
operation_id = self.service.submit(
|
||||
action,
|
||||
payload,
|
||||
job.device_id,
|
||||
("source:" + job.source.source_id)
|
||||
if job.library_pipeline
|
||||
and isinstance(job.source, PhotoSourceContent)
|
||||
else job.device_id,
|
||||
"scheduler",
|
||||
"schedule-" + uuid4().hex,
|
||||
"job-" + row["id"] + "-" + slot,
|
||||
|
||||
@@ -36,19 +36,20 @@ function applyTheme(){const dark=state.theme==='dark'||(state.theme==='system'&&
|
||||
function revealCurrentNavigation(){const active=$('#nav .active'),sidebar=$('.sidebar');if(active&&matchMedia('(max-width:720px)').matches)sidebar.scrollLeft=active.offsetLeft-(sidebar.clientWidth-active.offsetWidth)/2;}
|
||||
window.addEventListener('resize',revealCurrentNavigation);
|
||||
function deviceNameForId(id){return deviceDisplayName(state.devices.find(d=>d.id===id)||{id,name:id});}
|
||||
function operationActionLabel(action){const labels={'render':['生成预览','Generate preview'],'render-ai':['AI 生成图片','Generate AI image'],'official-widget':['更新官方订阅','Update official widget'],'delete':['删除图片','Delete image'],'display':['显示图片','Display image'],'restore':['恢复图片','Restore image'],'local-upload':['保存到图库','Save to gallery'],'reconcile':['核对状态','Reconcile']};return labels[action]?.[state.lang==='zh'?0:1]||t('operation');}
|
||||
function operationClientLabel(client){return client==='admin'?tr('管理员','Administrator'):client==='scheduler'?tr('自动调度','Scheduler'):client;}
|
||||
function operationActionLabel(action){const labels={'render':['生成预览','Generate preview'],'render-ai':['AI 生成图片','Generate AI image'],'official-widget':['更新官方订阅','Update official widget'],'delete':['删除图片','Delete image'],'display':['显示图片','Display image'],'restore':['恢复图片','Restore image'],'library-generate':['来源图库统一输出','Source-library outputs'],'local-upload':['保存到图库','Save to gallery'],'reconcile':['核对状态','Reconcile']};return labels[action]?.[state.lang==='zh'?0:1]||t('operation');}
|
||||
function operationClientLabel(client){return client==='admin'?tr('管理员','Administrator'):client==='scheduler'?tr('自动调度','Scheduler'):client==='library-terminal'?tr('来源图库输出','Source-library output'):client;}
|
||||
function operationResultHTML(o){
|
||||
const r=o.result||{},parts=[];
|
||||
if(r.image_name)parts.push(`<div>${esc(r.image_name)}</div>`);
|
||||
if(r.image_id!=null)parts.push(`<small>${t('cloudImageId')}: ${esc(r.image_id)}</small>`);
|
||||
else if(r.asset_id||r.rendered_asset_id)parts.push(`<small>${t('localAssetId')}: ${esc(r.asset_id||r.rendered_asset_id)}</small>`);
|
||||
if(r.source_image_id)parts.push(`<div>${tr('已选定源画面','Source picture selected')}</div><small>${tr('输出:','Outputs: ')}${(r.outputs||[]).map(x=>esc(x.terminal_id==='watch'?tr('手表','Watch'):deviceNameForId(x.terminal_id))).join('、')||'—'}</small>`);
|
||||
if(o.error)parts.push(`<div class="history-status bad">${esc(operationErrorMessage(o.error))}</div>`);
|
||||
return parts.join('')||'—';
|
||||
}
|
||||
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);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();}
|
||||
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).filter(([id])=>id!=='legacy-plans').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','watch-thumbnails']);
|
||||
@@ -152,10 +153,10 @@ const views={
|
||||
async upload(){state.preview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('uploadNotice')}</div><div class="columns"><section class="card"><h2>${t('upload')}</h2><div class="dropzone"><strong>${t('selectFile')}</strong><p class="hint">${t('uploadHint')}</p><input id="file" type="file" accept="image/jpeg,image/png,image/webp" aria-label="${t('selectFile')}"></div><div class="form-grid" style="margin-top:24px"><div class="field"><label for="fit">${t('mode')}</label><select id="fit">${['contain','cover','smart'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label for="profile">${t('profile')}</label><select id="profile">${['color','grayscale'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div></div>${buttons('make-preview','preview',true)}<p class="hint">${t('previewHint')}</p></section><section class="card"><h3>${t('preview')}</h3><div id="preview-box" class="empty">${t('previewHint')}</div><div class="actions" style="margin-top:18px"><button id="save-local" disabled>${t('staging')}</button><button id="display-upload" class="primary" disabled>${t('display')}</button></div></section></div>`;bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);state.preview=await api('/api/v1/preview',{method:'POST',body:form});$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('save-local',async()=>{const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image',asset_id:state.preview.asset_id});await waitOperation(id);state.tab='local';return navigate('gallery');});bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id}));},
|
||||
async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('templateHint')}<p>${t('templateSteps')}</p></div><div class="template-grid">${state.templates.map((x,i)=>`<div class="card template-card ${x.id===selected.id?'selected':''}" role="button" tabindex="0" data-template="${x.id}"><span>${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}</span><div><h3>${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}</h3><small>1200 × 1600 · ${esc(x.id)}</small></div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('templateData')}</h3><p class="hint">${t('templateGuideTitle')}</p><p class="hint">${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}</p>${['weather','daily-dashboard'].includes(selected.id)?`<p class="hint">${t('templateWeatherHelp')}</p>`:''}<textarea id="template-data" aria-label="${t('templateData')}">${esc(JSON.stringify(selected.defaults,null,2))}</textarea><div class="field" style="margin-top:16px"><label for="template-profile">${t('profile')}</label><select id="template-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select><p class="hint">${t('helpProfile')}</p></div><p class="hint">${t('templateActionHelp')}</p><p class="hint">${t('templateAutomationHelp')}</p><div class="actions">${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}</div></section><section class="card"><h3>${t('preview')}</h3><div id="template-preview" class="empty">${t('templatePreviewHint')}</div><button id="display-template" class="primary" style="margin-top:18px" disabled>${t('display')}</button></section></div>`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate(['collage','poster'].includes(b.dataset.template)?'studio':undefined);};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};if([...document.querySelectorAll('#template-fields textarea'),$('#template-data')].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='<img id="template-image" class="preview" alt="Template preview">';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}},
|
||||
async playlists(epoch){const playlists=await api('/api/v1/playlists');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="notice">${t('playlistNotice')}</div><div class="section-heading"><h2>${t('playlists')}</h2>${buttons('new-playlist','newPlaylist',true)}</div><div class="gallery">${playlists.map((p,i)=>`<section class="card"><span class="badge">${t(p.enabled?'enabled':'disabled')}</span><h3 style="margin-top:16px">${esc(p.name)}</h3><p class="hint">${p.image_ids.length} ${t('count')} · ${p.interval_minutes} ${t('minutes')} · ${t(p.mode)}</p><p><code>${esc(p.image_ids.join(' → '))}</code></p><small>${t('lastScheduled')}: ${p.position>=0?esc(p.image_ids[p.position]):'—'}</small><div class="actions" style="margin-top:16px"><button data-pl-edit="${i}">${t('edit')}</button><button class="danger" data-pl-delete="${i}">${t('remove')}</button></div></section>`).join('')||`<div class="card empty"><strong>${t('noTasks')}</strong></div>`}</div>`;bind('new-playlist',()=>playlistEditor());document.querySelectorAll('[data-pl-edit]').forEach(b=>b.onclick=()=>playlistEditor(playlists[b.dataset.plEdit]));document.querySelectorAll('[data-pl-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/playlists/'+playlists[b.dataset.plDelete].id,{method:'DELETE'});await navigate();}));},
|
||||
async history(epoch){const data=await api('/api/v1/operation-history?'+historyQuery());if(epoch!==state.epoch)return;state.historyPageData=data;state.historyPage=data.page;const ops=data.items;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr data-operation-id="${esc(o.operation_id)}"><td>${fmt(o.created_at)}</td><td>${esc(operationActionLabel(o.action))}<br><small>${esc(deviceNameForId(o.device_id))}</small></td><td><span class="history-status ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${operationResultHTML(o)}</td><td>${esc(operationClientLabel(o.client_id))}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
|
||||
async history(epoch){const data=await api('/api/v1/operation-history?'+historyQuery());if(epoch!==state.epoch)return;state.historyPageData=data;state.historyPage=data.page;const ops=data.items;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr data-operation-id="${esc(o.operation_id)}"><td>${fmt(o.created_at)}</td><td>${esc(operationActionLabel(o.action))}<br><small>${esc(o.action==='library-generate'?tr('按来源绑定终端','Source-bound terminals'):deviceNameForId(o.device_id))}</small></td><td><span class="history-status ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${operationResultHTML(o)}</td><td>${esc(operationClientLabel(o.client_id))}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
|
||||
async settings(){state.settings=await api('/api/v1/settings');const s=state.settings;$('#main').innerHTML=notice()+`<div class="columns settings-columns"><section class="card"><h3>${t('settings')}</h3><div class="form-grid settings-preferences"><div class="field"><label for="theme-select">${t('theme')}</label><select id="theme-select">${['system','light','dark'].map(x=>`<option value="${x}" ${state.theme===x?'selected':''}>${t(x==='system'?'systemTheme':x)}</option>`).join('')}</select></div><div class="field"><label for="language-select">${t('language')}</label><select id="language-select"><option value="zh" ${state.lang==='zh'?'selected':''}>简体中文</option><option value="en" ${state.lang==='en'?'selected':''}>English</option></select></div></div><p class="hint">${t('tokenHint')}</p>${buttons('logout','logout')}</section><section class="card"><h3>${t('auth')}</h3><div class="detail-grid">${detail('version',s.version)}${detail('writeProtection',t(!s.write_enabled?'yes':'no'))}${detail('keyConfigured',t(s.api_key_configured?'yes':'no'))}${detail('timezone',s.timezone)}</div><p class="hint">${t('refreshInterval')}</p><a href="/docs" target="_blank" rel="noopener">${t('apiDocs')} →</a><p class="hint">GET /openapi.json · /api/v1</p></section></div>`;$('#theme-select').onchange=()=>{state.theme=$('#theme-select').value;localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#language-select').onchange=()=>{state.lang=$('#language-select').value;localStorage.setItem('ff-language',state.lang);navigate();};bind('logout',async()=>{await api('/api/v1/auth/logout',{method:'POST'});forgetLogin();shell();login();});accountSettings();}
|
||||
};
|
||||
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};}
|
||||
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,library_pipeline:j.library_pipeline||false,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=()=>{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(()=>{});
|
||||
@@ -177,15 +178,16 @@ async function jobEditor(job){
|
||||
function updateScheduleField(){const type=$('#job-schedule').value;const interval=['hours','minutes'].includes(type);$('#schedule-value').min=interval?'1':'';$('#schedule-value').max=type==='hours'?'168':type==='minutes'?'10080':'';$('#schedule-value').step=interval?'1':'';$('#schedule-label').textContent=interval?tr('间隔数值','Interval'):type==='cron'?scheduleTypeLabel(type):t(['date','temporary'].includes(type)?'at':'time');$('#schedule-value').type=interval?'number':type==='cron'?'text':['date','temporary'].includes(type)?'datetime-local':'time';$('#schedule-hint').textContent=scheduleHint(type);}
|
||||
$('#job-schedule').onchange=()=>{updateScheduleField();$('#schedule-value').value='';};
|
||||
updateScheduleField();
|
||||
$('#job-form').onsubmit=busy(async event=>{event.preventDefault();const type=$('#job-schedule').value;const value=$('#schedule-value').value;const schedule=['hours','minutes'].includes(type)?{type:'interval',interval_minutes:Number(value)*(type==='hours'?60:1)}:{type};if(['hours','minutes'].includes(type)){}else if(type==='cron')schedule.cron=value;else if(['date','temporary'].includes(type))schedule.at=value+':00+08:00';else schedule.time=value;const payload=jobPayload(j,{name:$('#job-name').value,schedule,source:JSON.parse(source.value),display_duration:Number($('#job-duration').value),priority:Number($('#job-priority').value),restore_previous:$('#job-restore').checked,cleanup_unstarred:false,generate_thumbnail:$('#job-thumbnail').checked,thumbnail_plan_id:$('#job-thumbnail').checked?$('#job-thumbnail-plan').value:null,enabled:$('#job-enabled').checked});await api('/api/v1/jobs'+(job?.id?'/'+job.id:''),{method:job?.id?'PUT':'POST',body:payload});$('#modal').close();await navigate();});
|
||||
$('#job-form').onsubmit=busy(async event=>{event.preventDefault();const type=$('#job-schedule').value;const value=$('#schedule-value').value;const schedule=['hours','minutes'].includes(type)?{type:'interval',interval_minutes:Number(value)*(type==='hours'?60:1)}:{type};if(['hours','minutes'].includes(type)){}else if(type==='cron')schedule.cron=value;else if(['date','temporary'].includes(type))schedule.at=value+':00+08:00';else schedule.time=value;const payload=jobPayload(j,{name:$('#job-name').value,schedule,source:JSON.parse(source.value),display_duration:Number($('#job-duration').value),priority:Number($('#job-priority').value),restore_previous:$('#job-restore').checked,cleanup_unstarred:false,library_pipeline:$('#job-library-pipeline').checked,generate_thumbnail:!$('#job-library-pipeline').checked&&$('#job-thumbnail').checked,thumbnail_plan_id:!$('#job-library-pipeline').checked&&$('#job-thumbnail').checked?$('#job-thumbnail-plan').value:null,enabled:$('#job-enabled').checked});await api('/api/v1/jobs'+(job?.id?'/'+job.id:''),{method:job?.id?'PUT':'POST',body:payload});$('#modal').close();await navigate();});
|
||||
await jobSourceEditor(j);
|
||||
await thumbnailJobFields(j);
|
||||
await libraryJobFields(j,!job?.id);
|
||||
}
|
||||
|
||||
views.automation=async epoch=>{
|
||||
const jobs=await api('/api/v1/jobs');
|
||||
if(epoch!==state.epoch)return;
|
||||
$('#main').innerHTML=notice()+`<div class="section-heading"><p class="hint">${state.lang==='zh'?'上海时间':'Shanghai time'} · ${t('automation')}</p>${buttons('new-job','newJob',true)}</div><div class="card">${jobs.length?`<div class="table-wrap"><table class="automation-table"><thead><tr>${['name','targetFrame','schedule','status','nextRun','lastRun','result','action'].map(key=>`<th>${key==='targetFrame'?tr('目标相框','Target frame'):t(key)}</th>`).join('')}</tr></thead><tbody>${jobs.map((job,index)=>`<tr><td><strong>${esc(job.name)}</strong></td><td>${esc(deviceDisplayName(state.devices.find(d=>d.id===job.device_id)||{id:job.device_id,name:job.device_id}))}</td><td>${esc(scheduleSummary(job.schedule))}</td><td><span class="automation-status">${t(job.enabled?'enabled':'disabled')}</span></td><td>${fmt(job.next_run)}</td><td>${fmt(job.last_run)}</td><td><code>${esc(job.last_result)}</code></td><td><div class="row-actions"><button data-job-edit="${index}">${t('edit')}</button><button data-job-toggle="${index}">${t(job.enabled?'disabled':'enabled')}</button><button class="danger" data-job-delete="${index}">${t('remove')}</button></div></td></tr>`).join('')}</tbody></table></div>`:`<div class="empty"><strong>${t('noTasks')}</strong></div>`}</div>`;
|
||||
$('#main').innerHTML=notice()+`<div class="section-heading"><p class="hint">${state.lang==='zh'?'上海时间':'Shanghai time'} · ${t('automation')}</p>${buttons('new-job','newJob',true)}</div><div class="card">${jobs.length?`<div class="table-wrap"><table class="automation-table"><thead><tr>${['name','targetFrame','schedule','status','nextRun','lastRun','result','action'].map(key=>`<th>${key==='targetFrame'?tr('输出目标','Output target'):t(key)}</th>`).join('')}</tr></thead><tbody>${jobs.map((job,index)=>`<tr><td><strong>${esc(job.name)}</strong></td><td>${esc(job.library_pipeline?tr('按来源绑定终端','Source-bound terminals'):deviceDisplayName(state.devices.find(d=>d.id===job.device_id)||{id:job.device_id,name:job.device_id}))}</td><td>${esc(scheduleSummary(job.schedule))}</td><td><span class="automation-status">${t(job.enabled?'enabled':'disabled')}</span></td><td>${fmt(job.next_run)}</td><td>${fmt(job.last_run)}</td><td><code>${esc(job.library_pipeline&&job.last_result==='COMPLETED'?tr('生成与提交完成','Generated and submitted'):job.last_result)}</code></td><td><div class="row-actions"><button data-job-edit="${index}">${t('edit')}</button><button data-job-toggle="${index}">${t(job.enabled?'disabled':'enabled')}</button><button class="danger" data-job-delete="${index}">${t('remove')}</button></div></td></tr>`).join('')}</tbody></table></div>`:`<div class="empty"><strong>${t('noTasks')}</strong></div>`}</div>`;
|
||||
bind('new-job',()=>jobEditor());
|
||||
document.querySelectorAll('[data-job-edit]').forEach(button=>button.onclick=()=>jobEditor(jobs[button.dataset.jobEdit]));
|
||||
document.querySelectorAll('[data-job-toggle]').forEach(button=>button.onclick=busy(async()=>{const job=jobs[button.dataset.jobToggle];await api('/api/v1/jobs/'+job.id,{method:'PUT',body:jobPayload(job,{enabled:!job.enabled})});await navigate();}));
|
||||
|
||||
@@ -182,7 +182,7 @@ function historyControls(){
|
||||
const filters=state.historyFilters||{};
|
||||
const form=document.createElement('form');form.id='history-filters';form.className='card history-filters';
|
||||
const select=(id,label,options)=>`<div class="field"><label for="${id}">${t(label)}</label><select id="${id}">${options}</select></div>`;
|
||||
form.innerHTML=`<div class="field"><label for="history-search">${t('searchHistory')}</label><input id="history-search" type="search" maxlength="200" value="${esc(filters.q||'')}"></div>`+select('history-status','status',`<option value="">${t('allStatuses')}</option>`+['QUEUED','RENDERING','UPLOADING','SYNCING','COMPLETED','FAILED','UNKNOWN'].map(x=>`<option value="${x}">${t(x)}</option>`).join(''))+select('history-action','action',`<option value="">${t('allActions')}</option>`+['display','render','render-ai','local-upload','delete','restore','official-widget','reconcile'].map(x=>`<option value="${x}">${esc(operationActionLabel(x))}</option>`).join(''))+select('history-device','device',`<option value="">${t('allDevices')}</option>`+state.devices.map(d=>`<option value="${esc(d.id)}">${esc(deviceNameForId(d.id))}</option>`).join(''))+`<div class="field"><label for="history-start">${t('startDate')}</label><input id="history-start" type="date" value="${esc(filters.start||'')}"></div><div class="field"><label for="history-end">${t('endDate')}</label><input id="history-end" type="date" value="${esc(filters.end||'')}"></div><div class="actions"><button type="submit" class="primary">${t('filter')}</button><button id="history-reset" type="button">${t('resetFilters')}</button></div>`;
|
||||
form.innerHTML=`<div class="field"><label for="history-search">${t('searchHistory')}</label><input id="history-search" type="search" maxlength="200" value="${esc(filters.q||'')}"></div>`+select('history-status','status',`<option value="">${t('allStatuses')}</option>`+['QUEUED','RENDERING','UPLOADING','SYNCING','COMPLETED','FAILED','UNKNOWN'].map(x=>`<option value="${x}">${t(x)}</option>`).join(''))+select('history-action','action',`<option value="">${t('allActions')}</option>`+['library-generate','display','render','render-ai','local-upload','delete','restore','official-widget','reconcile'].map(x=>`<option value="${x}">${esc(operationActionLabel(x))}</option>`).join(''))+select('history-device','device',`<option value="">${t('allDevices')}</option>`+state.devices.map(d=>`<option value="${esc(d.id)}">${esc(deviceNameForId(d.id))}</option>`).join(''))+`<div class="field"><label for="history-start">${t('startDate')}</label><input id="history-start" type="date" value="${esc(filters.start||'')}"></div><div class="field"><label for="history-end">${t('endDate')}</label><input id="history-end" type="date" value="${esc(filters.end||'')}"></div><div class="actions"><button type="submit" class="primary">${t('filter')}</button><button id="history-reset" type="button">${t('resetFilters')}</button></div>`;
|
||||
const wrap=$('#main .table-wrap');wrap.before(form);
|
||||
for(const key of ['status','action','device'])$('#history-'+key).value=filters[key==='device'?'device_id':key]||'';
|
||||
form.onsubmit=busy(async e=>{e.preventDefault();const start=$('#history-start'),end=$('#history-end');end.setCustomValidity(start.value&&end.value&&start.value>end.value?t('dateRangeInvalid'):'');if(!form.reportValidity())return;state.historyFilters={};for(const [id,key] of [['search','q'],['status','status'],['action','action'],['device','device_id'],['start','start'],['end','end']]){const value=$('#history-'+id).value;if(value)state.historyFilters[key]=value;}state.historyPage=1;await navigate();});
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>PIGWay Frame Flow</title><link rel="icon" type="image/svg+xml" href="/ui/favicon.svg"><link rel="icon" href="/ui/favicon.ico" sizes="16x16 32x32 48x48"><link rel="apple-touch-icon" sizes="180x180" href="/ui/apple-touch-icon.png"><link rel="stylesheet" href="/ui/style.css"></head>
|
||||
<body><aside class="sidebar"><a class="brand" href="/" aria-label="PIGWay Frame Flow"><img class="pigway-logo" src="/ui/logo-symbol.svg" alt="" width="56" height="56"><span>PIGWAY<small>FRAME FLOW</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.29</small></div></aside><div class="workspace"><header><div class="workspace-heading"><img class="mobile-brand-logo pigway-logo" src="/ui/logo-symbol.svg" alt="PIGWay" width="56" height="56"><div><span class="eyebrow">PIGWay Frame Flow</span><h1 id="page-title"></h1></div></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=0.1.29"></script><script src="/ui/features.js?v=0.1.29"></script><script src="/ui/extensions.js?v=0.1.29"></script><script src="/ui/app.js?v=0.1.29"></script><script src="/ui/thumbnails.js?v=0.1.29"></script></body></html>
|
||||
<body><aside class="sidebar"><a class="brand" href="/" aria-label="PIGWay Frame Flow"><img class="pigway-logo" src="/ui/logo-symbol.svg" alt="" width="56" height="56"><span>PIGWAY<small>FRAME FLOW</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.29</small></div></aside><div class="workspace"><header><div class="workspace-heading"><img class="mobile-brand-logo pigway-logo" src="/ui/logo-symbol.svg" alt="PIGWay" width="56" height="56"><div><span class="eyebrow">PIGWay Frame Flow</span><h1 id="page-title"></h1></div></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=0.1.29"></script><script src="/ui/features.js?v=0.1.29"></script><script src="/ui/extensions.js?v=0.1.29"></script><script src="/ui/app.js?v=0.1.29"></script><script src="/ui/thumbnails.js?v=0.1.29"></script><script src="/ui/libraries.js?v=0.1.29"></script></body></html>
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
'use strict';
|
||||
// Keep older assets and APIs accessible without interpreting frame products as sources.
|
||||
Object.assign(MESSAGES.zh,{'legacy-plans':'旧版小图方案',libraryOutputPartial:'部分终端输出失败,源画面与成功版本已保留,可单独重试失败目标。',libraryGenerateFirst:'请先按终端生成方案生成成品,源画面不能直接推送。',librarySourceMismatch:'方案不属于所选来源图库,请重新选择。',librarySizeMismatch:'方案尺寸与终端屏幕不匹配;相框使用设备屏幕尺寸,当前手表为 410×502。',libraryPlanBound:'此方案正在被终端使用,请先解除绑定或更换方案。',libraryBindFirst:'请先在终端图库中绑定此来源及其中的生成方案。',libraryBindingChanged:'终端绑定已改变,此版本不再匹配,已停止推送。'});Object.assign(MESSAGES.en,{'legacy-plans':'Legacy thumbnail plans',libraryOutputPartial:'Some outputs failed. Source pixels and successful versions are kept; retry failed targets individually.',libraryGenerateFirst:'Generate a terminal version before sending source pixels.',librarySourceMismatch:'The plan belongs to another source library.',librarySizeMismatch:'Plan dimensions must match the terminal screen. The current watch uses 410×502.',libraryPlanBound:'Unbind terminals or select another plan first.',libraryBindFirst:'Bind a terminal to this source and one of its plans first.',libraryBindingChanged:'The terminal binding changed; this version was not sent.'});
|
||||
const legacyGalleryView=views.gallery;
|
||||
const legacyPlanView=views.thumbnails;
|
||||
delete icons.thumbnails;
|
||||
icons['legacy-plans']='▧';
|
||||
views['legacy-plans']=legacyPlanView;
|
||||
const libraryUI={kind:'source',source:null,plan:null,terminal:null,offset:0,legacy:false};
|
||||
|
||||
function libraryStatus(status){return ({GENERATED:tr('已生成','Generated'),QUEUED:tr('待推送','Queued'),RENDERING:tr('准备推送','Preparing'),UPLOADING:tr('上传中','Uploading'),SYNCING:tr('等待设备确认','Awaiting confirmation'),COMPLETED:tr('已确认显示','Display confirmed'),FAILED:tr('推送失败','Failed'),UNKNOWN:tr('结果待核对','Outcome unknown')})[status]||status;}
|
||||
function terminalLabel(terminal){return terminal.id==='watch'?tr('手表','Watch'):deviceNameForId(terminal.id);}
|
||||
function librarySelectOptions(items,selected,label){return `<option value="">${label}</option>`+items.map(p=>`<option value="${esc(p.id)}" ${p.id===selected?'selected':''}>${esc(p.name)}</option>`).join('');}
|
||||
function libraryLegacy(tab){libraryUI.legacy=true;state.tab=tab;return navigate('gallery');}
|
||||
|
||||
views.gallery=async function(epoch){
|
||||
if(libraryUI.legacy){await legacyGalleryView(epoch);if(epoch!==state.epoch)return;const back=document.createElement('button');back.textContent=tr('返回新图库','Back to libraries');back.onclick=()=>{libraryUI.legacy=false;return navigate('gallery');};$('#main').prepend(back);return;}
|
||||
const [sources,terminals]=await Promise.all([api('/api/v1/source-libraries'),api('/api/v1/library-terminals')]);
|
||||
if(epoch!==state.epoch)return;
|
||||
$('#main').innerHTML=`<div class="section-heading"><div class="tabs"><button id="library-source-tab" class="${libraryUI.kind==='source'?'active':''}">${tr('来源图库','Source libraries')}</button><button id="library-terminal-tab" class="${libraryUI.kind==='terminal'?'active':''}">${tr('终端图库','Terminal libraries')}</button></div><button id="library-refresh">${t('refresh')}</button></div><div id="library-content"></div>`;
|
||||
bind('library-refresh',()=>navigate());
|
||||
bind('library-source-tab',()=>{libraryUI.kind='source';libraryUI.offset=0;state.tab='local';return navigate();});
|
||||
bind('library-terminal-tab',()=>{libraryUI.kind='terminal';libraryUI.offset=0;return navigate();});
|
||||
if(libraryUI.kind==='source')await sourceLibraryPage(epoch,sources,terminals);else await terminalLibraryPage(epoch,sources,terminals);
|
||||
};
|
||||
|
||||
async function sourceLibraryPage(epoch,sources,terminals){
|
||||
const panel=$('#library-content');
|
||||
if(!sources.length){panel.innerHTML=`<section class="card empty"><p>${tr('先在“照片数据源”中添加来源,再建立该来源的生成方案。','Add a photo source, then create generation plans in its library.')}</p><button id="library-add-source">${tr('添加照片来源','Add photo source')}</button><button id="library-legacy">${tr('查看历史本地成品','View legacy local images')}</button></section>`;bind('library-add-source',()=>navigate('photo-sources'));bind('library-legacy',()=>libraryLegacy('local'));return;}
|
||||
if(!sources.some(s=>s.id===libraryUI.source)){libraryUI.source=sources[0].id;libraryUI.plan=null;libraryUI.offset=0;}
|
||||
const sourceId=libraryUI.source;
|
||||
const [plans,page]=await Promise.all([api('/api/v1/generation-plans?source_id='+encodeURIComponent(sourceId)),api('/api/v1/source-libraries/'+encodeURIComponent(sourceId)+'/images?offset='+libraryUI.offset+'&limit=30')]);
|
||||
if(epoch!==state.epoch)return;
|
||||
if(libraryUI.plan&&!plans.some(p=>p.id===libraryUI.plan))libraryUI.plan=null;
|
||||
const plan=plans.find(p=>p.id===libraryUI.plan),uses=terminals.filter(t=>t.binding?.source_id===sourceId&&(!plan||t.binding.plan_id===plan.id));
|
||||
panel.innerHTML=`<section class="card"><div class="field"><label for="library-source">${tr('来源图库','Source library')}</label><select id="library-source">${librarySelectOptions(sources,sourceId,tr('选择来源','Choose source'))}</select></div><div class="actions"><button id="library-import">${tr('从来源选入照片','Import from source')}</button><button id="library-run">${tr('随机选入并按绑定生成','Pick once and generate bound plans')}</button><button id="library-source-settings">${tr('来源设置','Source settings')}</button><button id="library-legacy">${tr('历史本地成品','Legacy local images')}</button></div><p class="hint">${tr('源画面先保存到本地;同一张照片按不同方案生成版本,多个终端可共用一份版本。Photos 画面可能是源预览。','Source pixels are stored locally first. Plans create versions of the same photo; terminals may share one version. Photos may supply source previews.')}</p><div class="tabs gallery-tabs library-plan-tabs"><button data-library-plan="" class="${!plan?'active':''}">${tr('源画面','Source pixels')}</button>${plans.map(p=>`<button data-library-plan="${esc(p.id)}" class="${p.id===plan?.id?'active':''}">${esc(p.name)}${p.archived?tr('(已归档)',' (archived)'):''}</button>`).join('')}</div><div class="actions"><button id="library-new-plan">${tr('新增生成方案','New generation plan')}</button>${plan&&!plan.archived?`<button id="library-edit-plan">${t('edit')}</button><button id="library-delete-plan" class="danger">${tr('删除方案','Delete plan')}</button>`:''}</div><p class="hint">${tr('使用终端:','Used by: ')}${uses.map(terminalLabel).map(esc).join('、')||tr('尚未绑定','No terminals bound')}</p></section><div class="actions"><button id="library-prev" ${libraryUI.offset===0?'disabled':''}>${tr('上一页','Previous')}</button><button id="library-next" ${libraryUI.offset+30>=page.total?'disabled':''}>${tr('下一页','Next')}</button><span>${tr('共','Total')} ${page.total} ${tr('张来源照片','source pictures')}</span></div><div class="gallery">${page.items.map((picture,i)=>{
|
||||
const version=plan?picture.versions.find(v=>v.plan_id===plan.id):picture;
|
||||
return `<article class="card image-card">${version?`<button class="image-preview" data-library-preview="${i}"><img class="thumb" alt="${esc(picture.name)}" data-src="${esc(version.url)}" style="object-fit:contain;${plan?`aspect-ratio:${version.plan.width}/${version.plan.height}`:''}"></button>`:`<div class="empty">${tr('此方案尚未生成','Not generated for this plan')}</div>`}<h3>${esc(picture.name)}</h3><small>${plan?version?`${esc(version.plan.name)} · ${version.plan.width} × ${version.plan.height}`:tr('未生成','Not generated'):tr('无叠加的源画面','Source pixels without overlays')}</small><small>${esc(fmt(version?.created_at||picture.created_at))}</small>${version?.terminal_uses?.length?`<small>${version.terminal_uses.map(u=>esc(terminalLabel(terminals.find(t=>t.id===u.terminal_id)||{id:u.terminal_id}))+' · '+esc(libraryStatus(u.status))).join(' / ')}</small>`:''}<div class="actions">${version?`<button data-library-preview="${i}">${tr('预览','Preview')}</button><button data-library-download="${i}">${tr('下载图片','Download image')}</button>${plan?`<button data-library-settings="${i}">${tr('生成参数','Generation settings')}</button>`:''}`:''}${!plan?.archived?`<button data-library-generate="${i}">${plan?tr('按此方案生成','Generate this plan'):tr('生成各方案版本','Generate plan versions')}</button>`:''}${!plan?`<button data-library-star="${i}" ${picture.excluded?'disabled':''} title="${picture.excluded?esc(t('clearExclusionFirst')):''}">${t(picture.starred?'unstar':'star')}</button><button data-library-exclude="${i}" ${picture.starred?'disabled':''} title="${picture.starred?esc(t('clearStarFirst')):''}">${t(picture.excluded?'includePhoto':'excludePhoto')}</button>`:''}<button class="danger" data-library-delete="${i}" ${plan&&!version?'disabled':''}>${t('delete')}</button></div></article>`;
|
||||
}).join('')}</div>${page.items.length?'':`<section class="card empty">${tr('尚未选入源画面。先从来源选入照片,再按方案生成。旧成品保留在“历史本地成品”。','No source pixels imported yet. Import a photo, then generate its versions. Older products remain under Legacy local images.')}</section>`}`;
|
||||
$('#library-source').onchange=()=>{libraryUI.source=$('#library-source').value;libraryUI.plan=null;libraryUI.offset=0;return navigate();};
|
||||
document.querySelectorAll('[data-library-plan]').forEach(b=>b.onclick=()=>{libraryUI.plan=b.dataset.libraryPlan||null;return navigate();});
|
||||
bind('library-import',()=>libraryImportDialog(sourceId));bind('library-source-settings',()=>navigate('photo-sources'));bind('library-legacy',()=>libraryLegacy('local'));
|
||||
bind('library-new-plan',()=>generationPlanEditor(sourceId,null,page.items));bind('library-edit-plan',()=>generationPlanEditor(sourceId,plan,page.items));
|
||||
bind('library-delete-plan',()=>confirmDialog(tr('删除此方案?已有生成版本保留参数快照;正在被终端使用的方案不能删除。','Delete this plan? Existing versions keep their snapshots. Bound plans cannot be deleted.'),async()=>{await api('/api/v1/generation-plans/'+plan.id,{method:'DELETE'});await navigate();}));
|
||||
bind('library-run',async()=>{const result=await api('/api/v1/source-libraries/'+encodeURIComponent(sourceId)+'/run',{method:'POST',body:{deliver:false}});toast(result.errors.length?result.errors.map(e=>e.message).join('; '):tr('已从同一来源画面生成各终端版本,尚未推送','Generated terminal versions from one source picture; not sent'));libraryUI.offset=0;await navigate();});
|
||||
bind('library-prev',()=>{libraryUI.offset=Math.max(0,libraryUI.offset-30);return navigate();});bind('library-next',()=>{libraryUI.offset+=30;return navigate();});
|
||||
const selected=i=>{const p=page.items[i];return plan?p.versions.find(v=>v.plan_id===plan.id):p;};
|
||||
document.querySelectorAll('[data-library-preview]').forEach(b=>b.onclick=busy(()=>{const v=selected(b.dataset.libraryPreview);return openImageViewer(v.url,v.name);}));
|
||||
document.querySelectorAll('[data-library-download]').forEach(b=>b.onclick=busy(()=>{const v=selected(b.dataset.libraryDownload);return downloadImage(v.url,v.name);}));
|
||||
document.querySelectorAll('[data-library-settings]').forEach(b=>b.onclick=()=>generationVersionDetails(selected(b.dataset.librarySettings)));
|
||||
document.querySelectorAll('[data-library-generate]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/source-images/'+page.items[b.dataset.libraryGenerate].id+'/generate',{method:'POST',body:{plan_id:plan?.id||null}});await navigate();}));
|
||||
document.querySelectorAll('[data-library-star]').forEach(b=>b.onclick=busy(async()=>{const p=page.items[b.dataset.libraryStar];await api('/api/v1/source-images/'+p.id+'/star',{method:'PUT',body:{starred:!p.starred}});await navigate();}));
|
||||
document.querySelectorAll('[data-library-exclude]').forEach(b=>b.onclick=busy(async()=>{const p=page.items[b.dataset.libraryExclude];await api('/api/v1/source-images/'+p.id+'/exclusion',{method:'PUT',body:{excluded:!p.excluded}});await navigate();}));
|
||||
document.querySelectorAll('[data-library-delete]').forEach(b=>b.onclick=()=>{const p=page.items[b.dataset.libraryDelete],v=selected(b.dataset.libraryDelete);if(plan&&!v)return;return confirmDialog(plan?tr('删除这个本地生成版本?不会删除源画面或设备上已接收的图片。','Delete this local version? Source pixels and device copies are kept.'):tr('删除本地源画面及其所有生成版本?不会删除照片数据源中的照片或设备副本。','Delete this local source picture and all generated versions? Provider photos and device copies are kept.'),async()=>{await api(plan?'/api/v1/library-versions/'+v.asset_id:'/api/v1/source-images/'+p.id,{method:'DELETE'});await navigate();});});
|
||||
hydrateImages();
|
||||
}
|
||||
|
||||
async function generationPlanEditor(sourceId,plan,pictures){
|
||||
const oldPlans=plan?[]:await api('/api/v1/thumbnail-plans');
|
||||
const p=plan||{name:'',width:410,height:502,fit:'cover',quality:85,center_x:0.5,center_y:0.5,caption:false,profile:'color'};
|
||||
showModal(`<h2>${tr('来源图库生成方案','Source-library generation plan')}</h2><form id="generation-form">${!plan?`<div class="actions"><button type="button" data-generation-preset="frame-portrait">${tr('相框竖版','Portrait frame')}</button><button type="button" data-generation-preset="frame-landscape">${tr('相框横版','Landscape frame')}</button><button type="button" data-generation-preset="watch">${tr('手表','Watch')}</button></div>${oldPlans.length?`<div class="field"><label for="generation-legacy">${tr('从旧版小图方案复制参数(可选)','Copy legacy thumbnail settings (optional)')}</label><select id="generation-legacy">${librarySelectOptions(oldPlans,null,tr('不复制','Do not copy'))}</select></div>`:''}`:''}<div class="field"><label for="generation-name">${t('name')}</label><input id="generation-name" required maxlength="120" value="${esc(p.name)}"></div><div class="form-grid">${[['width',tr('宽度','Width'),16,2048,1],['height',tr('高度','Height'),16,2048,1],['quality',tr('JPEG 质量','JPEG quality'),40,95,1],['center_x',tr('水平裁切位置(0–1)','Crop X (0–1)'),0,1,0.01],['center_y',tr('垂直裁切位置(0–1)','Crop Y (0–1)'),0,1,0.01]].map(([key,label,min,max,step])=>`<div class="field"><label for="generation-${key}">${label}</label><input id="generation-${key}" type="number" required min="${min}" max="${max}" step="${step}" value="${p[key]}"></div>`).join('')}<div class="field"><label for="generation-fit">${tr('适配方式','Fit')}</label><select id="generation-fit"><option value="cover">${tr('等比裁切铺满','Proportional fill')}</option><option value="contain">${tr('完整画面,必要时补白','Full picture, white padding')}</option></select></div><div class="field"><label for="generation-profile">${t('profile')}</label><select id="generation-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select></div></div><label class="check-row"><input id="generation-caption" type="checkbox" ${p.caption?'checked':''}>${tr('叠加来源提供的日期与说明','Add date and caption supplied by the source')}</label><p class="hint">${tr('默认不拉伸、不补黑边。相框常用 1200×1600 或 1600×1200,当前手表为 410×502。绑定终端时校验尺寸。修改方案只影响后续生成,旧版本保留参数快照。','No stretching or black padding by default. Frames use 1200×1600 or 1600×1200; the current watch uses 410×502. Binding checks screen dimensions. Existing versions keep parameter snapshots.')}</p><div class="field"><label for="generation-picture">${tr('用本地源画面预览','Preview with a local source picture')}</label><select id="generation-picture">${librarySelectOptions(pictures.map(x=>({id:x.id,name:x.name})),pictures[0]?.id,tr('选择照片','Choose picture'))}</select></div><div id="generation-preview-box"></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="button" id="generation-preview">${tr('预览','Preview')}</button><button type="submit" class="primary">${t('save')}</button></div></form>`);
|
||||
$('#generation-fit').value=p.fit;$('#generation-profile').value=p.profile;
|
||||
const fill=values=>{for(const key of ['name','width','height','quality','center_x','center_y','fit'])$('#generation-'+key).value=values[key];$('#generation-caption').checked=values.caption||false;$('#generation-profile').value=values.profile||'color';};
|
||||
document.querySelectorAll('[data-generation-preset]').forEach(b=>b.onclick=()=>{const type=b.dataset.generationPreset;fill({...p,name:type==='watch'?tr('手表','Watch'):type==='frame-portrait'?tr('相框竖版','Portrait frame'):tr('相框横版','Landscape frame'),width:type==='watch'?410:type==='frame-portrait'?1200:1600,height:type==='watch'?502:type==='frame-portrait'?1600:1200,caption:type!=='watch'});});
|
||||
if($('#generation-legacy'))$('#generation-legacy').onchange=()=>{const old=oldPlans.find(p=>p.id===$('#generation-legacy').value);if(old)fill(old);};
|
||||
const read=()=>({source_id:sourceId,name:$('#generation-name').value,width:Number($('#generation-width').value),height:Number($('#generation-height').value),quality:Number($('#generation-quality').value),center_x:Number($('#generation-center_x').value),center_y:Number($('#generation-center_y').value),fit:$('#generation-fit').value,profile:$('#generation-profile').value,caption:$('#generation-caption').checked});
|
||||
bind('generation-preview',async()=>{if(!$('#generation-form').reportValidity())return;const id=$('#generation-picture').value;if(!id)throw new Error(tr('请先选入源画面','Import a source picture first'));const blob=await api('/api/v1/source-images/'+id+'/preview',{method:'POST',body:read(),binary:true});if(!$('#generation-preview-box'))return;const url=URL.createObjectURL(blob);state.blobs.push(url);$('#generation-preview-box').innerHTML=`<img alt="${tr('生成预览','Generation preview')}" style="max-width:100%;max-height:360px" src="${url}">`;});
|
||||
$('#generation-form').onsubmit=busy(async e=>{e.preventDefault();const saved=await api('/api/v1/generation-plans'+(plan?'/'+plan.id:''),{method:plan?'PUT':'POST',body:read()});libraryUI.plan=saved.id;$('#modal').close();await navigate();});
|
||||
}
|
||||
|
||||
async function libraryImportDialog(sourceId){
|
||||
showModal(`<h2>${tr('从照片来源选入','Import from photo source')}</h2><p class="hint">${tr('保存无叠加的源画面,不会修改来源照片。','Stores undecorated source pixels without modifying provider photos.')}</p><div class="field"><label for="library-import-item">${tr('选择照片','Choose picture')}</label><select id="library-import-item"></select></div><div class="actions"><button id="library-import-prev">${tr('上一页','Previous')}</button><button id="library-import-next">${tr('下一页','Next')}</button></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button id="library-import-confirm" class="primary">${tr('选入本地','Import locally')}</button></div>`);
|
||||
let offset=0;
|
||||
async function load(){const page=await api('/api/v1/photo-sources/'+encodeURIComponent(sourceId)+'/images?limit=50&offset='+offset);if(!$('#library-import-item'))return;$('#library-import-item').innerHTML=librarySelectOptions(page.items,page.items[0]?.id,tr('请选择','Choose'));$('#library-import-prev').disabled=offset===0;$('#library-import-next').disabled=offset+50>=page.total;}
|
||||
bind('library-import-prev',async()=>{offset=Math.max(0,offset-50);await load();});bind('library-import-next',async()=>{offset+=50;await load();});
|
||||
bind('library-import-confirm',async()=>{const item=$('#library-import-item').value;if(!item)return;await api('/api/v1/source-libraries/'+encodeURIComponent(sourceId)+'/import',{method:'POST',body:{item_id:item}});$('#modal').close();libraryUI.offset=0;await navigate();});await load();
|
||||
}
|
||||
|
||||
async function terminalLibraryPage(epoch,sources,terminals){
|
||||
if(!terminals.some(t=>t.id===libraryUI.terminal))libraryUI.terminal=state.tab==='watch'?'watch':state.device;
|
||||
const terminal=terminals.find(t=>t.id===libraryUI.terminal)||terminals[0];libraryUI.terminal=terminal.id;state.tab=terminal.id==='watch'?'watch':'cloud';if(terminal.id!=='watch')state.device=terminal.id;shell();
|
||||
const [page,plans]=await Promise.all([api('/api/v1/library-terminals/'+encodeURIComponent(terminal.id)+'/images?limit=30&offset='+libraryUI.offset),terminal.binding?api('/api/v1/generation-plans?source_id='+encodeURIComponent(terminal.binding.source_id)):Promise.resolve([])]);
|
||||
if(epoch!==state.epoch)return;
|
||||
const source=sources.find(s=>s.id===terminal.binding?.source_id),plan=plans.find(p=>p.id===terminal.binding?.plan_id);
|
||||
$('#library-content').innerHTML=`<section class="card"><div class="field"><label for="library-terminal">${tr('终端','Terminal')}</label><select id="library-terminal">${terminals.map(t=>`<option value="${esc(t.id)}" ${t.id===terminal.id?'selected':''}>${esc(terminalLabel(t))}</option>`).join('')}</select></div><h2>${esc(terminalLabel(terminal))}</h2><p>${tr('来源图库:','Source library: ')}${esc(source?.name||tr('未绑定','Not bound'))} · ${tr('生成方案:','Generation plan: ')}${esc(plan?.name||tr('未选择','Not selected'))}</p><p class="hint">${terminal.transport_supported?tr('生成、上传和设备确认分别记录;已生成不等于已显示。','Generation, upload and device confirmation are tracked separately. Generated does not mean displayed.'):tr('当前手表屏幕 410×502,传输尚未接入;这里管理生成成品。','Current watch screen: 410×502. Transfer is not connected; this library manages generated versions.')}</p><div class="actions"><button id="library-binding" class="primary">${tr('设置来源与方案(各单选)','Bind one source and one plan')}</button>${terminal.binding?`<button id="library-unbind">${tr('解除绑定','Unbind')}</button><button id="library-to-source">${tr('查看来源与生成版本','View source and versions')}</button>`:''}<button id="library-old-terminal">${terminal.kind==='watch'?tr('旧版小图图库','Legacy watch gallery'):tr('设备已有云端图片','Existing device cloud images')}</button></div></section><div class="actions"><button id="terminal-prev" ${libraryUI.offset===0?'disabled':''}>${tr('上一页','Previous')}</button><button id="terminal-next" ${libraryUI.offset+30>=page.total?'disabled':''}>${tr('下一页','Next')}</button><span>${tr('共','Total')} ${page.total} ${tr('个成品版本','generated versions')}</span></div><div class="gallery">${page.items.map((v,i)=>`<article class="card image-card"><button class="image-preview" data-terminal-preview="${i}"><img class="thumb" alt="${esc(v.name)}" data-src="${esc(v.url)}" style="object-fit:contain;aspect-ratio:${v.plan.width}/${v.plan.height}"></button><h3>${esc(v.name)}</h3><small>${v.plan.width} × ${v.plan.height} · ${esc(v.plan.name)}</small><small>${esc(libraryStatus(v.delivery_status))} · ${esc(fmt(v.created_at))}</small><div class="actions"><button data-terminal-preview="${i}">${tr('预览','Preview')}</button><button data-terminal-download="${i}">${tr('下载图片','Download image')}</button><button data-terminal-settings="${i}">${tr('生成参数','Generation settings')}</button>${v.operation?`<button data-terminal-operation="${i}">${tr('推送记录','Delivery history')}</button>`:''}<button data-terminal-send="${i}" ${!terminal.transport_supported||!liveWriteAllowed()||['QUEUED','RENDERING','UPLOADING','SYNCING','UNKNOWN'].includes(v.delivery_status)?'disabled':''} title="${terminal.transport_supported?(liveWriteAllowed()?'':esc(t('readOnlyWarning'))):tr('手表传输尚未接入','Watch transfer is not connected')}">${tr('推送到终端','Send to terminal')}</button></div></article>`).join('')}</div>${page.items.length?'':`<section class="card empty">${tr('尚无对应成品。绑定来源与方案后,在来源图库选入照片并生成。','No matching versions. Bind a source and plan, then import and generate in the source library.')}</section>`}`;
|
||||
$('#library-terminal').onchange=()=>{libraryUI.terminal=$('#library-terminal').value;libraryUI.offset=0;state.tab=libraryUI.terminal==='watch'?'watch':'cloud';if(libraryUI.terminal!=='watch')state.device=libraryUI.terminal;return navigate();};
|
||||
bind('library-binding',()=>terminalBindingEditor(terminal,sources));bind('library-unbind',()=>confirmDialog(tr('解除终端绑定?已有图片保留,后续来源任务不再向此终端输出。','Unbind this terminal? Images are kept; future source tasks will not output to it.'),async()=>{await api('/api/v1/library-terminals/'+encodeURIComponent(terminal.id)+'/binding',{method:'DELETE'});await navigate();}));
|
||||
bind('library-to-source',()=>{libraryUI.source=terminal.binding.source_id;libraryUI.plan=terminal.binding.plan_id;libraryUI.kind='source';libraryUI.offset=0;state.tab='local';return navigate();});
|
||||
bind('library-old-terminal',()=>{if(terminal.id!=='watch')state.device=terminal.id;return libraryLegacy(terminal.id==='watch'?'watch':'cloud');});
|
||||
bind('terminal-prev',()=>{libraryUI.offset=Math.max(0,libraryUI.offset-30);return navigate();});bind('terminal-next',()=>{libraryUI.offset+=30;return navigate();});
|
||||
document.querySelectorAll('[data-terminal-preview]').forEach(b=>b.onclick=busy(()=>{const v=page.items[b.dataset.terminalPreview];return openImageViewer(v.url,v.name);}));
|
||||
document.querySelectorAll('[data-terminal-download]').forEach(b=>b.onclick=busy(()=>{const v=page.items[b.dataset.terminalDownload];return downloadImage(v.url,v.name);}));
|
||||
document.querySelectorAll('[data-terminal-settings]').forEach(b=>b.onclick=()=>generationVersionDetails(page.items[b.dataset.terminalSettings]));
|
||||
document.querySelectorAll('[data-terminal-operation]').forEach(b=>b.onclick=()=>{state.historyFilters={q:page.items[b.dataset.terminalOperation].operation.operation_id};state.historyPage=1;return navigate('history');});
|
||||
document.querySelectorAll('[data-terminal-send]').forEach(b=>b.onclick=()=>{const v=page.items[b.dataset.terminalSend];return confirmDialog(tr('将此版本推送到终端并显示?','Send and display this version on the terminal?'),async()=>{await api('/api/v1/library-terminals/'+encodeURIComponent(terminal.id)+'/images/'+v.asset_id+'/send',{method:'POST'});toast(t('accepted'));await navigate();});});hydrateImages();
|
||||
}
|
||||
|
||||
async function terminalBindingEditor(terminal,sources){
|
||||
showModal(`<h2>${esc(terminalLabel(terminal))} · ${tr('来源与生成方案','Source and generation plan')}</h2><form id="terminal-binding-form"><div class="field"><label for="binding-source">${tr('来源图库(单选)','Source library (one)')}</label><select id="binding-source" required>${librarySelectOptions(sources,terminal.binding?.source_id,tr('请选择来源','Choose source'))}</select></div><div class="field"><label for="binding-plan">${tr('该来源内的生成方案(单选)','Generation plan in this source (one)')}</label><select id="binding-plan" required></select></div><p class="hint">${tr('切换来源必须重新选择方案。方案可被多个终端共用,但尺寸必须匹配各终端屏幕。请先在来源图库中创建方案。','Changing source clears the plan selection. Terminals may share a plan when screen dimensions match. Create plans in the source library first.')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}<button class="primary" type="submit">${t('save')}</button></div></form>`);
|
||||
let request=0;
|
||||
async function load(selected){const source=$('#binding-source').value,ticket=++request;$('#binding-plan').innerHTML='<option value="">—</option>';if(!source)return;const plans=await api('/api/v1/generation-plans?source_id='+encodeURIComponent(source));if(ticket!==request||!$('#binding-plan'))return;$('#binding-plan').innerHTML=librarySelectOptions(plans.filter(p=>!p.archived),selected,tr('请选择生成方案','Choose generation plan'));}
|
||||
$('#binding-source').onchange=busy(()=>load(null));$('#terminal-binding-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/library-terminals/'+encodeURIComponent(terminal.id)+'/binding',{method:'PUT',body:{source_id:$('#binding-source').value,plan_id:$('#binding-plan').value}});$('#modal').close();await navigate();});await load(terminal.binding?.plan_id);
|
||||
}
|
||||
|
||||
$('#device').onchange=()=>{const id=$('#device').value;if(id==='@watch'||state.page==='gallery'){libraryUI.kind='terminal';libraryUI.terminal=id==='@watch'?'watch':id;libraryUI.offset=0;libraryUI.legacy=false;state.tab=id==='@watch'?'watch':'cloud';if(id!=='@watch')state.device=id;return navigate('gallery');}state.device=id;return navigate();};
|
||||
|
||||
async function libraryJobFields(job,isNew){
|
||||
const box=document.createElement('section');box.innerHTML=`<label class="check-row"><input id="job-library-pipeline" type="checkbox" ${job.library_pipeline||(isNew&&job.source?.type==='photo_source')?'checked':''}>${tr('按来源图库统一生成并推送','Generate and send through source-library bindings')}</label><p class="hint">${tr('选一次照片并保存源画面,按来源内的终端绑定分别生成;同一方案共用成品。相框继续走上传和显示确认,手表仅生成。此模式不再单独选择“小图方案”。旧任务默认保留原模式,勾选后切换。','Select one photo, save source pixels and generate each bound plan. Terminals sharing a plan share its version. Frames upload and confirm display; watches generate only. Legacy tasks keep their old mode until enabled.')}</p>`;
|
||||
$('#job-form .modal-actions').before(box);
|
||||
const kind=$('#job-content-type'),pipeline=$('#job-library-pipeline');
|
||||
const update=()=>{const supported=kind.value==='photo_source';box.hidden=!supported;$('#legacy-thumbnail-job-fields').hidden=!supported||pipeline.checked;pipeline.disabled=!supported;if(!supported)pipeline.checked=false;if(pipeline.checked){$('#job-thumbnail').checked=false;$('#job-thumbnail').disabled=true;$('#job-thumbnail-plan').required=false;$('#job-thumbnail-plan').disabled=true;}else{ $('#job-thumbnail').disabled=!supported;}};
|
||||
kind.addEventListener('change',()=>{if(isNew&&kind.value==='photo_source')pipeline.checked=true;update();});pipeline.onchange=update;update();
|
||||
}
|
||||
|
||||
if(state.settings&&state.page==='gallery')void navigate('gallery');else shell();
|
||||
|
||||
function generationVersionDetails(version){
|
||||
const p=version.plan;
|
||||
showModal(`<h2>${esc(p.name)}</h2><div class="detail-grid">${detail(tr('输出尺寸','Output size'),p.width+' × '+p.height)}${detail(tr('JPEG 质量','JPEG quality'),p.quality)}${detail(tr('适配方式','Fit'),p.fit==='cover'?tr('等比铺满','Proportional fill'):tr('完整画面','Full picture'))}${detail(tr('裁切位置','Crop position'),p.center_x+' / '+p.center_y)}${detail(tr('叠加说明','Caption'),p.caption?tr('开启','Enabled'):tr('关闭','Disabled'))}${detail(tr('生成时间','Generated at'),fmt(version.created_at))}</div><p class="hint">${tr('这是生成此版本时的参数快照。修改方案不会改变已有图片。','This snapshot records the settings used for this version. Editing a plan does not change existing images.')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ views.thumbnails=async function(epoch){
|
||||
|
||||
async function thumbnailJobFields(job){
|
||||
const plans=await api('/api/v1/thumbnail-plans');if(!$('#job-form'))return;
|
||||
const box=document.createElement('section');box.innerHTML=`<label class="check-row"><input type="checkbox" id="job-thumbnail" ${job.generate_thumbnail?'checked':''}>${tr('生成小图','Generate thumbnail')}</label><div class="field"><label for="job-thumbnail-plan">${tr('小图生成方案(单选)','Thumbnail plan (choose one)')}</label><select id="job-thumbnail-plan"><option value="">${tr('请选择已保存方案','Choose a saved plan')}</option>${plans.map(p=>`<option value="${esc(p.id)}" ${job.thumbnail_plan_id===p.id?'selected':''}>${esc(p.name)} · ${p.width}×${p.height}</option>`).join('')}</select></div><p class="hint">${tr('仅支持照片数据源任务;上传成功后使用源画面生成。请先在“小图方案”中保存方案。','For photo-source tasks only; generates from source pixels after upload succeeds. Save a plan in Thumbnail plans first.')}</p>`;
|
||||
const box=document.createElement('section');box.id='legacy-thumbnail-job-fields';box.innerHTML=`<label class="check-row"><input type="checkbox" id="job-thumbnail" ${job.generate_thumbnail?'checked':''}>${tr('生成小图','Generate thumbnail')}</label><div class="field"><label for="job-thumbnail-plan">${tr('小图生成方案(单选)','Thumbnail plan (choose one)')}</label><select id="job-thumbnail-plan"><option value="">${tr('请选择已保存方案','Choose a saved plan')}</option>${plans.map(p=>`<option value="${esc(p.id)}" ${job.thumbnail_plan_id===p.id?'selected':''}>${esc(p.name)} · ${p.width}×${p.height}</option>`).join('')}</select></div><p class="hint">${tr('仅支持照片数据源任务;上传成功后使用源画面生成。请先在“小图方案”中保存方案。','For photo-source tasks only; generates from source pixels after upload succeeds. Save a plan in Thumbnail plans first.')}</p>`;
|
||||
$('#job-form .modal-actions').before(box);
|
||||
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();
|
||||
@@ -39,7 +39,7 @@ async function watchGalleryPage(epoch,destinations){
|
||||
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-refresh',()=>navigate());bind('watch-plans',()=>navigate('legacy-plans'));
|
||||
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);}));
|
||||
|
||||
Reference in New Issue
Block a user