schenesort 2.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- schenesort/__init__.py +1 -0
- schenesort/cli.py +1323 -0
- schenesort/config.py +108 -0
- schenesort/db.py +341 -0
- schenesort/tui/__init__.py +5 -0
- schenesort/tui/app.py +180 -0
- schenesort/tui/widgets/__init__.py +6 -0
- schenesort/tui/widgets/image_preview.py +97 -0
- schenesort/tui/widgets/metadata_panel.py +161 -0
- schenesort/xmp.py +294 -0
- schenesort-2.1.1.dist-info/METADATA +318 -0
- schenesort-2.1.1.dist-info/RECORD +15 -0
- schenesort-2.1.1.dist-info/WHEEL +4 -0
- schenesort-2.1.1.dist-info/entry_points.txt +2 -0
- schenesort-2.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Image preview widget using textual-image."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Container
|
|
7
|
+
from textual.widgets import Static
|
|
8
|
+
from textual_image.widget import Image
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ImagePreview(Container):
|
|
12
|
+
"""Widget for displaying image preview with zoom support."""
|
|
13
|
+
|
|
14
|
+
DEFAULT_CSS = """
|
|
15
|
+
ImagePreview {
|
|
16
|
+
width: 100%;
|
|
17
|
+
height: 100%;
|
|
18
|
+
background: $background;
|
|
19
|
+
align: center middle;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
ImagePreview Image {
|
|
23
|
+
width: 100%;
|
|
24
|
+
height: 100%;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
ImagePreview .no-image {
|
|
28
|
+
color: $text-disabled;
|
|
29
|
+
text-style: italic;
|
|
30
|
+
text-align: center;
|
|
31
|
+
}
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, **kwargs) -> None:
|
|
35
|
+
super().__init__(**kwargs)
|
|
36
|
+
self._image_path: Path | None = None
|
|
37
|
+
self._zoom_level: float = 1.0
|
|
38
|
+
self._image_widget: Image | None = None
|
|
39
|
+
|
|
40
|
+
def compose(self) -> ComposeResult:
|
|
41
|
+
yield Static("No image selected", classes="no-image", id="placeholder")
|
|
42
|
+
|
|
43
|
+
def load_image(self, path: Path | None) -> None:
|
|
44
|
+
"""Load and display an image."""
|
|
45
|
+
self._image_path = path
|
|
46
|
+
self._zoom_level = 1.0
|
|
47
|
+
|
|
48
|
+
# Remove existing image widget if present
|
|
49
|
+
if self._image_widget is not None:
|
|
50
|
+
self._image_widget.remove()
|
|
51
|
+
self._image_widget = None
|
|
52
|
+
|
|
53
|
+
placeholder = self.query_one("#placeholder", Static)
|
|
54
|
+
|
|
55
|
+
if path is None or not path.exists():
|
|
56
|
+
placeholder.update("No image selected")
|
|
57
|
+
placeholder.display = True
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
placeholder.display = False
|
|
62
|
+
self._image_widget = Image(path)
|
|
63
|
+
self.mount(self._image_widget)
|
|
64
|
+
except Exception as e:
|
|
65
|
+
placeholder.update(f"Error loading image: {e}")
|
|
66
|
+
placeholder.display = True
|
|
67
|
+
|
|
68
|
+
def zoom_in(self) -> None:
|
|
69
|
+
"""Zoom in on the image."""
|
|
70
|
+
self._zoom_level = min(4.0, self._zoom_level * 1.25)
|
|
71
|
+
self._apply_zoom()
|
|
72
|
+
|
|
73
|
+
def zoom_out(self) -> None:
|
|
74
|
+
"""Zoom out of the image."""
|
|
75
|
+
self._zoom_level = max(0.25, self._zoom_level / 1.25)
|
|
76
|
+
self._apply_zoom()
|
|
77
|
+
|
|
78
|
+
def reset_zoom(self) -> None:
|
|
79
|
+
"""Reset zoom to 100%."""
|
|
80
|
+
self._zoom_level = 1.0
|
|
81
|
+
self._apply_zoom()
|
|
82
|
+
|
|
83
|
+
def _apply_zoom(self) -> None:
|
|
84
|
+
"""Apply current zoom level to the image widget."""
|
|
85
|
+
# textual-image handles scaling internally based on container size
|
|
86
|
+
# For now, zoom is a placeholder for future enhancement
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def current_path(self) -> Path | None:
|
|
91
|
+
"""Get the currently displayed image path."""
|
|
92
|
+
return self._image_path
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def zoom_level(self) -> float:
|
|
96
|
+
"""Get the current zoom level."""
|
|
97
|
+
return self._zoom_level
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Metadata panel widget for displaying image metadata."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.containers import VerticalScroll
|
|
5
|
+
from textual.widgets import Static
|
|
6
|
+
|
|
7
|
+
from schenesort.xmp import ImageMetadata
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MetadataPanel(VerticalScroll):
|
|
11
|
+
"""Panel displaying image metadata from XMP sidecar."""
|
|
12
|
+
|
|
13
|
+
DEFAULT_CSS = """
|
|
14
|
+
MetadataPanel {
|
|
15
|
+
width: 100%;
|
|
16
|
+
height: 100%;
|
|
17
|
+
background: $surface;
|
|
18
|
+
padding: 1 2;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
MetadataPanel .metadata-title {
|
|
22
|
+
text-style: bold;
|
|
23
|
+
color: $text;
|
|
24
|
+
margin-bottom: 1;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
MetadataPanel .metadata-label {
|
|
28
|
+
color: $text-muted;
|
|
29
|
+
margin-top: 1;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
MetadataPanel .metadata-value {
|
|
33
|
+
color: $text;
|
|
34
|
+
margin-left: 2;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
MetadataPanel .metadata-empty {
|
|
38
|
+
color: $text-disabled;
|
|
39
|
+
text-style: italic;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
MetadataPanel .metadata-tags {
|
|
43
|
+
color: $primary;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
MetadataPanel .metadata-colors {
|
|
47
|
+
color: $secondary;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
MetadataPanel .metadata-scene {
|
|
51
|
+
color: $text;
|
|
52
|
+
margin-left: 2;
|
|
53
|
+
text-style: italic;
|
|
54
|
+
}
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, **kwargs) -> None:
|
|
58
|
+
super().__init__(**kwargs)
|
|
59
|
+
self._metadata: ImageMetadata | None = None
|
|
60
|
+
self._filename: str = ""
|
|
61
|
+
|
|
62
|
+
def compose(self) -> ComposeResult:
|
|
63
|
+
yield Static("No image selected", classes="metadata-empty", id="content")
|
|
64
|
+
|
|
65
|
+
def update_metadata(self, metadata: ImageMetadata | None, filename: str = "") -> None:
|
|
66
|
+
"""Update the displayed metadata."""
|
|
67
|
+
self._metadata = metadata
|
|
68
|
+
self._filename = filename
|
|
69
|
+
self._refresh_display()
|
|
70
|
+
|
|
71
|
+
def _refresh_display(self) -> None:
|
|
72
|
+
"""Refresh the metadata display."""
|
|
73
|
+
content = self.query_one("#content", Static)
|
|
74
|
+
|
|
75
|
+
if self._metadata is None or self._metadata.is_empty():
|
|
76
|
+
if self._filename:
|
|
77
|
+
content.update(
|
|
78
|
+
f"[bold]{self._filename}[/bold]\n\n[dim italic]No metadata[/dim italic]"
|
|
79
|
+
)
|
|
80
|
+
else:
|
|
81
|
+
content.update("[dim italic]No image selected[/dim italic]")
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
lines = []
|
|
85
|
+
|
|
86
|
+
# Filename as title
|
|
87
|
+
if self._filename:
|
|
88
|
+
lines.append(f"[bold]{self._filename}[/bold]")
|
|
89
|
+
lines.append("")
|
|
90
|
+
|
|
91
|
+
# Description
|
|
92
|
+
if self._metadata.description:
|
|
93
|
+
lines.append("[dim]Description[/dim]")
|
|
94
|
+
lines.append(f" {self._metadata.description}")
|
|
95
|
+
|
|
96
|
+
# Scene (detailed description)
|
|
97
|
+
if self._metadata.scene:
|
|
98
|
+
lines.append("")
|
|
99
|
+
lines.append("[dim]Scene[/dim]")
|
|
100
|
+
lines.append(f" [italic]{self._metadata.scene}[/italic]")
|
|
101
|
+
|
|
102
|
+
# Tags
|
|
103
|
+
if self._metadata.tags:
|
|
104
|
+
lines.append("")
|
|
105
|
+
lines.append("[dim]Tags[/dim]")
|
|
106
|
+
tags_str = ", ".join(f"[cyan]{tag}[/cyan]" for tag in self._metadata.tags)
|
|
107
|
+
lines.append(f" {tags_str}")
|
|
108
|
+
|
|
109
|
+
# Mood
|
|
110
|
+
if self._metadata.mood:
|
|
111
|
+
lines.append("")
|
|
112
|
+
lines.append("[dim]Mood[/dim]")
|
|
113
|
+
mood_str = ", ".join(f"[magenta]{m}[/magenta]" for m in self._metadata.mood)
|
|
114
|
+
lines.append(f" {mood_str}")
|
|
115
|
+
|
|
116
|
+
# Style
|
|
117
|
+
if self._metadata.style:
|
|
118
|
+
lines.append("")
|
|
119
|
+
lines.append("[dim]Style[/dim]")
|
|
120
|
+
lines.append(f" [yellow]{self._metadata.style}[/yellow]")
|
|
121
|
+
|
|
122
|
+
# Colors
|
|
123
|
+
if self._metadata.colors:
|
|
124
|
+
lines.append("")
|
|
125
|
+
lines.append("[dim]Colors[/dim]")
|
|
126
|
+
colors_str = ", ".join(f"[green]{c}[/green]" for c in self._metadata.colors)
|
|
127
|
+
lines.append(f" {colors_str}")
|
|
128
|
+
|
|
129
|
+
# Time of day
|
|
130
|
+
if self._metadata.time_of_day:
|
|
131
|
+
lines.append("")
|
|
132
|
+
lines.append("[dim]Time[/dim]")
|
|
133
|
+
lines.append(f" {self._metadata.time_of_day}")
|
|
134
|
+
|
|
135
|
+
# Subject
|
|
136
|
+
if self._metadata.subject:
|
|
137
|
+
lines.append("")
|
|
138
|
+
lines.append("[dim]Subject[/dim]")
|
|
139
|
+
lines.append(f" {self._metadata.subject}")
|
|
140
|
+
|
|
141
|
+
# Dimensions
|
|
142
|
+
if self._metadata.width and self._metadata.height:
|
|
143
|
+
lines.append("")
|
|
144
|
+
lines.append("[dim]Dimensions[/dim]")
|
|
145
|
+
lines.append(f" {self._metadata.width} x {self._metadata.height}")
|
|
146
|
+
if self._metadata.recommended_screen:
|
|
147
|
+
lines.append(f" [green]Best for: {self._metadata.recommended_screen}[/green]")
|
|
148
|
+
|
|
149
|
+
# Source
|
|
150
|
+
if self._metadata.source:
|
|
151
|
+
lines.append("")
|
|
152
|
+
lines.append("[dim]Source[/dim]")
|
|
153
|
+
lines.append(f" [blue]{self._metadata.source}[/blue]")
|
|
154
|
+
|
|
155
|
+
# AI Model
|
|
156
|
+
if self._metadata.ai_model:
|
|
157
|
+
lines.append("")
|
|
158
|
+
lines.append("[dim]AI Model[/dim]")
|
|
159
|
+
lines.append(f" [dim]{self._metadata.ai_model}[/dim]")
|
|
160
|
+
|
|
161
|
+
content.update("\n".join(lines))
|
schenesort/xmp.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""XMP sidecar file handling for image metadata."""
|
|
2
|
+
|
|
3
|
+
import xml.etree.ElementTree as ET
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import defusedxml.ElementTree as DefusedET
|
|
8
|
+
|
|
9
|
+
# XML namespaces
|
|
10
|
+
NAMESPACES = {
|
|
11
|
+
"x": "adobe:ns:meta/",
|
|
12
|
+
"rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
|
|
13
|
+
"dc": "http://purl.org/dc/elements/1.1/",
|
|
14
|
+
"schenesort": "http://github.com/sthysel/schenesort/",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
# Register namespaces for writing
|
|
18
|
+
for prefix, uri in NAMESPACES.items():
|
|
19
|
+
ET.register_namespace(prefix, uri)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Common screen resolutions for recommendations
|
|
23
|
+
SCREEN_RESOLUTIONS = [
|
|
24
|
+
("8K", 7680, 4320),
|
|
25
|
+
("5K", 5120, 2880),
|
|
26
|
+
("4K", 3840, 2160),
|
|
27
|
+
("Ultrawide 4K", 5120, 2160),
|
|
28
|
+
("Ultrawide 1440p", 3440, 1440),
|
|
29
|
+
("1440p", 2560, 1440),
|
|
30
|
+
("Ultrawide 1080p", 2560, 1080),
|
|
31
|
+
("1080p", 1920, 1080),
|
|
32
|
+
("720p", 1280, 720),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_recommended_screen(width: int, height: int) -> str:
|
|
37
|
+
"""Determine the recommended screen size based on image dimensions."""
|
|
38
|
+
if width <= 0 or height <= 0:
|
|
39
|
+
return ""
|
|
40
|
+
|
|
41
|
+
# Find the largest screen this image can cover without upscaling
|
|
42
|
+
recommendations = []
|
|
43
|
+
for name, sw, sh in SCREEN_RESOLUTIONS:
|
|
44
|
+
if width >= sw and height >= sh:
|
|
45
|
+
recommendations.append(name)
|
|
46
|
+
|
|
47
|
+
if not recommendations:
|
|
48
|
+
return "below 720p"
|
|
49
|
+
|
|
50
|
+
# Return the best (first) match
|
|
51
|
+
return recommendations[0]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class ImageMetadata:
|
|
56
|
+
"""Metadata for a wallpaper image."""
|
|
57
|
+
|
|
58
|
+
description: str = ""
|
|
59
|
+
scene: str = "" # Fuller scene description
|
|
60
|
+
tags: list[str] = field(default_factory=list)
|
|
61
|
+
mood: list[str] = field(default_factory=list)
|
|
62
|
+
style: str = ""
|
|
63
|
+
colors: list[str] = field(default_factory=list)
|
|
64
|
+
time_of_day: str = ""
|
|
65
|
+
subject: str = ""
|
|
66
|
+
source: str = ""
|
|
67
|
+
ai_model: str = ""
|
|
68
|
+
width: int = 0
|
|
69
|
+
height: int = 0
|
|
70
|
+
recommended_screen: str = ""
|
|
71
|
+
|
|
72
|
+
def is_empty(self) -> bool:
|
|
73
|
+
return not any(
|
|
74
|
+
[
|
|
75
|
+
self.description,
|
|
76
|
+
self.scene,
|
|
77
|
+
self.tags,
|
|
78
|
+
self.mood,
|
|
79
|
+
self.style,
|
|
80
|
+
self.colors,
|
|
81
|
+
self.time_of_day,
|
|
82
|
+
self.subject,
|
|
83
|
+
self.source,
|
|
84
|
+
self.ai_model,
|
|
85
|
+
self.width,
|
|
86
|
+
self.height,
|
|
87
|
+
]
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_xmp_path(image_path: Path) -> Path:
|
|
92
|
+
"""Get the XMP sidecar path for an image."""
|
|
93
|
+
return image_path.parent / f"{image_path.name}.xmp"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def read_xmp(image_path: Path) -> ImageMetadata:
|
|
97
|
+
"""Read metadata from XMP sidecar file."""
|
|
98
|
+
xmp_path = get_xmp_path(image_path)
|
|
99
|
+
|
|
100
|
+
if not xmp_path.exists():
|
|
101
|
+
return ImageMetadata()
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
tree = DefusedET.parse(xmp_path)
|
|
105
|
+
root = tree.getroot()
|
|
106
|
+
|
|
107
|
+
metadata = ImageMetadata()
|
|
108
|
+
|
|
109
|
+
# Find the Description element
|
|
110
|
+
desc_elem = root.find(".//rdf:Description", NAMESPACES)
|
|
111
|
+
if desc_elem is None:
|
|
112
|
+
return metadata
|
|
113
|
+
|
|
114
|
+
# Read description
|
|
115
|
+
dc_desc = desc_elem.find("dc:description", NAMESPACES)
|
|
116
|
+
if dc_desc is not None:
|
|
117
|
+
# Handle both simple text and Alt/li structure
|
|
118
|
+
alt = dc_desc.find("rdf:Alt/rdf:li", NAMESPACES)
|
|
119
|
+
if alt is not None and alt.text:
|
|
120
|
+
metadata.description = alt.text
|
|
121
|
+
elif dc_desc.text:
|
|
122
|
+
metadata.description = dc_desc.text
|
|
123
|
+
|
|
124
|
+
# Read tags
|
|
125
|
+
dc_subject = desc_elem.find("dc:subject/rdf:Bag", NAMESPACES)
|
|
126
|
+
if dc_subject is not None:
|
|
127
|
+
for li in dc_subject.findall("rdf:li", NAMESPACES):
|
|
128
|
+
if li.text:
|
|
129
|
+
metadata.tags.append(li.text)
|
|
130
|
+
|
|
131
|
+
# Read source
|
|
132
|
+
dc_source = desc_elem.find("dc:source", NAMESPACES)
|
|
133
|
+
if dc_source is not None and dc_source.text:
|
|
134
|
+
metadata.source = dc_source.text
|
|
135
|
+
|
|
136
|
+
# Read AI model
|
|
137
|
+
ai_model = desc_elem.find("schenesort:ai_model", NAMESPACES)
|
|
138
|
+
if ai_model is not None and ai_model.text:
|
|
139
|
+
metadata.ai_model = ai_model.text
|
|
140
|
+
|
|
141
|
+
# Read mood
|
|
142
|
+
mood_elem = desc_elem.find("schenesort:mood/rdf:Bag", NAMESPACES)
|
|
143
|
+
if mood_elem is not None:
|
|
144
|
+
for li in mood_elem.findall("rdf:li", NAMESPACES):
|
|
145
|
+
if li.text:
|
|
146
|
+
metadata.mood.append(li.text)
|
|
147
|
+
|
|
148
|
+
# Read style
|
|
149
|
+
style_elem = desc_elem.find("schenesort:style", NAMESPACES)
|
|
150
|
+
if style_elem is not None and style_elem.text:
|
|
151
|
+
metadata.style = style_elem.text
|
|
152
|
+
|
|
153
|
+
# Read colors
|
|
154
|
+
colors_elem = desc_elem.find("schenesort:colors/rdf:Bag", NAMESPACES)
|
|
155
|
+
if colors_elem is not None:
|
|
156
|
+
for li in colors_elem.findall("rdf:li", NAMESPACES):
|
|
157
|
+
if li.text:
|
|
158
|
+
metadata.colors.append(li.text)
|
|
159
|
+
|
|
160
|
+
# Read time of day
|
|
161
|
+
time_elem = desc_elem.find("schenesort:time_of_day", NAMESPACES)
|
|
162
|
+
if time_elem is not None and time_elem.text:
|
|
163
|
+
metadata.time_of_day = time_elem.text
|
|
164
|
+
|
|
165
|
+
# Read subject
|
|
166
|
+
subject_elem = desc_elem.find("schenesort:subject", NAMESPACES)
|
|
167
|
+
if subject_elem is not None and subject_elem.text:
|
|
168
|
+
metadata.subject = subject_elem.text
|
|
169
|
+
|
|
170
|
+
# Read scene (full description)
|
|
171
|
+
scene_elem = desc_elem.find("schenesort:scene", NAMESPACES)
|
|
172
|
+
if scene_elem is not None and scene_elem.text:
|
|
173
|
+
metadata.scene = scene_elem.text
|
|
174
|
+
|
|
175
|
+
# Read dimensions
|
|
176
|
+
width_elem = desc_elem.find("schenesort:width", NAMESPACES)
|
|
177
|
+
if width_elem is not None and width_elem.text:
|
|
178
|
+
try:
|
|
179
|
+
metadata.width = int(width_elem.text)
|
|
180
|
+
except ValueError:
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
height_elem = desc_elem.find("schenesort:height", NAMESPACES)
|
|
184
|
+
if height_elem is not None and height_elem.text:
|
|
185
|
+
try:
|
|
186
|
+
metadata.height = int(height_elem.text)
|
|
187
|
+
except ValueError:
|
|
188
|
+
pass
|
|
189
|
+
|
|
190
|
+
# Read recommended screen
|
|
191
|
+
screen_elem = desc_elem.find("schenesort:recommended_screen", NAMESPACES)
|
|
192
|
+
if screen_elem is not None and screen_elem.text:
|
|
193
|
+
metadata.recommended_screen = screen_elem.text
|
|
194
|
+
|
|
195
|
+
return metadata
|
|
196
|
+
|
|
197
|
+
except Exception:
|
|
198
|
+
return ImageMetadata()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def write_xmp(image_path: Path, metadata: ImageMetadata) -> None:
|
|
202
|
+
"""Write metadata to XMP sidecar file."""
|
|
203
|
+
xmp_path = get_xmp_path(image_path)
|
|
204
|
+
|
|
205
|
+
# Build XMP structure
|
|
206
|
+
root = ET.Element(f"{{{NAMESPACES['x']}}}xmpmeta")
|
|
207
|
+
|
|
208
|
+
rdf = ET.SubElement(root, f"{{{NAMESPACES['rdf']}}}RDF")
|
|
209
|
+
desc = ET.SubElement(rdf, f"{{{NAMESPACES['rdf']}}}Description")
|
|
210
|
+
|
|
211
|
+
# Add description
|
|
212
|
+
if metadata.description:
|
|
213
|
+
dc_desc = ET.SubElement(desc, f"{{{NAMESPACES['dc']}}}description")
|
|
214
|
+
alt = ET.SubElement(dc_desc, f"{{{NAMESPACES['rdf']}}}Alt")
|
|
215
|
+
li = ET.SubElement(alt, f"{{{NAMESPACES['rdf']}}}li")
|
|
216
|
+
li.set(f"{{{NAMESPACES['rdf']}}}parseType", "Literal")
|
|
217
|
+
li.text = metadata.description
|
|
218
|
+
|
|
219
|
+
# Add tags
|
|
220
|
+
if metadata.tags:
|
|
221
|
+
dc_subject = ET.SubElement(desc, f"{{{NAMESPACES['dc']}}}subject")
|
|
222
|
+
bag = ET.SubElement(dc_subject, f"{{{NAMESPACES['rdf']}}}Bag")
|
|
223
|
+
for tag in metadata.tags:
|
|
224
|
+
li = ET.SubElement(bag, f"{{{NAMESPACES['rdf']}}}li")
|
|
225
|
+
li.text = tag
|
|
226
|
+
|
|
227
|
+
# Add source
|
|
228
|
+
if metadata.source:
|
|
229
|
+
dc_source = ET.SubElement(desc, f"{{{NAMESPACES['dc']}}}source")
|
|
230
|
+
dc_source.text = metadata.source
|
|
231
|
+
|
|
232
|
+
# Add AI model
|
|
233
|
+
if metadata.ai_model:
|
|
234
|
+
ai_model = ET.SubElement(desc, f"{{{NAMESPACES['schenesort']}}}ai_model")
|
|
235
|
+
ai_model.text = metadata.ai_model
|
|
236
|
+
|
|
237
|
+
# Add mood
|
|
238
|
+
if metadata.mood:
|
|
239
|
+
mood_elem = ET.SubElement(desc, f"{{{NAMESPACES['schenesort']}}}mood")
|
|
240
|
+
bag = ET.SubElement(mood_elem, f"{{{NAMESPACES['rdf']}}}Bag")
|
|
241
|
+
for mood in metadata.mood:
|
|
242
|
+
li = ET.SubElement(bag, f"{{{NAMESPACES['rdf']}}}li")
|
|
243
|
+
li.text = mood
|
|
244
|
+
|
|
245
|
+
# Add style
|
|
246
|
+
if metadata.style:
|
|
247
|
+
style_elem = ET.SubElement(desc, f"{{{NAMESPACES['schenesort']}}}style")
|
|
248
|
+
style_elem.text = metadata.style
|
|
249
|
+
|
|
250
|
+
# Add colors
|
|
251
|
+
if metadata.colors:
|
|
252
|
+
colors_elem = ET.SubElement(desc, f"{{{NAMESPACES['schenesort']}}}colors")
|
|
253
|
+
bag = ET.SubElement(colors_elem, f"{{{NAMESPACES['rdf']}}}Bag")
|
|
254
|
+
for color in metadata.colors:
|
|
255
|
+
li = ET.SubElement(bag, f"{{{NAMESPACES['rdf']}}}li")
|
|
256
|
+
li.text = color
|
|
257
|
+
|
|
258
|
+
# Add time of day
|
|
259
|
+
if metadata.time_of_day:
|
|
260
|
+
time_elem = ET.SubElement(desc, f"{{{NAMESPACES['schenesort']}}}time_of_day")
|
|
261
|
+
time_elem.text = metadata.time_of_day
|
|
262
|
+
|
|
263
|
+
# Add subject
|
|
264
|
+
if metadata.subject:
|
|
265
|
+
subject_elem = ET.SubElement(desc, f"{{{NAMESPACES['schenesort']}}}subject")
|
|
266
|
+
subject_elem.text = metadata.subject
|
|
267
|
+
|
|
268
|
+
# Add scene (full description)
|
|
269
|
+
if metadata.scene:
|
|
270
|
+
scene_elem = ET.SubElement(desc, f"{{{NAMESPACES['schenesort']}}}scene")
|
|
271
|
+
scene_elem.text = metadata.scene
|
|
272
|
+
|
|
273
|
+
# Add dimensions
|
|
274
|
+
if metadata.width > 0:
|
|
275
|
+
width_elem = ET.SubElement(desc, f"{{{NAMESPACES['schenesort']}}}width")
|
|
276
|
+
width_elem.text = str(metadata.width)
|
|
277
|
+
|
|
278
|
+
if metadata.height > 0:
|
|
279
|
+
height_elem = ET.SubElement(desc, f"{{{NAMESPACES['schenesort']}}}height")
|
|
280
|
+
height_elem.text = str(metadata.height)
|
|
281
|
+
|
|
282
|
+
# Add recommended screen
|
|
283
|
+
if metadata.recommended_screen:
|
|
284
|
+
screen_elem = ET.SubElement(desc, f"{{{NAMESPACES['schenesort']}}}recommended_screen")
|
|
285
|
+
screen_elem.text = metadata.recommended_screen
|
|
286
|
+
|
|
287
|
+
# Write to file
|
|
288
|
+
tree = ET.ElementTree(root)
|
|
289
|
+
ET.indent(tree, space=" ")
|
|
290
|
+
|
|
291
|
+
with open(xmp_path, "w", encoding="utf-8") as f:
|
|
292
|
+
f.write('<?xml version="1.0" encoding="UTF-8"?>\n')
|
|
293
|
+
tree.write(f, encoding="unicode")
|
|
294
|
+
f.write("\n")
|