Rendering code refactor

This commit is contained in:
marc
2025-04-26 19:09:59 +02:00
parent 7a823a98ab
commit d132e6fd60
33 changed files with 638 additions and 188 deletions

View File

@@ -5,3 +5,5 @@ from .sessio import *
from .sessions import * from .sessions import *
from .tema import * from .tema import *
from .temes import * from .temes import *
__all__ = ["router"]

View File

@@ -1,9 +1,15 @@
from typing import Annotated from typing import Annotated
from fastapi import Form, Request from fastapi import Form, HTTPException, Request
from fastapi.responses import HTMLResponse
from folkugat_web.api import router from folkugat_web.api import router
from folkugat_web.fragments import live, sessio from folkugat_web.fragments import live, sessio
from folkugat_web.services import auth from folkugat_web.services import auth, files
from folkugat_web.services import playlists as playlists_service
from folkugat_web.services.lilypond import build as lilypond_build
from folkugat_web.services.lilypond import render as lilypond_render
from folkugat_web.services.lilypond import source as lilypond_source
from folkugat_web.services.temes import scores as scores_service
from folkugat_web.services.temes import write as temes_service from folkugat_web.services.temes import write as temes_service
from folkugat_web.templates import templates from folkugat_web.templates import templates
@@ -197,3 +203,27 @@ def set_tema_new(
entry_id=entry_id, entry_id=entry_id,
tema_id=new_tema.id, tema_id=new_tema.id,
) )
@router.get("/api/sessio/{session_id}/set/{set_id}/score")
async def render(
request: Request,
_: auth.RequireLogin,
session_id: int,
set_id: int,
):
set_entry = playlists_service.get_set(session_id=session_id, set_id=set_id)
if not set_entry:
raise HTTPException(status_code=404, detail="Could not find set!")
tune_set = lilypond_build.set_from_set(set_entry=set_entry)
set_source = lilypond_source.set_source(tune_set=tune_set)
pdf_result = await lilypond_render.render(
source=set_source,
output=lilypond_render.RenderOutput.PDF,
)
if output_filename := pdf_result.result:
score_render_url = files.get_db_file_path(output_filename)
# return temes.score_render(request=request, score_id=score_id, score_render_url=score_render_url)
return HTMLResponse(content=score_render_url)
else:
return HTMLResponse()

View File

@@ -1,5 +1,5 @@
import datetime import datetime
from typing import Annotated, Optional from typing import Annotated
from fastapi import Form, Request from fastapi import Form, Request
from folkugat_web.api import router from folkugat_web.api import router

View File

