PluginProbe
Extendify / 3.2.2
Extendify v3.2.2
3.2.2 3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 All 128 releases
extendify / src / Agent / workflows / abilities / components / run / imagify / OptimizeMedia.jsx

OptimizeMedia.jsx in Extendify 3.2.2, at src/Agent/workflows/abilities/components/run/imagify/OptimizeMedia.jsx

350 lines 10.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { doReload } from '@agent/lib/reload';
2 import executeAbility from '@agent/workflows/abilities/tools/execute-ability';
3 import apiFetch from '@wordpress/api-fetch';
4 import { useEffect, useState } from '@wordpress/element';
5 import { __, _n, sprintf } from '@wordpress/i18n';
6 import { addQueryArgs } from '@wordpress/url';
7
8 const ABILITY = 'imagify/optimize-media';
9 const STATUS_ABILITY = 'imagify/get-media-status';
10 const ACCOUNT_ABILITY = 'imagify/get-account';
11 const POLL_MS = 2000;
12 const GIVE_UP_MS = 60000;
13
14 // get-media-status rejects anything outside its own resolver keys.
15 const mediaInput = (inputs) => ({
16 ...(inputs?.media_id ? { media_id: inputs.media_id } : {}),
17 ...(inputs?.media_filename ? { media_filename: inputs.media_filename } : {}),
18 ...(inputs?.media_url ? { media_url: inputs.media_url } : {}),
19 });
20
21 const ask = async (ability, input) => {
22 try {
23 return await executeAbility({ ability, input });
24 } catch {
25 return null;
26 }
27 };
28
29 // Imagify translates its own guard messages only where it has language packs.
30 const blockedMessage = (status) => {
31 if (status === 'invalid_api_key')
32 return __('Imagify needs a valid API key.', 'extendify-local');
33 if (status === 'insufficient_quota')
34 return __('Your Imagify quota is used up.', 'extendify-local');
35 return null;
36 };
37
38 // Imagify files the real reason where no ability reads it back.
39 const failureReason = async () => {
40 const account = await ask(ACCOUNT_ABILITY, {});
41 if (!account) return null;
42 if (account.is_api_key_valid === false)
43 return blockedMessage('invalid_api_key');
44 const spent = Number(account.consumed_current_month_quota ?? 0);
45 const quota = Number(account.quota ?? 0);
46 const extra = Number(account.extra_quota ?? 0);
47 const extraSpent = Number(account.extra_quota_consumed ?? 0);
48 if (quota && spent >= quota && extraSpent >= extra)
49 return blockedMessage('insufficient_quota');
50 return null;
51 };
52
53 const usePreview = (inputs, enabled) => {
54 const [preview, setPreview] = useState(null);
55
56 useEffect(() => {
57 if (!enabled) return;
58 let live = true;
59 // Imagify runs nothing without confirm: true, so this only reports the cost.
60 executeAbility({ ability: ABILITY, input: { ...inputs, confirm: false } })
61 .then((result) => {
62 if (live) setPreview(result ?? {});
63 })
64 // Imagify still gates the run, so a failed cost check need not block it.
65 .catch(() => {
66 if (live) setPreview({});
67 });
68 return () => {
69 live = false;
70 };
71 }, [inputs, enabled]);
72
73 return preview;
74 };
75
76 // Asked by name, the model sends only media_filename, which WP matches on title.
77 const mediaPath = ({ media_id: id, media_filename: filename }) => {
78 if (id) return `/wp/v2/media/${id}`;
79 if (filename)
80 return addQueryArgs('/wp/v2/media', { search: filename, per_page: 1 });
81 return null;
82 };
83
84 // Each turn remounts the card, and a fresh lookup would blank the thumbnail.
85 const resolved = new Map();
86
87 const useImageUrl = (inputs) => {
88 // A turn re-sends inputs by value, so keying on the object never settles.
89 const path = mediaPath(inputs ?? {});
90 const [url, setUrl] = useState(
91 inputs?.media_url ?? resolved.get(path) ?? null,
92 );
93
94 useEffect(() => {
95 if (url || !path) return;
96 let live = true;
97 apiFetch({ path })
98 .then((media) => {
99 const found = Array.isArray(media) ? media[0] : media;
100 if (found?.source_url) resolved.set(path, found.source_url);
101 if (live) setUrl(found?.source_url ?? null);
102 })
103 .catch(() => {});
104 return () => {
105 live = false;
106 };
107 }, [path, url]);
108
109 return url;
110 };
111
112 // A remount cancels the check, so without this the status never settles.
113 const observed = new Map();
114
115 const IDLE = { size: null, replaced: false, checked: false, failure: null };
116
117 const seededState = (url, originalSize) => {
118 const length = observed.get(url);
119 if (!length) return IDLE;
120 return {
121 ...IDLE,
122 size: length === originalSize ? null : length,
123 checked: true,
124 };
125 };
126
127 // Imagify answers once the job is queued, so the file is the only proof it ran.
128 const useOptimizedSize = (url, originalSize, enabled, mediaKey) => {
129 const [state, setState] = useState(() => seededState(url, originalSize));
130 const [unwatchable, setUnwatchable] = useState(false);
131
132 useEffect(() => {
133 if (!enabled || !url || !originalSize) return;
134 let live = true;
135 let timer;
136 let seenOriginal = false;
137 const startedAt = Date.now();
138 const check = async () => {
139 const response = await fetch(`${url}?extendify=${Date.now()}`, {
140 method: 'HEAD',
141 cache: 'no-store',
142 }).catch(() => null);
143 const length = Number(response?.headers?.get('content-length'));
144 // A turn that cancels every check would otherwise record nothing.
145 if (length) observed.set(url, length);
146 if (!live) return;
147 // Only a change we watched may reload; a remount would reload forever.
148 if (length && length !== originalSize)
149 return setState({
150 ...IDLE,
151 size: length,
152 replaced: seenOriginal,
153 checked: true,
154 });
155 seenOriginal = seenOriginal || !!length;
156
157 // The run answered at queue time, so only this reports a later failure.
158 const status = await ask(STATUS_ABILITY, JSON.parse(mediaKey));
159 if (!live) return;
160 if (status?.status === 'error') {
161 const reason = status.error_message || (await failureReason());
162 if (!live) return;
163 return setState({ ...IDLE, checked: true, failure: reason || '' });
164 }
165 if (status?.status === 'success' && status.optimized_size)
166 return setState({
167 ...IDLE,
168 size: status.optimized_size,
169 replaced: seenOriginal,
170 checked: true,
171 });
172
173 setState((current) =>
174 current.checked ? current : { ...current, checked: true },
175 );
176 // A hidden length leaves only the status, and it did not answer either.
177 if (!length && status?.status !== 'unoptimized')
178 return setUnwatchable(true);
179 if (Date.now() - startedAt > GIVE_UP_MS) return setUnwatchable(true);
180 timer = setTimeout(check, POLL_MS);
181 };
182 // A reload of a finished run would otherwise claim to be working first.
183 check();
184 return () => {
185 live = false;
186 clearTimeout(timer);
187 };
188 }, [url, originalSize, enabled, mediaKey]);
189
190 return { ...state, unwatchable };
191 };
192
193 const savingText = (original, optimized) =>
194 sprintf(
195 // translators: %s is how much smaller the optimized image is, e.g. "42%".
196 __('Image optimized — %s smaller.', 'extendify-local'),
197 `${Math.round(((original - optimized) / original) * 100)}%`,
198 );
199
200 // Waiting on the lookup or on the decode would shift everything under it.
201 const Thumbnail = ({ url, optimizing }) => (
202 <div className="h-24 w-24 overflow-hidden rounded-lg bg-gray-100">
203 {url ? (
204 <img
205 src={url}
206 alt={__('The image being optimized', 'extendify-local')}
207 className={`m-0 h-full w-full object-cover transition-[filter] duration-500 ${optimizing ? 'blur-sm' : 'blur-none'}`}
208 />
209 ) : null}
210 </div>
211 );
212
213 const Status = ({ pending, failed, children }) => (
214 <p
215 className={`m-0 p-0 text-start text-sm ${failed ? 'text-wp-alert-red' : 'text-gray-900'}`}
216 >
217 {pending ? <span className="status-animation">{children}</span> : children}
218 </p>
219 );
220
221 const Cost = ({ preview }) => {
222 const count = preview?.impact?.count ?? 1;
223 const quota = preview?.quota_remaining;
224
225 return (
226 <>
227 <Status>
228 {sprintf(
229 // translators: %d is how many Imagify credits optimizing this image will use.
230 _n(
231 'Optimizing this image uses %d Imagify credit.',
232 'Optimizing this image uses %d Imagify credits.',
233 count,
234 'extendify-local',
235 ),
236 count,
237 )}
238 </Status>
239 {quota == null ? null : (
240 <p className="m-0 mt-1 p-0 text-start text-sm text-gray-700">
241 {sprintf(
242 // translators: %s is the percentage of the user's Imagify quota that is still unused, e.g. "88%".
243 __('%s of your quota remains.', 'extendify-local'),
244 `${Math.round(quota)}%`,
245 )}
246 </p>
247 )}
248 </>
249 );
250 };
251
252 export const OptimizeMedia = ({
253 inputs,
254 result,
255 onConfirm,
256 onCancel,
257 live,
258 }) => {
259 const gate = !!onConfirm && !result;
260 const preview = usePreview(inputs, gate);
261 const blocked = blockedMessage(preview?.status);
262 const url = useImageUrl(inputs);
263
264 const run = result?.[ABILITY] ?? {};
265 const refused = result ? blockedMessage(run.status) : null;
266 const failed = !!result && (result.error || run.status !== 'success');
267 // A reoptimize answers with its own figures, so it has nothing left to watch.
268 const queued = !!result && !refused && !failed && !run.optimized_size;
269 const { size, replaced, unwatchable, checked, failure } = useOptimizedSize(
270 url,
271 run.original_size,
272 queued && live,
273 JSON.stringify(mediaInput(inputs)),
274 );
275
276 useEffect(() => {
277 if (replaced) doReload();
278 }, [replaced]);
279
280 const watching =
281 queued && live && checked && !unwatchable && !size && failure === null;
282 const checking = queued && live && !checked && !unwatchable;
283 const optimizing = watching || (!result && !gate);
284 const pending = optimizing || checking || (gate && !preview);
285
286 const wrong = !!refused || failed || failure !== null;
287
288 const statusText = () => {
289 if (refused) return refused;
290 if (failed || failure !== null)
291 return (
292 failure || __("The image couldn't be optimized.", 'extendify-local')
293 );
294 if (run.optimized_size)
295 return savingText(run.original_size, run.optimized_size);
296 if (size) return savingText(run.original_size, size);
297 if (!result)
298 return gate
299 ? preview
300 ? blocked
301 : __('Checking your Imagify quota...', 'extendify-local')
302 : __('Optimizing image...', 'extendify-local');
303 if (watching) return __('Optimizing image...', 'extendify-local');
304 // The run may already be finished, so this must not say it is optimizing.
305 if (checking) return __('Checking status...', 'extendify-local');
306 return __(
307 'Imagify is optimizing this image in the background.',
308 'extendify-local',
309 );
310 };
311 const message = statusText();
312
313 return (
314 <div className="mb-4 ms-2 me-2 flex flex-col gap-2">
315 <Thumbnail url={url} optimizing={optimizing} />
316 {message ? (
317 <Status pending={pending} failed={wrong}>
318 {message}
319 </Status>
320 ) : null}
321 {gate && preview && !blocked ? <Cost preview={preview} /> : null}
322 {gate && preview ? (
323 <div className="flex justify-start gap-2">
324 <button
325 type="button"
326 className="w-full rounded-sm border border-gray-500 bg-white p-2 text-sm text-gray-900"
327 onClick={onCancel}
328 >
329 {__('Cancel', 'extendify-local')}
330 </button>
331 {blocked ? null : (
332 <button
333 type="button"
334 className="w-full rounded-sm border border-design-main bg-design-main p-2 text-sm text-white"
335 onClick={() =>
336 onConfirm({
337 data: { ...inputs, confirm: true },
338 shouldRefreshPage: false,
339 })
340 }
341 >
342 {__('Optimize image', 'extendify-local')}
343 </button>
344 )}
345 </div>
346 ) : null}
347 </div>
348 );
349 };
350