PluginProbe
Extendify / 3.2.3
Extendify v3.2.3
3.2.3 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 All 129 releases
extendify / src / Agent / workflows / abilities / components / run / imagify / OptimizeMedia.jsx

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

354 lines 10.8 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 let seenOtherStatus = false;
138 const startedAt = Date.now();
139 const check = async () => {
140 const response = await fetch(`${url}?extendify=${Date.now()}`, {
141 method: 'HEAD',
142 cache: 'no-store',
143 }).catch(() => null);
144 const length = Number(response?.headers?.get('content-length'));
145 // A turn that cancels every check would otherwise record nothing.
146 if (length) observed.set(url, length);
147 if (!live) return;
148 // Only a change we watched may reload; a remount would reload forever.
149 if (length && length !== originalSize)
150 return setState({
151 ...IDLE,
152 size: length,
153 replaced: seenOriginal,
154 checked: true,
155 });
156 seenOriginal = seenOriginal || !!length;
157
158 // The run answered at queue time, so only this reports a later failure.
159 const status = await ask(STATUS_ABILITY, JSON.parse(mediaKey));
160 if (!live) return;
161 const expired = Date.now() - startedAt > GIVE_UP_MS;
162 // Imagify keeps an earlier failure until the queued job overwrites it.
163 if (status?.status === 'error' && (seenOtherStatus || expired)) {
164 const reason = status.error_message || (await failureReason());
165 if (!live) return;
166 return setState({ ...IDLE, checked: true, failure: reason || '' });
167 }
168 if (status?.status && status.status !== 'error') seenOtherStatus = true;
169 if (status?.status === 'success' && status.optimized_size)
170 return setState({
171 ...IDLE,
172 size: status.optimized_size,
173 replaced: seenOriginal,
174 checked: true,
175 });
176
177 setState((current) =>
178 current.checked ? current : { ...current, checked: true },
179 );
180 // A hidden length leaves only the status, and it did not answer either.
181 if (!length && status?.status !== 'unoptimized')
182 return setUnwatchable(true);
183 if (expired) return setUnwatchable(true);
184 timer = setTimeout(check, POLL_MS);
185 };
186 // A reload of a finished run would otherwise claim to be working first.
187 check();
188 return () => {
189 live = false;
190 clearTimeout(timer);
191 };
192 }, [url, originalSize, enabled, mediaKey]);
193
194 return { ...state, unwatchable };
195 };
196
197 const savingText = (original, optimized) =>
198 sprintf(
199 // translators: %s is how much smaller the optimized image is, e.g. "42%".
200 __('Image optimized — %s smaller.', 'extendify-local'),
201 `${Math.round(((original - optimized) / original) * 100)}%`,
202 );
203
204 // Waiting on the lookup or on the decode would shift everything under it.
205 const Thumbnail = ({ url, optimizing }) => (
206 <div className="h-24 w-24 overflow-hidden rounded-lg bg-gray-100">
207 {url ? (
208 <img
209 src={url}
210 alt={__('The image being optimized', 'extendify-local')}
211 className={`m-0 h-full w-full object-cover transition-[filter] duration-500 ${optimizing ? 'blur-sm' : 'blur-none'}`}
212 />
213 ) : null}
214 </div>
215 );
216
217 const Status = ({ pending, failed, children }) => (
218 <p
219 className={`m-0 p-0 text-start text-sm ${failed ? 'text-wp-alert-red' : 'text-gray-900'}`}
220 >
221 {pending ? <span className="status-animation">{children}</span> : children}
222 </p>
223 );
224
225 const Cost = ({ preview }) => {
226 const count = preview?.impact?.count ?? 1;
227 const quota = preview?.quota_remaining;
228
229 return (
230 <>
231 <Status>
232 {sprintf(
233 // translators: %d is how many Imagify credits optimizing this image will use.
234 _n(
235 'Optimizing this image uses %d Imagify credit.',
236 'Optimizing this image uses %d Imagify credits.',
237 count,
238 'extendify-local',
239 ),
240 count,
241 )}
242 </Status>
243 {quota == null ? null : (
244 <p className="m-0 mt-1 p-0 text-start text-sm text-gray-700">
245 {sprintf(
246 // translators: %s is the percentage of the user's Imagify quota that is still unused, e.g. "88%".
247 __('%s of your quota remains.', 'extendify-local'),
248 `${Math.round(quota)}%`,
249 )}
250 </p>
251 )}
252 </>
253 );
254 };
255
256 export const OptimizeMedia = ({
257 inputs,
258 result,
259 onConfirm,
260 onCancel,
261 live,
262 }) => {
263 const gate = !!onConfirm && !result;
264 const preview = usePreview(inputs, gate);
265 const blocked = blockedMessage(preview?.status);
266 const url = useImageUrl(inputs);
267
268 const run = result?.[ABILITY] ?? {};
269 const refused = result ? blockedMessage(run.status) : null;
270 const failed = !!result && (result.error || run.status !== 'success');
271 // A reoptimize answers with its own figures, so it has nothing left to watch.
272 const queued = !!result && !refused && !failed && !run.optimized_size;
273 const { size, replaced, unwatchable, checked, failure } = useOptimizedSize(
274 url,
275 run.original_size,
276 queued && live,
277 JSON.stringify(mediaInput(inputs)),
278 );
279
280 useEffect(() => {
281 if (replaced) doReload();
282 }, [replaced]);
283
284 const watching =
285 queued && live && checked && !unwatchable && !size && failure === null;
286 const checking = queued && live && !checked && !unwatchable;
287 const optimizing = watching || (!result && !gate);
288 const pending = optimizing || checking || (gate && !preview);
289
290 const wrong = !!refused || failed || failure !== null;
291
292 const statusText = () => {
293 if (refused) return refused;
294 if (failed || failure !== null)
295 return (
296 failure || __("The image couldn't be optimized.", 'extendify-local')
297 );
298 if (run.optimized_size)
299 return savingText(run.original_size, run.optimized_size);
300 if (size) return savingText(run.original_size, size);
301 if (!result)
302 return gate
303 ? preview
304 ? blocked
305 : __('Checking your Imagify quota...', 'extendify-local')
306 : __('Optimizing image...', 'extendify-local');
307 if (watching) return __('Optimizing image...', 'extendify-local');
308 // The run may already be finished, so this must not say it is optimizing.
309 if (checking) return __('Checking status...', 'extendify-local');
310 return __(
311 'Imagify is optimizing this image in the background.',
312 'extendify-local',
313 );
314 };
315 const message = statusText();
316
317 return (
318 <div className="mb-4 ms-2 me-2 flex flex-col gap-2">
319 <Thumbnail url={url} optimizing={optimizing} />
320 {message ? (
321 <Status pending={pending} failed={wrong}>
322 {message}
323 </Status>
324 ) : null}
325 {gate && preview && !blocked ? <Cost preview={preview} /> : null}
326 {gate && preview ? (
327 <div className="flex justify-start gap-2">
328 <button
329 type="button"
330 className="w-full rounded-sm border border-gray-500 bg-white p-2 text-sm text-gray-900"
331 onClick={onCancel}
332 >
333 {__('Cancel', 'extendify-local')}
334 </button>
335 {blocked ? null : (
336 <button
337 type="button"
338 className="w-full rounded-sm border border-design-main bg-design-main p-2 text-sm text-white"
339 onClick={() =>
340 onConfirm({
341 data: { ...inputs, confirm: true },
342 shouldRefreshPage: false,
343 })
344 }
345 >
346 {__('Optimize image', 'extendify-local')}
347 </button>
348 )}
349 </div>
350 ) : null}
351 </div>
352 );
353 };
354