@@ -48,6 +48,12 @@ def contingut(request: Request, logged_in: auth.LoggedIn, tema_id: int):
@router.delete("/api/tema/{tema_id}") @router.delete("/api/tema/{tema_id}")
def delete_tema(_: auth.RequireLogin, tema_id: int): def delete_tema(_: auth.RequireLogin, tema_id: int):
tema = temes_q.get_tema_by_id(tema_id)
if not tema:
raise HTTPException(status_code=404, detail="Could not find tune")
tema = temes_q.tema_compute_stats(tema=tema)
if tema.stats:
raise HTTPException(status_code=400, detail="Can't delete a tune that has been played in a set!")
temes_w.delete_tema(tema_id=tema_id) temes_w.delete_tema(tema_id=tema_id)
files_service.clean_orphan_files() files_service.clean_orphan_files()
return HTMLResponse(headers={ return HTMLResponse(headers={

View File

@@ -7,11 +7,11 @@ from fastapi.responses import HTMLResponse
from folkugat_web.api import router from folkugat_web.api import router
from folkugat_web.fragments import temes from folkugat_web.fragments import temes
from folkugat_web.fragments.tema import scores as scores_fragments from folkugat_web.fragments.tema import scores as scores_fragments
from folkugat_web.services import auth, files, lilypond from folkugat_web.services import auth, files
from folkugat_web.services.temes import properties as properties_service from folkugat_web.services.lilypond import build as lilypond_build
from folkugat_web.services.temes import query as temes_q from folkugat_web.services.lilypond import render as lilypond_render
from folkugat_web.services.lilypond import source as lilypond_source
from folkugat_web.services.temes import scores as scores_service from folkugat_web.services.temes import scores as scores_service
from folkugat_web.utils import FnChain
@router.get("/api/tema/{tema_id}/score/{score_id}") @router.get("/api/tema/{tema_id}/score/{score_id}")
@@ -36,10 +36,19 @@ async def set_score(
if not score: if not score:
raise HTTPException(status_code=404, detail="Could not find lyric!") raise HTTPException(status_code=404, detail="Could not find lyric!")
full_source = scores_service.build_single_tune_full_source(tema_id=tema_id, source=source) tune = lilypond_build.tune_from_tema_id(tema_id=tema_id, score_source=source)
pdf_filename = files.create_tema_filename(tema_id=tema_id) tune_source = lilypond_source.tune_source(tune=tune)
pdf_filename, errors = await lilypond.render(source=full_source, fmt="pdf", output_filename=pdf_filename) pdf_result, png_result = await render_tune(tune_source=tune_source, tema_id=tema_id)
if errors: if errors := pdf_result.error:
new_score = dataclasses.replace(
score,
source=source,
title=title,
errors=errors,
)
elif png_result is None:
raise RuntimeError(f"Received empty png_result with pdf_result: {pdf_result}")
elif errors := png_result.error:
new_score = dataclasses.replace( new_score = dataclasses.replace(
score, score,
source=source, source=source,
@@ -47,21 +56,43 @@ async def set_score(
errors=errors, errors=errors,
) )
else: else:
png_filename = files.create_tema_filename(tema_id=tema_id) pdf_file = pdf_result.result
png_filename, errors = await lilypond.render(source=full_source, fmt="png", output_filename=png_filename) png_file = png_result.result
new_score = dataclasses.replace( new_score = dataclasses.replace(
score, score,
source=source, source=source,
title=title, title=title,
pdf_url=files.get_db_file_path(pdf_filename) if pdf_filename else None, pdf_url=files.get_db_file_path(pdf_file) if pdf_file else None,
img_url=files.get_db_file_path(png_filename) if png_filename else None, img_url=files.get_db_file_path(png_file) if png_file else None,
errors=errors, errors=[],
) )
scores_service.update_score(score=new_score) scores_service.update_score(score=new_score)
files.clean_orphan_files() files.clean_orphan_files()
return scores_fragments.score(request=request, logged_in=logged_in, score=new_score) return scores_fragments.score(request=request, logged_in=logged_in, score=new_score)
async def render_tune(
tune_source: str,
tema_id: int,
) -> tuple[lilypond_render.RenderResult, lilypond_render.RenderResult | None]:
async with files.tmp_file(content=tune_source) as source_file:
pdf_file = files.create_tema_filename(tema_id=tema_id)
pdf_result = await lilypond_render.render_file(
input_file=source_file,
output=lilypond_render.RenderOutput.PDF,
output_file=pdf_file,
)
if pdf_result.error:
return pdf_result, None
png_file = files.create_tema_filename(tema_id=tema_id)
png_result = await lilypond_render.render_file(
input_file=source_file,
output=lilypond_render.RenderOutput.PNG_CROPPED,
output_file=png_file,
)
return pdf_result, png_result
@router.post("/api/tema/{tema_id}/score") @router.post("/api/tema/{tema_id}/score")
def add_score( def add_score(
request: Request, request: Request,
@@ -108,13 +139,17 @@ async def render(
score_id: int, score_id: int,
source: Annotated[str, Form()], source: Annotated[str, Form()],
): ):
full_source = scores_service.build_single_tune_full_source(tema_id=tema_id, source=source) tune = lilypond_build.tune_from_tema_id(tema_id=tema_id, score_source=source)
output_filename, errors = await lilypond.render(source=full_source, fmt="png") full_source = lilypond_source.tune_source(tune=tune)
if output_filename: png_result = await lilypond_render.render(
source=full_source,
output=lilypond_render.RenderOutput.PNG_CROPPED,
)
if output_filename := png_result.result:
score_render_url = files.get_db_file_path(output_filename) score_render_url = files.get_db_file_path(output_filename)
return temes.score_render(request=request, score_id=score_id, score_render_url=score_render_url) return temes.score_render(request=request, score_id=score_id, score_render_url=score_render_url)
else: else:
return temes.score_render(request=request, score_id=score_id, errors=errors) return temes.score_render(request=request, score_id=score_id, errors=png_result.error)
@router.put("/api/tema/{tema_id}/score/{score_id}/show") @router.put("/api/tema/{tema_id}/score/{score_id}/show")

View File

@@ -602,6 +602,10 @@ video {
margin: 0.75rem; margin: 0.75rem;
} }
.m-4 {
margin: 1rem;
}
.m-6 { .m-6 {
margin: 1.5rem; margin: 1.5rem;
} }
@@ -701,6 +705,10 @@ video {
height: 80%; height: 80%;
} }
.h-auto {
height: auto;
}
.h-px { .h-px {
height: 1px; height: 1px;
} }
@@ -729,6 +737,10 @@ video {
max-width: 56rem; max-width: 56rem;
} }
.max-w-full {
max-width: 100%;
}
.max-w-xl { .max-w-xl {
max-width: 36rem; max-width: 36rem;
} }
@@ -871,6 +883,10 @@ video {
border-radius: 0.25rem; border-radius: 0.25rem;
} }
.rounded-lg {
border-radius: 0.5rem;
}
.rounded-md { .rounded-md {
border-radius: 0.375rem; border-radius: 0.375rem;
} }
@@ -911,6 +927,11 @@ video {
background-color: rgb(62 56 52 / var(--tw-bg-opacity, 1)); background-color: rgb(62 56 52 / var(--tw-bg-opacity, 1));
} }
.bg-white {
--tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1));
}
.p-0 { .p-0 {
padding: 0px; padding: 0px;
} }
@@ -961,6 +982,11 @@ video {
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
} }
.py-4 {
padding-top: 1rem;
padding-bottom: 1rem;
}
.pl-5 { .pl-5 {
padding-left: 1.25rem; padding-left: 1.25rem;
} }

