1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
.Dd September 5, 2026
.Dt MASTODON-ALT-TEXT 1
.Os
.Sh NAME
.Nm mastodon-alt-text
.Nd generate Mastodon alt text for the image in the clipboard
.Sh SYNOPSIS
.Nm
.Op Fl addr Ar host:port
.Op Fl browser Ar bin
.Nm
.Cm serve
.Op Fl addr Ar host:port
.Op Fl idle Ar duration
.Sh DESCRIPTION
.Nm
takes the image on the X11
.Dv CLIPBOARD
selection, asks Gemini 2.5 Flash for three alt texts, and opens a browser
window to pick between them.
The chosen text is copied to both the
.Dv PRIMARY
and
.Dv CLIPBOARD
selections, ready to paste into Mastodon's alt text field.
Any of them can then be refined by saying what should change, which produces
three new ones to choose from, or the prompt they were all written to can be
edited and the three generated again.
.Pp
Three are generated rather than one because judging alt text is a matter of
taste: whether to name what a thing is or describe how it looks, how much of
the background matters, whether text in the image should be quoted.
Picking from a few is faster than re-running until one is good, and saying
what is wrong with the closest one is faster still.
.Pp
Invoked bare \(en from a keybinding \(en it restarts the user service and
points a browser at it.
The work happens in the service; see
.Sx SERVICE .
.Ss Options
.Bl -tag -width Ds
.It Fl addr Ar host:port
Address to bind
.Pq Cm serve
or to open
.Pq the launcher .
By default
.Cm serve
takes an ephemeral port and the launcher reads whichever one it got, so
neither has a port number built in.
.It Fl browser Ar bin
Browser to open the picker with, resolved on
.Ev PATH .
Default
.Ic chromium .
.It Fl idle Ar duration
.Pq Cm serve No only
Exit once no page has been connected for this long.
Default
.Ar 60s .
.El
.Sh THE PICKER
The page shows the image and three cards, which fill in as the generations
land rather than all at once at the end.
Each card gives its length, and says so if it runs over Mastodon's 1500
character limit.
.Pp
Clicking a card copies it.
Picking again just overwrites the selections, so it costs nothing to change
your mind.
A generation that fails is shown as an error on its own card and does not
disturb the other two.
.Pp
Nothing closes the window: you close it when you are done, and the server
notices the browser has gone and exits by itself.
.Ss The prompt
Under the image is the prompt all three were written to, prefilled with the
default and editable.
Changing it and pressing Regenerate \(en or
.Ic Ctrl-Enter ,
or simply leaving the field after an edit \(en produces three new texts.
.Pp
This is the whole prompt rather than an extra line appended to a hidden one,
so the default's clauses can be contradicted rather than only added to:
asking for a different language, or for more than 1500 characters, works
because the sentence that said otherwise is right there to be edited.
The other side of that is that the default's three clauses are load-bearing.
.Dq Output only the alt text
is what suppresses the
.Dq Here is the alt text:
preamble, and deleting it brings the preamble back.
.Ic reset to default ,
which appears once the text differs, puts the original back without
generating anything.
.Pp
Regenerate is enabled whether or not the prompt was edited, so it doubles as
a reroll: the three differ by sampling alone
.Pq see Sx CAVEATS ,
so asking the same question again is a reasonable move when all three were
bad rather than the brief being wrong.
An empty prompt is refused instead of being quietly replaced by the default,
which would leave the field disagreeing with what was sent.
.Pp
Regenerating starts a new conversation: the image and the new prompt, and
nothing else.
Refinements made before it are discarded rather than carried over \(en see
.Sx Refining
for why that is the useful behaviour rather than a limitation.
.Ss Refining
If none of the three is quite right, pick the closest one, type what should
change \(en
.Dq shorter ,
.Dq do not mention the background ,
.Dq name the species
\(en and press Refine.
Three fresh takes on that instruction replace the cards.
This can be repeated as often as you like.
.Pp
Refining works from the variant you picked, so a pick is required first.
That is also why nothing is lost when the cards are replaced: the text a
round started from was copied to the clipboard when it was chosen, and it
stays there until you pick again.
.Pp
Instructions accumulate rather than replace one another.
A round that asked for a shorter text is still in force when a later round
asks for something else, so constraints can be built up one at a time
instead of having to be described all at once.
The image is part of the conversation throughout, so each round still
describes the picture rather than paraphrasing the previous description.
.Pp
Only the chosen variant is remembered; the two you did not pick are
discarded and never shown to the model.
Rounds run one at a time, and both Refine and Regenerate are disabled while
one is generating.
.Pp
Refining and regenerating are opposites, and which one to reach for follows
from that.
Refining is a conversation: it needs a pick, it accumulates, and it drifts.
Regenerating throws the conversation away and asks the original question
differently: it needs no pick, works before anything has been chosen, and
cannot drift because there is no history left to drift through.
Use Refine to adjust a text that is nearly right; change the prompt when all
three are answering the wrong question.
.Sh SERVICE
The server runs as the systemd user unit
.Pa mastodon-alt-text.service ,
which the launcher
.Em restarts
on every invocation.
Restart rather than start, because
.Ic systemctl start
on an already-running unit does nothing, and each run has to re-read the
clipboard.
.Pp
The server binds an ephemeral port rather than a fixed one.
A single browser window on loopback has no need of a well-known number, and
claiming one only creates a collision with whatever else happens to want it.
The port that was assigned is written to
.Pa $XDG_RUNTIME_DIR/mastodon-alt-text.port ,
which the launcher reads.
.Pp
The unit is
.Ic Type=notify ,
so
.Ic systemctl restart
returns only once the port is accepting connections and has been published.
The browser is spawned after that returns, which is what removes the need for
it to retry against a server that is not up yet, and what makes the port file
certain to be the current run's rather than a leftover.
.Pp
There is no
.Ic [Install]
section.
The service is only ever started on demand; enabling it would start a server
with nothing to caption.
.Ss Idle shutdown
An open page holds a server-sent events stream, and that stream is the
liveness signal.
While one is connected the server stays up; when the last one goes the
process exits after the idle timeout, or immediately reconnects if the
browser was only reloading.
The timeout also runs from startup, so a browser that never manages to
connect does not leave a server behind.
.Pp
The timeout is measured on a monotonic clock, which does not advance while the
machine is suspended.
Closing the lid on a closed picker therefore does not expire the server
mid-sleep; it exits a minute after the machine wakes.
.Sh IMPLEMENTATION NOTES
Images are converted to WebP before upload, which for a typical PNG
screenshot is a twentyfold reduction \(en 408 KB to 18 KB in one measurement
\(en and upload dominates the request.
.Pp
The generations run concurrently, so three cost about as long as the slowest
one rather than their sum: around two seconds in total.
Model thinking is disabled, as it adds seconds and buys nothing for
captioning an image that is already in the request.
.Pp
The unit sets
.Ic KillMode=process ,
which is required rather than hardening.
An X11 selection is not stored anywhere: it is owned by a live process that
hands over the data when another client asks for it, which is why
.Xr xclip 1
stays resident after writing one.
Under the default
.Ic KillMode=control-group
systemd kills that process along with the server, and the text quietly
disappears from the clipboard about a minute after it was copied.
.Pp
Reading the image consults the
.Dv TARGETS
selection first.
Asking
.Xr xclip 1
for
.Ic image/png
when the clipboard holds text does not fail \(en it returns the text, which
then reaches
.Xr magick 1
and surfaces as
.Qq no decode delegate for this image format
rather than saying there is no image.
.Sh ENVIRONMENT
.Bl -tag -width Ds
.It Ev GEMINI_API_KEY
API key for Gemini.
If unset, the key is read from
.Xr pass 1
at
.Pa internet/ai.google.dev/gemini/api-keys/gemini-2.5-flash .
Under systemd that needs
.Ev GNUPGHOME
set so
.Xr gpg-agent 1
can be reached; the shipped unit does this.
.El
.Sh FILES
.Bl -tag -width Ds
.It Pa ~/.config/systemd/user/mastodon-alt-text.service
The user unit, copied from the source tree.
.It Pa $XDG_RUNTIME_DIR/mastodon-alt-text.port
The address the running server bound, written before it reports readiness and
removed when it exits.
Falls back to
.Pa /tmp
if the variable is unset.
.El
.Sh EXIT STATUS
.Ex -std
.Pp
Failures that happen after the server is up \(en an empty clipboard, a
rejected API key \(en are reported in the browser window instead, since that
is where you are looking.
The service still exits successfully once it goes idle.
.Sh EXAMPLES
Take a screenshot, then caption it:
.Pp
.Dl $ import png:- | xclip -selection clipboard -t image/png
.Dl $ mastodon-alt-text
.Pp
Use a different browser:
.Pp
.Dl $ mastodon-alt-text -browser firefox
.Pp
Run the server in the foreground, without systemd, to see what it is doing:
.Pp
.Dl $ mastodon-alt-text serve -addr 127.0.0.1:9999 -idle 5m
.Sh SEE ALSO
.Xr magick 1 ,
.Xr pass 1 ,
.Xr systemctl 1 ,
.Xr xclip 1
.Sh AUTHORS
.An Profpatsch
.Sh CAVEATS
The launcher passes
.Fl -app
to the browser and does not wait for it.
With a browser already running, the URL is handed to the existing process and
the launcher returns at once, so a window that fails to open is not reported:
the symptom is a service that starts and then quietly goes away a minute
later.
.Pp
Alt text is generated from an image alone, with no idea of the post it
belongs to, so it describes rather than explains.
For a screenshot of a conversation or a chart, expect to edit the result.
.Pp
The three generations differ only in sampling temperature, raised above the
default so they do not come back nearly identical.
They are all written to the one prompt, so all three can share a misreading
of the image, and no amount of rerolling will shake it \(en that is what
editing the prompt is for.
.Pp
Refinements chain, and a long chain drifts.
Each round works from the previous wording as much as from the picture, so
by the fourth or fifth a detail that was only ever visible in the image can
quietly disappear.
When that happens it is quicker to start over than to refine back: press
Regenerate, which drops the accumulated conversation and goes back to the
image.
The last text you picked is on the clipboard either way.