Add output generation, cover image management, and back-translation tool

- Add cover image CRUD endpoints: GET/POST/DELETE /api/projects/{id}/cover
- Add output generation endpoints: POST /api/projects/{id}/output/{format}
- Add cover image and output queue service methods
- Add CoverImageResponse and CoverImageUploadResponse schemas
- Add umbra-api.js request handling for FormData (cover uploads)
- Add output generation buttons (EPUB/PDF/Audio) to Format UI in umbra-app.js
- Add backtranslate.py tool for verifying translation quality via back-translation
- Add cover image support to render.py EPUB generation
- Copy cover image to deal-hunter-zh source directory
- Spanish translation pipeline started (running in background)
This commit is contained in:
umbra audio
2026-10-25 15:27:02 -07:00
parent be04e19d04
commit fbbe23d7f9
7 changed files with 712 additions and 4 deletions

View File

@@ -0,0 +1,193 @@
# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""Back-translation checker for verifying Chinese translation intent.
Uses a *different* LLM model than the drafter to translate Chinese back to
English, then compares against the original English to verify that meaning,
tone, and intent are preserved. This is an independent quality gate -- if the
back-translation captures the same story beats, the Chinese is faithful.
"""
from __future__ import annotations
import json
import argparse
import json
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from llm import DEFAULT_MODEL, LLM, LLMError, Usage # noqa: E402
# A model *different* from the drafter (qwen/qwen3.5-9b) for independent
# verification. gemma-4-12b-qat is a good choice: capable but architecturally
# distinct from Qwen.
BACKTRANSLATE_MODEL = "google/gemma-4-12b-qat"
SYSTEM_BACKTRANSLATE = """你是一位严格的双语审校。你的任务是把中文译文译回英文,然后与英文原文对比,判断原意是否被保留。
只输出 JSON,不要解释,不要 Markdown 代码围栏。输出结构:
{
"back_translation": "完整的英文回译",
"intent_preserved": true/false,
"confidence": 0.0-1.0,
"issues": [
{"id": "段落id", "problem": "具体问题描述", "severity": "block|major|minor"}
],
"notes": "整体评估(可选)
}
只有当读者会因此理解错、人物声音被抹平、或事实被改变时才报告问题。不要因为“措辞不同”而报错。
"""
def backtrans_prompt(
*,
chapter_title: str,
paragraphs: list[dict],
drafts: dict[str, str],
) -> str:
pairs = []
for p in paragraphs:
if p.get("type") == "scene_break":
continue
pairs.append(
{
"id": p["id"],
"speaker": p.get("speaker"),
"type": p.get("type"),
"en": p["text"],
"zh": drafts.get(p["id"], ""),
}
)
pairs_json = json.dumps(pairs, ensure_ascii=False, indent=2)
return f"""书名:Deal Hunter
本章:{chapter_title}
请把下面每段中文译文译回英文,并与英文原文对比,判断原意是否保留。
输出 JSON:{{"back_translation": "...", "intent_preserved": true/false, "confidence": 0.0-1.0, "issues": [...], "notes": "..."}}
英文原文与中文译文如下:
{pairs_json}"""
def check_chapter(
llm: LLM,
chapter: dict,
drafts: dict[str, str],
sample_fraction: float = 0.3,
) -> dict:
"""Back-translate a sample of paragraphs from one chapter."""
paras = [p for p in chapter["paragraphs"] if p.get("type") != "scene_break"]
if not paras:
return {"chapter_id": chapter["id"], "error": "no paragraphs"}
# Sample a subset for back-translation (full book back-translation is expensive)
import random
random.seed(42) # deterministic sampling
n_sample = max(1, int(len(paras) * sample_fraction))
sample = random.sample(paras, min(n_sample, len(paras)))
prompt = backtrans_prompt(
chapter_title=f"{chapter['title']} / {chapter['title_zh']}",
paragraphs=sample,
drafts=drafts,
)
try:
value, usage = llm.chat(
SYSTEM_BACKTRANSLATE,
prompt,
model=BACKTRANSLATE_MODEL,
temperature=0.1,
max_tokens=8000,
cache_key=f"{chapter['id']}.backtranslate.{BACKTRANSLATE_MODEL}",
)
except LLMError as exc:
return {
"chapter_id": chapter["id"],
"chapter_title": chapter["title"],
"error": str(exc),
"sampled": len(sample),
"total": len(paras),
}
try:
result = json.loads(value)
except json.JSONDecodeError:
# Try to extract JSON from markdown fence or surrounding prose
text = value.strip()
if text.startswith("```"):
text = re.sub(r"^```[a-zA-Z0-9_-]*\s*", "", text)
text = re.sub(r"\s*```$", "", text).strip()
try:
result = json.loads(text)
except json.JSONDecodeError:
return {
"chapter_id": chapter["id"],
"chapter_title": chapter["title"],
"error": f"could not parse back-translation response: {value[:200]}",
"sampled": len(sample),
"total": len(paras),
"raw": value,
}
result["chapter_id"] = chapter["id"]
result["chapter_title"] = chapter["title"]
result["sampled"] = len(sample)
result["total"] = len(paras)
result["model"] = BACKTRANSLATE_MODEL
return result
def main() -> None:
ap = argparse.ArgumentParser(description="Back-translate Chinese to English for intent verification")
ap.add_argument("book", help="Path to book.json")
ap.add_argument("run_dir", help="Path to runs directory containing translation.json per chapter")
ap.add_argument("chapters", nargs="*", help="Chapter IDs to check (default: all translated)")
ap.add_argument("--sample", type=float, default=0.3, help="Fraction of paragraphs to back-translate (default 0.3)")
ap.add_argument("--model", default=BACKTRANSLATE_MODEL, help="Back-translation model")
args = ap.parse_args()
from llm import DEFAULT_MODEL, LLM, LLMError, Usage # noqa: E402
book = json.loads(Path(args.book).read_text(encoding="utf-8"))
run_dir = Path(args.run_dir)
llm = LLM(cache_dir=run_dir / "cache", usage_log=run_dir / "usage.jsonl")
wanted = set(args.chapters) if args.chapters else None
chapters = [c for c in book["chapters"] if not wanted or c["id"] in wanted or str(c["index"]) in wanted]
results = []
for ch in chapters:
draft_path = run_dir / ch["id"] / "translation.json"
if not draft_path.exists():
print(f"SKIP {ch['id']}: no translation.json", flush=True)
continue
draft_data = json.loads(draft_path.read_text(encoding="utf-8"))
drafts = draft_data.get("paragraphs", {})
print(f"Checking {ch['id']} ({ch['title']})...", flush=True)
result = check_chapter(llm, ch, drafts, sample_fraction=args.sample)
results.append(result)
print(f" verdict: {result.get('intent_preserved')}, confidence: {result.get('confidence')}, issues: {len(result.get('issues', []))}", flush=True)
# Summary
total_issues = sum(len(r.get("issues", [])) for r in results)
blocked = [r for r in results if any(i.get("severity") == "block" for i in r.get("issues", []))]
print(f"\n=== BACK-TRANSLATION SUMMARY ===")
print(f"Chapters checked: {len(results)}")
print(f"Total issues: {total_issues}")
print(f"Chapters with block-level issues: {len(blocked)}")
for r in blocked:
print(f" BLOCK: {r['chapter_id']} - {r['chapter_title']}")
for i in r.get("issues", []):
if i.get("severity") == "block":
print(f" {i['id']}: {i['problem']}")
if __name__ == "__main__":
main()

View File

@@ -541,9 +541,157 @@ def render(
out = {"asciidoc": asciidoc, "fodt": fodt, "odt": odt}
if pdf_path:
out["pdf"] = pdf_path
# Enhance EPUB with cover image and metadata
epub_path = out_dir / f"{stem}.epub"
cover_path = book_path.parent / "covers" / "cover.png"
enhance_epub(epub_path, book, cover_path if cover_path.exists() else None)
out["epub"] = epub_path
return out
def enhance_epub(epub_path: Path, book: dict, cover_path: Path | None = None) -> None:
"""Add cover image, ISBN, publisher, and translator metadata to EPUB.
Pandoc's ODT->EPUB conversion loses some metadata. This function post-processes
the EPUB to add cover image and complete metadata.
"""
import zipfile
import tempfile
import shutil
from lxml import etree
epub_path = epub_path.resolve()
tmpdir = Path(tempfile.mkdtemp(prefix="epub-enhance-"))
try:
with zipfile.ZipFile(epub_path, "r") as zin:
zin.extractall(tmpdir)
# Find content.opf
opf_path = None
for f in tmpdir.rglob("*.opf"):
if f.name == "content.opf":
opf_path = f
break
if opf_path is None:
return
tree = etree.parse(str(opf_path))
root = tree.getroot()
ns = {
"opf": "http://www.idpf.org/2007/opf",
"dc": "http://purl.org/dc/elements/1.1/",
}
# Add ISBN metadata if present in book
# ISBN is stored in copyright paragraph text; extract it
import re
for ch in book.get("chapters", []):
if ch.get("matter") == "front_copyright":
for p in ch.get("paragraphs", []):
text = p.get("text", "")
isbn_match = re.search(r"ISBN:\s*([\d-]+)", text)
if isbn_match:
isbn = isbn_match.group(1)
# Add ISBN identifier
id_elem = etree.SubElement(root.find("opf:metadata", ns), "{http://purl.org/dc/elements/1.1/}identifier")
id_elem.set("{http://www.idpf.org/2007/opf}scheme", "ISBN")
id_elem.set("id", "epub-isbn")
id_elem.text = isbn
break
# Add cover image if provided
if cover_path and cover_path.exists():
# Add cover image to manifest
cover_href = "images/cover.png"
cover_id = "cover-image"
# Copy cover image to EPUB images dir
images_dir = tmpdir / "EPUB" / "images"
images_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(cover_path, images_dir / "cover.png")
# Add manifest item
manifest = root.find("opf:manifest", ns)
cover_item = etree.SubElement(manifest, "{http://www.idpf.org/2007/opf}item")
cover_item.set("id", cover_id)
cover_item.set("href", cover_href)
cover_item.set("media-type", "image/png")
cover_item.set("properties", "cover-image")
# Add cover meta tag
metadata = root.find("opf:metadata", ns)
cover_meta = etree.SubElement(metadata, "{http://www.idpf.org/2007/opf}meta")
cover_meta.set("name", "cover")
cover_meta.text = cover_id
# Add CJK-optimized CSS
css_path = tmpdir / "EPUB" / "styles" / "stylesheet1.css"
if css_path.exists():
css_content = css_path.read_text(encoding="utf-8")
cjk_css = '''
/* CJK typography for Chinese body text */
html {
font-family: "Noto Serif CJK SC", "Source Han Serif SC", "WenQuanYi Micro Hei", serif;
}
html[lang="zh-CN"], body[lang="zh-CN"] {
font-family: "Noto Serif CJK SC", "Source Han Serif SC", "WenQuanYi Micro Hei", serif;
line-break: strict;
word-break: normal;
}
p[lang="zh-CN"] {
font-family: "Noto Serif CJK SC", "Source Han Serif SC", "WenQuanYi Micro Hei", serif;
line-break: strict;
word-break: normal;
}
/* Dialogue styling */
.utterance__text {
font-size: 1.1em;
line-height: 1.6;
}
/* Chapter headings */
h1, h2 {
page-break-before: always;
page-break-after: avoid;
}
/* Speaker labels */
.utterance__speaker {
font-size: 0.85em;
color: #666;
font-weight: bold;
}
/* Variation dropdowns */
.utterance__variation select {
font-size: 0.8em;
}
'''
css_path.write_text(css_content + chr(10) + cjk_css, encoding="utf-8")
# Add publisher metadata
metadata = root.find("opf:metadata", ns)
publisher = etree.SubElement(metadata, "{http://purl.org/dc/elements/1.1/}publisher")
publisher.text = "Umbra Audio"
# Add translator contributor
translator = etree.SubElement(metadata, "{http://purl.org/dc/elements/1.1/}contributor")
translator.set("{http://www.idpf.org/2007/opf}role", "trl")
translator.text = "Umbra Tools Translation Team"
tree.write(str(opf_path), xml_declaration=True, encoding="UTF-8", standalone=True)
# Repackage EPUB
with zipfile.ZipFile(epub_path, "w", zipfile.ZIP_DEFLATED) as zout:
# mimetype must be first and uncompressed
zout.write(tmpdir / "mimetype", "mimetype", compress_type=zipfile.ZIP_STORED)
for f in tmpdir.rglob("*"):
if f.is_file() and f.name != "mimetype":
arcname = f.relative_to(tmpdir)
zout.write(f, arcname)
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
def _sha256(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:

View File

@@ -25,6 +25,7 @@ from fastapi import APIRouter, Depends, Request
from .app import get_service, require_principal
from .models import Principal
from .schemas import (
CoverImageResponse,
OrganizationCreateResponse,
OrganizationCreateRequest,
ProgramCreateResponse,
@@ -162,3 +163,97 @@ def get_utterance_translations(
UUID(str(project_id)),
language_code,
)
@router.get(
"/projects/{project_id}/cover",
response_model=CoverImageResponse,
)
def get_cover_image(
project_id: str,
request: Request,
language_tag: str | None = None,
principal: Principal = Depends(require_principal),
) -> CoverImageResponse:
"""Get cover image info for a project."""
service: AccountService = get_service(request)
from uuid import UUID
return service.get_cover_image(
principal,
UUID(str(project_id)),
language_tag,
)
@router.post(
"/projects/{project_id}/cover",
response_model=CoverImageResponse,
)
async def set_cover_image(
project_id: str,
request: Request,
language_tag: str | None = None,
principal: Principal = Depends(require_principal),
) -> CoverImageResponse:
"""Save cover image for a project. Uploads image/png data in the request body."""
service: AccountService = get_service(request)
from uuid import UUID
image_data = await request.body()
return service.set_cover_image(
principal,
UUID(str(project_id)),
language_tag,
image_data,
content_type=request.headers.get("content-type", "image/png"),
)
@router.delete(
"/projects/{project_id}/cover",
response_model=CoverImageResponse,
)
def delete_cover_image(
project_id: str,
request: Request,
language_tag: str | None = None,
principal: Principal = Depends(require_principal),
) -> CoverImageResponse:
"""Delete cover image for a project."""
service: AccountService = get_service(request)
from uuid import UUID
return service.delete_cover_image(
principal,
UUID(str(project_id)),
language_tag,
)
@router.post(
"/projects/{project_id}/output/{format}",
response_model=dict,
)
async def queue_output_generation(
project_id: str,
format: str,
request: Request,
principal: Principal = Depends(require_principal),
) -> dict:
"""Queue output generation (EPUB, PDF, or audio) for a language project."""
service: AccountService = get_service(request)
from uuid import UUID
body = await request.json()
language_tag = body.get("language_tag")
return service.queue_output(
principal,
UUID(str(project_id)),
language_tag,
format,
)

View File

@@ -641,5 +641,23 @@ class SpeakerSyncResponse(ResponseModel):
aliases_added: int
# ---------------------------------------------------------------------------
# Cover image (controller integration endpoint)
class CoverImageResponse(ResponseModel):
project_id: UUID
language_tag: str | None = None
has_cover: bool
cover_path: str | None = None
content_type: str | None = None
size_bytes: int | None = None
class CoverImageUploadResponse(ResponseModel):
project_id: UUID
language_tag: str | None = None
saved: bool
cover_path: str
size_bytes: int

View File

@@ -114,6 +114,7 @@ from .tenancy import TenantScope, resolve_target, scope_for
from .project_status import ProjectStatusAdapter
from .schemas import (
AudioQueueResponse,
CoverImageResponse,
FrontMatterResponse,
FormatMetadataResponse,
OrganizationCreateResponse,
@@ -2964,6 +2965,200 @@ class AccountService:
assigned = [view for view in projects if actor.user_id in view.assigned_account_ids]
return WorkspaceSnapshot(organization=organization, projects=projects, assigned_to_me=assigned, total=total)
# ------------------------------------------------------------------
# Cover image (controller integration endpoint)
# ------------------------------------------------------------------
def get_cover_image(
self,
actor: Principal,
project_id: UUID,
language_tag: str | None = None,
) -> CoverImageResponse:
"""Get cover image info for a project (optionally per-language)."""
scope = scope_for(actor)
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
if project is None:
if scope.unrestricted:
raise NotFoundError("project_not_found")
raise ForbiddenError("cross_organization_forbidden")
prod_dir = self._productions_dir_for(project)
if prod_dir is None:
return CoverImageResponse(
project_id=project_id,
language_tag=language_tag,
has_cover=False,
)
cover_dir = prod_dir / "source" / "covers"
cover_path = cover_dir / "cover.png"
if cover_path.is_file():
return CoverImageResponse(
project_id=project_id,
language_tag=language_tag,
has_cover=True,
cover_path=str(cover_path),
content_type="image/png",
size_bytes=cover_path.stat().st_size,
)
return CoverImageResponse(
project_id=project_id,
language_tag=language_tag,
has_cover=False,
)
def set_cover_image(
self,
actor: Principal,
project_id: UUID,
language_tag: str | None,
image_data: bytes,
content_type: str = "image/png",
) -> CoverImageResponse:
"""Save cover image for a project (optionally per-language)."""
scope = scope_for(actor)
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
if project is None:
if scope.unrestricted:
raise NotFoundError("project_not_found")
raise ForbiddenError("cross_organization_forbidden")
prod_dir = self._productions_dir_for(project)
if prod_dir is None:
raise NotFoundError("productions_not_configured")
cover_dir = prod_dir / "source" / "covers"
cover_dir.mkdir(parents=True, exist_ok=True)
cover_path = cover_dir / "cover.png"
cover_path.write_bytes(image_data)
self._audit(
action=ACTION_FORMAT_METADATA_SAVED,
outcome="success",
actor=actor,
organization_id=project.organization_id,
detail={
"project_slug": project.slug,
"language_tag": language_tag,
"action": "cover_image_saved",
},
)
return CoverImageResponse(
project_id=project_id,
language_tag=language_tag,
saved=True,
cover_path=str(cover_path),
content_type=content_type,
size_bytes=cover_path.stat().st_size,
)
def delete_cover_image(
self,
actor: Principal,
project_id: UUID,
language_tag: str | None = None,
) -> CoverImageResponse:
"""Delete cover image for a project."""
scope = scope_for(actor)
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
if project is None:
if scope.unrestricted:
raise NotFoundError("project_not_found")
raise ForbiddenError("cross_organization_forbidden")
prod_dir = self._productions_dir_for(project)
if prod_dir is None:
return CoverImageResponse(
project_id=project_id,
language_tag=language_tag,
has_cover=False,
)
cover_path = prod_dir / "source" / "covers" / "cover.png"
if cover_path.is_file():
cover_path.unlink()
self._audit(
action=ACTION_FORMAT_METADATA_SAVED,
outcome="success",
actor=actor,
organization_id=project.organization_id,
detail={
"project_slug": project.slug,
"language_tag": language_tag,
"action": "cover_image_deleted",
},
)
return CoverImageResponse(
project_id=project_id,
language_tag=language_tag,
has_cover=False,
)
# ------------------------------------------------------------------
# Output generation (controller integration endpoint)
# ------------------------------------------------------------------
def queue_output(
self,
actor: Principal,
project_id: UUID,
language_tag: str | None,
output_format: str,
) -> dict:
"""Queue output generation (EPUB, PDF, or audio) for a language project."""
scope = scope_for(actor)
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
if project is None:
if scope.unrestricted:
raise NotFoundError("project_not_found")
raise ForbiddenError("cross_organization_forbidden")
prod_dir = self._productions_dir_for(project)
if prod_dir is None:
raise NotFoundError("productions_not_configured")
# Queue the output generation job
key = self._job_key(project_id, language_tag or "en", f"output_{output_format}")
job_id = str(uuid4())
_JOB_STORE[key] = {
"job_id": job_id,
"project_id": str(project_id),
"language_tag": language_tag,
"type": f"output_{output_format}",
"state": "queued",
"created_at": datetime.now(timezone.utc).isoformat(),
}
self._audit(
action=ACTION_FORMAT_METADATA_SAVED,
outcome="success",
actor=actor,
organization_id=project.organization_id,
detail={
"project_slug": project.slug,
"language_tag": language_tag,
"output_format": output_format,
"job_id": job_id,
},
)
LOGGER.info(
"output queued project_id=%s language_tag=%s format=%s job_id=%s",
project_id, language_tag, output_format, job_id,
)
return {
"job_id": job_id,
"project_id": str(project_id),
"language_tag": language_tag,
"format": output_format,
"state": "queued",
}
# ------------------------------------------------------------------
# Controller integration: organizations and programs
# ------------------------------------------------------------------

View File

@@ -209,8 +209,13 @@
}
if (opts.body !== undefined && opts.body !== null) {
init.headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(opts.body);
if (opts.body instanceof FormData) {
init.body = opts.body;
// Let the browser set Content-Type with boundary for FormData
} else {
init.headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(opts.body);
}
}
return global.fetch(path + buildQuery(opts.params), init).then(

View File

@@ -1110,6 +1110,7 @@
var program = state.programId ? (state.programById[String(state.programId)] || null) : null;
var langLabel = state.languageCode ? languageTagLabel(state.languageCode) : '';
var isSource = state.languageCode === 'en';
main.appendChild(el('div', { class: 'umbra-heading umbra-heading--row' }, [
el('div', {}, [
@@ -1125,8 +1126,25 @@
main.appendChild(el('div', { class: 'form-grid' }, [
el('div', { class: 'form-grid__full' }, [
el('label', { text: 'Cover' }),
el('input', { class: 'input', type: 'text', placeholder: 'Cover image' })
el('label', { text: 'Cover image' }),
el('div', { class: 'cover-upload' }, [
el('input', { class: 'input', type: 'file', accept: 'image/*', placeholder: 'Cover image' }),
el('button', { class: 'btn btn--ghost', type: 'button', text: 'Upload', on: { click: function () {
var fileInput = main.querySelector('input[type="file"]');
if (fileInput && fileInput.files && fileInput.files[0]) {
var file = fileInput.files[0];
var formData = new FormData();
formData.append('file', file);
api.request('/api/workspace/' + encodeURIComponent(state.programId) + '/cover', {
method: 'POST',
body: formData
}).then(
function () { api.showToast('Cover image uploaded', 'success'); },
function (err) { api.showToast(api.describeError(err), 'error'); }
);
}
} })
])
]),
el('div', { class: 'form-grid__full' }, [
el('label', { text: 'ISBN' }),
@@ -1172,6 +1190,42 @@
});
}
// Output generation section
var outputPanel = el('div', { class: 'output-panel' });
outputPanel.appendChild(el('h3', { text: 'Generate outputs' }));
outputPanel.appendChild(el('div', { class: 'output-buttons' }, [
el('button', { class: 'btn', type: 'button', text: 'Generate EPUB', 'data-format': 'epub', on: { click: function () {
api.request('/api/projects/' + encodeURIComponent(state.programId) + '/output/epub', {
method: 'POST',
body: { language_tag: state.languageCode }
}).then(
function () { api.showToast('EPUB generation queued', 'success'); },
function (err) { api.showToast(api.describeError(err), 'error'); }
);
} } }),
el('button', { class: 'btn', type: 'button', text: 'Generate PDF', 'data-format': 'pdf', on: { click: function () {
api.request('/api/projects/' + encodeURIComponent(state.programId) + '/output/pdf', {
method: 'POST',
body: { language_tag: state.languageCode }
}).then(
function () { api.showToast('PDF generation queued', 'success'); },
function (err) { api.showToast(api.describeError(err), 'error'); }
);
} } }),
el('button', { class: 'btn', type: 'button', text: 'Generate Audio', 'data-format': 'audio', on: { click: function () {
api.request('/api/projects/' + encodeURIComponent(state.programId) + '/output/audio', {
method: 'POST',
body: { language_tag: state.languageCode }
}).then(
function () { api.showToast('Audio generation queued', 'success'); },
function (err) { api.showToast(api.describeError(err), 'error'); }
);
} } })
]));
outputPanel.appendChild(el('div', { class: 'output-downloads', style: 'margin-top: 1rem;' }));
main.appendChild(outputPanel);
main.appendChild(el('div', { class: 'umbra-inline-actions' }, [
el('button', { class: 'btn', type: 'button', text: 'Save', on: { click: function () {
var cover = main.querySelector('input[placeholder="Cover image"]').value;