View File

@@ -2,7 +2,10 @@
<h5 class="text text-beige"> Previsualització </h5> <h5 class="text text-beige"> Previsualització </h5>
<hr class="h-px mt-1 mb-3 bg-beige border-0"> <hr class="h-px mt-1 mb-3 bg-beige border-0">
{% if score_render_url %} {% if score_render_url %}
<img class="my-2" src="{{ score_render_url }}" /> <div class="flex flex-col items-center
bg-white rounded-lg">
<img class="m-4" src="{{ score_render_url }}" />
</div>
{% else %} {% else %}
<ol class="text-red-400"> <ol class="text-red-400">
{% for error in errors %} {% for error in errors %}

View File

@@ -47,22 +47,25 @@
{% endif %} {% endif %}
</h5> </h5>
<hr class="h-px mt-1 mb-3 bg-beige border-0"> <hr class="h-px mt-1 mb-3 bg-beige border-0">
<div class="flex flex-col items-center">
{% if score.img_url is not none %} {% if score.img_url is not none %}
<div class="flex flex-col items-center
bg-white rounded-lg ">
<div class="flex justify-center"> <div class="flex justify-center">
<a href="{{ score.pdf_url or score.img_url }}" target="_blank"> <a href="{{ score.pdf_url or score.img_url }}" target="_blank">
<img class="m-2" src="{{ score.img_url }}" /> <img class="py-4 px-2 max-w-full h-auto" src="{{ score.img_url }}" />
</a> </a>
</div> </div>
{% if score.pdf_url is not none %} {% if score.pdf_url is not none %}
<div class="text-beige border rounded border-beige m-2 p-1"> <div class="bg-beige border rounded border-beige m-2 p-1">
<a href="{{ score.pdf_url }}" target="_blank">Obre el PDF</a> <a href="{{ score.pdf_url }}" target="_blank">Obre el PDF</a>
</div> </div>
{% endif %} {% endif %}
</div>
{% elif score.pdf_url is not none %} {% elif score.pdf_url is not none %}
<div class="flex flex-col items-center">
{% set pdf_url = score.pdf_url %} {% set pdf_url = score.pdf_url %}
{% include "fragments/pdf_viewer.html" %} {% include "fragments/pdf_viewer.html" %}
{% endif %} </div>
</div> {% endif %}
</div> </div>
{% endif %} {% endif %}

View File

@@ -10,11 +10,13 @@
<i class="fa fa-pencil" aria-hidden="true"></i> <i class="fa fa-pencil" aria-hidden="true"></i>
</button> </button>
{% include "fragments/tema/visibility.html" %} {% include "fragments/tema/visibility.html" %}
<button title="Canvia el títol" {% if not tema.stats %}
<button title="Esborra el tema"
class="text-beige text-2xl mx-2" class="text-beige text-2xl mx-2"
hx-delete="/api/tema/{{ tema.id }}" hx-delete="/api/tema/{{ tema.id }}"
hx-swap="outerHTML"> hx-swap="outerHTML">
<i class="fa fa-times" aria-hidden="true"></i> <i class="fa fa-times" aria-hidden="true"></i>
</button> </button>
{% endif %} {% endif %}
{% endif %}
</div> </div>

View File

@@ -1,19 +1,3 @@
% -----------------------------------------------------
% Comandes últils
% -----------------------------------------------------
% Anacrusa: \partial 2 (2 = blanca)
%
% Repetició amb caselles:
% \repeat volta 2 {
% %%%
% \alternative {
% \volta 1 { %%% }
% \volta 2 { %%% }
% }
% }
%
% -----------------------------------------------------
\new ChordNames { \new ChordNames {
\chords { \chords {
\set chordChanges = ##t \set chordChanges = ##t
@@ -50,11 +34,21 @@
% A % A
\mark \default \mark \default
\repeat volta 2 {
\bar "||" \break \alternative {
\volta 1 { }
\volta 2 { }
}
}
% B % B
\mark \default \mark \default
\repeat volta 2 {
\alternative {
\volta 1 { }
\volta 2 { }
}
}
\bar "|." \bar "|."
} }

View File

@@ -6,11 +6,9 @@
} }
\header { \header {
title = \markup { "{{ tema.title | safe }}" } title = \markup { "{{ tune.header.title | safe }}" }
{% if tema.composer() %} {% if tune.header.composer %}
composer = "{{ tema.composer() | safe }}" composer = "{{ tune.header.composer | safe }}"
{% elif tema.origin() %}
composer = "{{ tema.origin() | safe }}"
{% endif %} {% endif %}
tagline = "Partitura generada amb LilyPond" tagline = "Partitura generada amb LilyPond"
@@ -19,15 +17,17 @@
\markup \vspace #2 \markup \vspace #2
{% if tune.score_source is not none %}
\score { \score {
\language "english" \language "english"
<< <<
{{ score_beginning }} {{ score_beginning }}
{{ score_source | safe }} {{ tune.score_source | safe }}
>> >>
} }
{% if lyrics_text is not none %} {% endif %}
{% for line in lyrics_text.lines %} {% if tune.lyrics is not none %}
{% for line in tune.lyrics.lines %}
\markup { \markup {
\vspace #2 \vspace #2
\fill-line { \fill-line {
@@ -42,7 +42,7 @@
\hspace #1 \hspace #1
} }
} }
{% if loop.index < lyrics_text.lines|length %} {% if loop.index < tune.lyrics.lines|length %}
\markup { \markup {
\vspace #1 \vspace #1
\fill-line { \fill-line {

View File

@@ -0,0 +1,46 @@
\markup {
\vspace #2
\fill-line {
\center-column {
\large \bold "{{ tune.header.title | safe }}"
{% if tune.header.composer %}
"{{ tune.header.composer | safe }}"
{% endif %}
}
}
}
{% if tune.score_source is not none %}
\score {
\language "english"
<<
{{ tune.score_source | safe }}
>>
}
{% endif %}
{% if tune.lyrics is not none %}
{% for line in tune.lyrics.lines %}
\markup {
\vspace #2
\fill-line {
{% for paragraph in line.paragraphs %}
\hspace #1
\center-column {
{% for line in paragraph.lines %}
\line { {{ line | safe }} }
{% endfor %}
}
{% endfor %}
\hspace #1
}
}
{% if loop.index < tune.lyrics.lines|length %}
\markup {
\vspace #1
\fill-line {
\override #'(span-factor . 4/9)
\draw-hline
}
}
{% endif %}
{% endfor %}
{% endif %}

View File

@@ -0,0 +1,31 @@
{{ score_beginning }}
\version "2.24.4"
\book {
\paper {
top-margin = 10
left-margin = 15
right-margin = 15
scoreTitleMarkup = \markup {
\center-column {
\fontsize #3 \bold \fromproperty #'header:piece
\fill-line {
\null
\right-column {
\fromproperty #'header:composer
}
}
}
}
}
\header {
title = \markup { "{{ tune_set.title | safe }}" }
tagline = "Partitura generada amb LilyPond"
copyright = "Folkugat"
}
{% for tune in tune_set.tunes %}
{% include "lilypond/tune_in_set.ly" %}
{% endfor %}
}

View File

@@ -49,6 +49,7 @@ def get_playlist_entries(
id, session_id, set_id, tema_id id, session_id, set_id, tema_id
FROM playlists FROM playlists
WHERE {filter_clause} WHERE {filter_clause}
ORDER BY id ASC
""" """
with get_connection(con) as con: with get_connection(con) as con:
cur = con.cursor() cur = con.cursor()

View File

@@ -1,3 +1,5 @@
from collections.abc import Iterable
from folkugat_web.dal.sql import Connection, get_connection from folkugat_web.dal.sql import Connection, get_connection
from folkugat_web.model import search as search_model from folkugat_web.model import search as search_model
from folkugat_web.model import temes as model from folkugat_web.model import temes as model
@@ -22,6 +24,21 @@ def get_tema_by_id(tema_id: int, con: Connection | None = None) -> model.Tema |
return conversion.row_to_tema(row) if row else None return conversion.row_to_tema(row) if row else None
def get_temes_by_ids(tema_ids: list[int], con: Connection | None = None) -> list[model.Tema]:
placeholders = ", ".join(["?" for _ in tema_ids])
query = f"""
SELECT
id, title, alternatives, creation_date, modification_date, hidden
FROM temes
WHERE id IN ({placeholders})
"""
with get_connection(con) as con:
cur = con.cursor()
_ = cur.execute(query, tema_ids)
rows: Iterable[conversion.TemaRowTuple] = cur.fetchall()
return list(map(conversion.row_to_tema, rows))
def evict_tema_id_to_ngrams_cache(): def evict_tema_id_to_ngrams_cache():
global _tema_id_to_ngrams_cache global _tema_id_to_ngrams_cache
_tema_id_to_ngrams_cache = None _tema_id_to_ngrams_cache = None

View File

@@ -3,8 +3,8 @@ from collections.abc import Iterable
from typing import TypedDict from typing import TypedDict
from folkugat_web.dal.sql import Connection, get_connection from folkugat_web.dal.sql import Connection, get_connection
from folkugat_web.model import lilypond as lilypond_model
from folkugat_web.model import temes as model from folkugat_web.model import temes as model
from folkugat_web.model.lilypond.processing import RenderError
ScoreRowTuple = tuple[int, int, str, str, str, str | None, str | None, bool] ScoreRowTuple = tuple[int, int, str, str, str, str | None, str | None, bool]
@@ -40,7 +40,7 @@ def row_to_score(row: ScoreRowTuple) -> model.Score:
tema_id=row[1], tema_id=row[1],
title=row[2], title=row[2],
source=row[3], source=row[3],
errors=list(map(lilypond_model.RenderError.from_dict, errors_dicts)), errors=list(map(RenderError.from_dict, errors_dicts)),
img_url=row[5], img_url=row[5],
pdf_url=row[6], pdf_url=row[6],
hidden=bool(row[7]), hidden=bool(row[7]),

View File

@@ -1,5 +1,3 @@
from typing import Optional
from fastapi import Request from fastapi import Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from folkugat_web.model.pagines import Pages from folkugat_web.model.pagines import Pages

View File

@@ -1,5 +1,3 @@
from typing import Optional
from fastapi import Request from fastapi import Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from folkugat_web.config import calendari as config from folkugat_web.config import calendari as config

View File

@@ -1,6 +1,6 @@
from fastapi import Request from fastapi import Request
from folkugat_web.model import temes as model from folkugat_web.model import temes as model
from folkugat_web.model.lilypond import RenderError from folkugat_web.model.lilypond.processing import RenderError
from folkugat_web.model.pagines import Pages from folkugat_web.model.pagines import Pages
from folkugat_web.services import sessions as sessions_service from folkugat_web.services import sessions as sessions_service
from folkugat_web.services.temes import links as links_service from folkugat_web.services.temes import links as links_service

View File

View File

@@ -22,18 +22,3 @@ class RenderError:
pos=str(self.pos), pos=str(self.pos),
error=self.error, error=self.error,
) )
@dataclasses.dataclass
class LyricsParagraph:
lines: list[str]
@dataclasses.dataclass
class LyricsLine:
paragraphs: list[LyricsParagraph]
@dataclasses.dataclass
class LyricsText:
lines: list[LyricsLine]

View File

@@ -0,0 +1,91 @@
import dataclasses
import zlib
from typing import Self
from folkugat_web.model import temes
from folkugat_web.utils import batched
def get_hash(*v: bytes) -> bytes:
return zlib.adler32(b"".join(v)).to_bytes(4)
@dataclasses.dataclass
class LyricsParagraph:
lines: list[str]
def hash(self) -> bytes:
return get_hash(*(get_hash(line.encode()) for line in self.lines))
@dataclasses.dataclass
class LyricsLine:
paragraphs: list[LyricsParagraph]
def hash(self) -> bytes:
return get_hash(*(paragraph.hash() for paragraph in self.paragraphs))
@dataclasses.dataclass
class LyricsText:
lines: list[LyricsLine]
@classmethod
def from_lyrics(cls, lyrics: temes.Lyrics, max_cols: int = 3) -> Self:
paragraphs = [
LyricsParagraph(lines=par_str.splitlines())
for par_str in lyrics.content.split("\n\n") if par_str
]
return cls(lines=[
LyricsLine(paragraphs=list(line_pars))
for line_pars in batched(paragraphs, max_cols)
])
def hash(self) -> bytes:
return get_hash(*(line.hash() for line in self.lines))
@dataclasses.dataclass
class HeaderData:
title: str
composer: str | None
@classmethod
def from_tema(cls, tema: temes.Tema) -> Self:
return cls(
title=tema.title,
composer=tema.composer() or tema.origin(),
)
def hash(self) -> bytes:
return get_hash(
self.title.encode(),
(self.composer or "").encode(),
)
@dataclasses.dataclass
class LilypondTune:
header: HeaderData
score_source: str | None
lyrics: LyricsText | None
is_unknown: bool = False
def hash(self) -> bytes:
return get_hash(
self.header.hash(),
(self.score_source or "").encode(),
self.lyrics.hash() if self.lyrics else b"",
)
@dataclasses.dataclass
class LilypondSet:
title: str
tunes: list[LilypondTune]
def hash(self) -> bytes:
return get_hash(
self.title.encode(),
*(tune.hash() for tune in self.tunes),
)

View File

@@ -51,7 +51,7 @@ class Set:
raise ValueError("All PlaylistEntries must have the same session_id") raise ValueError("All PlaylistEntries must have the same session_id")
return cls( return cls(
id=set_id, id=set_id,
temes=[TemaInSet.from_playlist_entry(entry) for entry in entries], temes=[TemaInSet.from_playlist_entry(entry) for entry in sorted(entries, key=lambda e: e.id or 0)],
) )

View File

@@ -4,7 +4,7 @@ import enum
import itertools import itertools
from typing import Self from typing import Self
from folkugat_web.model.lilypond import RenderError from folkugat_web.model.lilypond.processing import RenderError
from folkugat_web.model.search import NGrams from folkugat_web.model.search import NGrams
from folkugat_web.model.sessions import Session from folkugat_web.model.sessions import Session
from folkugat_web.services import ngrams from folkugat_web.services import ngrams

View File

@@ -3,8 +3,10 @@ import os
import re import re
import uuid import uuid
from collections.abc import Iterator from collections.abc import Iterator
from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
import aiofiles
import magic import magic
from fastapi import HTTPException, UploadFile from fastapi import HTTPException, UploadFile
from folkugat_web.config import db from folkugat_web.config import db
@@ -57,8 +59,8 @@ async def store_file(tema_id: int, upload_file: UploadFile) -> str:
def create_tema_filename(tema_id: int, extension: str = "") -> Path: def create_tema_filename(tema_id: int, extension: str = "") -> Path:
filename = str(uuid.uuid4().hex) + extension filename = str(uuid.uuid4().hex) + extension
filedir = db.DB_FILES_DIR / str(tema_id) filedir = db.DB_FILES_DIR / "tema" / str(tema_id)
filedir.mkdir(exist_ok=True) filedir.mkdir(parents=True, exist_ok=True)
filepath = filedir / filename filepath = filedir / filename
return filepath return filepath
@@ -71,6 +73,18 @@ def create_tmp_filename(extension: str = "") -> Path:
return filepath return filepath
@asynccontextmanager
async def tmp_file(content: str):
input_filename = create_tmp_filename(extension=".ly")
async with aiofiles.open(input_filename, "w") as f:
_ = await f.write(content)
try:
yield input_filename
finally:
if input_filename.exists():
os.remove(input_filename)
def list_files(tema_id: str) -> list[str]: def list_files(tema_id: str) -> list[str]:
filedir = db.DB_FILES_DIR / str(tema_id) filedir = db.DB_FILES_DIR / str(tema_id)
return [get_db_file_path(f) for f in filedir.iterdir()] return [get_db_file_path(f) for f in filedir.iterdir()]

View File

@@ -1,89 +0,0 @@
import asyncio
import dataclasses
import os
import re
from pathlib import Path
from typing import Literal
import aiofiles
from folkugat_web.model.lilypond import (LyricsLine, LyricsParagraph,
LyricsText, RenderError)
from folkugat_web.model.temes import Tema
from folkugat_web.services import files as files_service
from folkugat_web.templates import templates
from folkugat_web.utils import batched
SCORE_BEGINNING = "% --- SCORE BEGINNING --- %"
RenderFormat = Literal["png"] | Literal["pdf"]
def source_to_single_score(source: str, tema: Tema) -> str:
if tema.lyrics:
lyrics_text = build_lyrics(tema.lyrics[0].content)
else:
lyrics_text = None
print("AAAAAA")
print(lyrics_text)
print("AAAAAA")
return templates.get_template("lilypond/single_score.ly").render(
score_beginning=SCORE_BEGINNING,
score_source=source,
tema=tema,
lyrics_text=lyrics_text,
)
async def render(source: str, fmt: RenderFormat, output_filename: Path | None = None) -> tuple[Path | None, list[RenderError]]:
input_filename = files_service.create_tmp_filename(extension=".ly")
async with aiofiles.open(input_filename, "w") as f:
_ = await f.write(source)
output_filename = output_filename or files_service.create_tmp_filename()
if fmt == "png":
proc = await asyncio.create_subprocess_exec(
"lilypond", "--png", "-o", output_filename, input_filename,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
else:
proc = await asyncio.create_subprocess_exec(
"lilypond", "-o", output_filename, input_filename,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_stdout, stderr = await proc.communicate()
if proc.returncode:
esc_input_filename = re.escape(str(input_filename))
error_re = re.compile(rf"{esc_input_filename}:(?P<line>\d+):(?P<pos>\d+): error: (?P<error>.*)$")
error_matches = filter(None, map(error_re.match, stderr.decode("utf-8").splitlines()))
errors = map(lambda m: RenderError.from_dict(m.groupdict()), error_matches)
line_offset = source.splitlines().index(SCORE_BEGINNING) + 1
errors_offset = [dataclasses.replace(e, line=e.line - line_offset) for e in errors if e.line > line_offset]
return None, errors_offset
if fmt == "png":
# Check if the output generated multiple pages
pages = sorted(output_filename.parent.rglob(f"{output_filename.name}*.png"))
if len(pages) > 1:
output_filename = pages[0]
for page in pages[1:]:
os.remove(page)
else:
output_filename = output_filename.with_suffix(".png")
elif fmt == "pdf":
output_filename = output_filename.with_suffix(".pdf")
return output_filename, []
def build_lyrics(text: str, max_cols: int = 3) -> LyricsText:
paragraphs = [LyricsParagraph(lines=par_str.splitlines()) for par_str in text.split("\n\n") if par_str]
print(list(batched(paragraphs, max_cols)))
return LyricsText(lines=[
LyricsLine(paragraphs=list(line_pars))
for line_pars in batched(paragraphs, max_cols)
])

View File

@@ -0,0 +1,102 @@
from collections.abc import Iterable, Iterator
from fastapi import HTTPException
from folkugat_web.dal.sql.temes import scores as scores_dal
from folkugat_web.model import playlists as playlists_model
from folkugat_web.model import temes as model
from folkugat_web.model.lilypond import score as lilypond_model
from folkugat_web.services import lilypond
from folkugat_web.services.temes import lyrics as lyrics_service
from folkugat_web.services.temes import properties as properties_service
from folkugat_web.services.temes import query as temes_q
from folkugat_web.services.temes import scores as scores_service
from folkugat_web.utils import FnChain
UNKNOWN_TITLE = "Desconegut"
def unknown_tune() -> lilypond_model.LilypondTune:
return lilypond_model.LilypondTune(
header=lilypond_model.HeaderData(
title=UNKNOWN_TITLE,
composer=None,
),
score_source=None,
lyrics=None,
is_unknown=True,
)
def tune_from_tema(tema: model.Tema | None, score_source: str | None = None) -> lilypond_model.LilypondTune:
"""
The given `tema` is assumed to have properties, lyrics and scores (if source is None)
"""
if tema is None:
return unknown_tune()
return lilypond_model.LilypondTune(
header=lilypond_model.HeaderData.from_tema(tema=tema),
score_source=score_source or (tema.scores[0].source if tema.scores else None),
lyrics=lilypond_model.LyricsText.from_lyrics(lyrics=tema.lyrics[0]) if tema.lyrics else None,
)
def tunes_from_temes(temes: Iterable[model.Tema | None]) -> list[lilypond_model.LilypondTune]:
"""
All `Tema` in `temes` are assumed to have properties, lyrics and scores
"""
return [tune_from_tema(tema) for tema in temes]
def tune_from_tema_id(tema_id: int, score_source: str | None = None) -> lilypond_model.LilypondTune:
tema = temes_q.get_tema_by_id(tema_id)
if not tema:
raise HTTPException(status_code=404, detail="Could not find tema!")
tema = (
FnChain.transform(tema) |
properties_service.add_properties_to_tema |
lyrics_service.add_lyrics_to_tema
).result()
if score_source is None:
tema = scores_service.add_scores_to_tema(tema)
return tune_from_tema(tema=tema, score_source=score_source)
def tunes_from_tema_ids(tema_ids: list[int]) -> list[lilypond_model.LilypondTune]:
temes = (
FnChain.transform(temes_q.get_temes_by_ids(tema_ids=tema_ids)) |
properties_service.add_properties_to_temes |
lyrics_service.add_lyrics_to_temes |
scores_service.add_scores_to_temes |
list
).result()
return tunes_from_temes(temes=temes)
def build_set_title(temes: list[model.Tema | None]) -> str:
def build_title(tema: model.Tema | None) -> str:
return tema.title if tema else UNKNOWN_TITLE
return " i ".join(filter(bool, [
", ".join([build_title(tema) for tema in temes[:-1]]),
build_title(temes[-1])
]))
def set_from_set(set_entry: playlists_model.Set) -> lilypond_model.LilypondSet:
def _build_temes_by_id(temes: Iterable[model.Tema]) -> dict[int, model.Tema]:
return {tema.id: tema for tema in temes if tema.id is not None}
tema_ids = [tema_in_set.tema_id for tema_in_set in set_entry.temes]
temes_by_id = (
FnChain.transform([tema_id for tema_id in tema_ids if tema_id is not None]) |
temes_q.get_temes_by_ids |
properties_service.add_properties_to_temes |
lyrics_service.add_lyrics_to_temes |
scores_service.add_scores_to_temes |
_build_temes_by_id
).result()
temes = [temes_by_id[tema_id] if tema_id is not None else None for tema_id in tema_ids]
set_title = build_set_title(temes=temes)
tunes = tunes_from_temes(temes)
return lilypond_model.LilypondSet(
title=set_title,
tunes=tunes
)

View File

@@ -0,0 +1,105 @@
import asyncio
import dataclasses
import enum
import re
from pathlib import Path
import aiofiles
from folkugat_web.log import logger
from folkugat_web.model.lilypond.processing import RenderError
from folkugat_web.services import files as files_service
from folkugat_web.utils import Result
from .source import SCORE_BEGINNING
RenderResult = Result[Path, list[RenderError]]
class RenderOutput(enum.Enum):
PDF = "pdf"
PNG_CROPPED = "png-cropped"
PNG_PREVIEW = "png-preview"
async def render(
source: str,
output: RenderOutput,
output_file: Path | None = None,
) -> RenderResult:
async with files_service.tmp_file(content=source) as input_file:
return await render_file(
input_file=input_file,
output=output,
output_file=output_file,
)
async def render_file(
input_file: Path,
output: RenderOutput,
output_file: Path | None = None,
) -> RenderResult:
output_file = output_file or files_service.create_tmp_filename()
match output:
case RenderOutput.PDF:
command = [
"lilypond",
"-f", "pdf",
"-o", str(output_file),
str(input_file)
]
output_file = output_file.with_suffix(".pdf")
case RenderOutput.PNG_CROPPED:
command = [
"lilypond",
"-f", "png",
"-dcrop=#t",
"-dno-print-pages",
# "-duse-paper-size-for-page=#f",
"-o", str(output_file),
str(input_file)
]
output_file = output_file.with_suffix(".cropped.png")
# output_file = output_file.with_suffix(".png")
case RenderOutput.PNG_PREVIEW:
command = [
"lilypond",
"-f", "png",
"-dpreview=#t",
"-dno-print-pages",
"-o", str(output_file),
str(input_file)
]
output_file = output_file.with_suffix(".preview.png")
proc = await asyncio.create_subprocess_exec(
*command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_stdout, stderr = await proc.communicate()
if proc.returncode:
errors = await _build_errors(input_file=input_file, stderr=stderr)
return Result(error=errors)
return Result(result=output_file)
async def _build_errors(input_file: Path, stderr: bytes) -> list[RenderError]:
stderr_lines = stderr.decode("utf-8").splitlines()
logger.warning("Lilypond errors:")
for line in stderr_lines:
logger.warning(line)
async with aiofiles.open(input_file, "r") as f:
lines = await f.readlines()
offset_mark = SCORE_BEGINNING + "\n"
line_offset = lines.index(offset_mark) + 1 if offset_mark in lines else 0
esc_input_file = re.escape(str(input_file))
error_re = re.compile(rf"{esc_input_file}:(?P<line>\d+):(?P<pos>\d+): error: (?P<error>.*)$")
error_matches = filter(None, map(error_re.match, stderr_lines))
errors = map(lambda m: RenderError.from_dict(m.groupdict()), error_matches)
errors_offset = [dataclasses.replace(e, line=e.line - line_offset) for e in errors if e.line > line_offset]
return errors_offset

View File

@@ -0,0 +1,18 @@
from folkugat_web.model.lilypond.score import LilypondSet, LilypondTune
from folkugat_web.templates import templates
SCORE_BEGINNING = "% --- SCORE BEGINNING --- %"
def tune_source(tune: LilypondTune) -> str:
return templates.get_template("lilypond/single_tune.ly").render(
score_beginning=SCORE_BEGINNING,
tune=tune,
)
def set_source(tune_set: LilypondSet) -> str:
return templates.get_template("lilypond/tune_set.ly").render(
score_beginning=SCORE_BEGINNING,
tune_set=tune_set,
)

View File

@@ -5,7 +5,11 @@ from folkugat_web.model import temes as model
def get_tema_by_id(tema_id: int) -> model.Tema | None: def get_tema_by_id(tema_id: int) -> model.Tema | None:
return temes_q.get_tema_by_id(tema_id) return temes_q.get_tema_by_id(tema_id=tema_id)
def get_temes_by_ids(tema_ids: list[int]) -> list[model.Tema]:
return temes_q.get_temes_by_ids(tema_ids=tema_ids)
def tema_compute_stats( def tema_compute_stats(

View File

@@ -3,6 +3,7 @@ from collections.abc import Iterable, Iterator
from fastapi import HTTPException from fastapi import HTTPException
from folkugat_web.dal.sql.temes import scores as scores_dal from folkugat_web.dal.sql.temes import scores as scores_dal
from folkugat_web.model import temes as model from folkugat_web.model import temes as model
from folkugat_web.model.lilypond import score as lilypond_model
from folkugat_web.services import lilypond from folkugat_web.services import lilypond
from folkugat_web.services.temes import lyrics as lyrics_service from folkugat_web.services.temes import lyrics as lyrics_service
from folkugat_web.services.temes import properties as properties_service from folkugat_web.services.temes import properties as properties_service
@@ -46,13 +47,29 @@ def create_score(tema_id: int) -> model.Score:
return scores_dal.insert_score(score=new_score) return scores_dal.insert_score(score=new_score)
def build_single_tune_full_source(tema_id: int, source: str) -> str: def build_tune_set_full_source(tema_ids: list[int]) -> str:
tema = temes_q.get_tema_by_id(tema_id) temes = (
if not tema: FnChain.transform(temes_q.get_temes_by_ids(tema_ids=tema_ids)) |
raise HTTPException(status_code=404, detail="Could not find tema!") properties_service.add_properties_to_temes |
tema = ( lyrics_service.add_lyrics_to_temes |
FnChain.transform(tema) | add_scores_to_temes |
properties_service.add_properties_to_tema | list
lyrics_service.add_lyrics_to_tema
).result() ).result()
return lilypond.source_to_single_score(source=source, tema=tema) if not temes:
return ""
set_title = " i ".join(filter(bool, [
", ".join([tema.title for tema in temes[:-1]]),
temes[-1].title
]))
tune_set = lilypond_model.LilypondSet(
title=set_title,
tunes=[
lilypond_model.LilypondTune(
header=lilypond_model.HeaderData.from_tema(tema=tema),
score_source=tema.scores[0].source if tema.scores else None,
lyrics=lilypond_model.LyricsText.from_lyrics(lyrics=tema.lyrics[0]) if tema.lyrics else None,
) for tema in temes
]
)
print("TUNE SET HASH: ", tune_set.hash().hex())
return lilypond.tune_set_score(tune_set=tune_set)

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import dataclasses
import itertools import itertools
from collections.abc import Callable, Iterable, Iterator from collections.abc import Callable, Iterable, Iterator
from typing import Generic, Protocol, Self, TypeVar from typing import Generic, Protocol, Self, TypeVar
@@ -55,3 +56,13 @@ def batched(iterable: Iterable[T], n: int) -> Iterator[tuple[T, ...]]:
iterator = iter(iterable) iterator = iter(iterable)
while batch := tuple(itertools.islice(iterator, n)): while batch := tuple(itertools.islice(iterator, n)):
yield batch yield batch
ResultT = TypeVar("ResultT")
ErrorT = TypeVar("ErrorT")
@dataclasses.dataclass
class Result(Generic[ResultT, ErrorT]):
result: ResultT | None = None
error: ErrorT | None = None