This commit is contained in:
+66
-29
@@ -65,43 +65,48 @@ User query: ` + query,
|
||||
},
|
||||
}
|
||||
|
||||
rawBody, _ := json.Marshal(body)
|
||||
endpoint := "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=" + g.APIKey
|
||||
resp, err := g.Client.Post(endpoint, "application/json", bytes.NewReader(rawBody))
|
||||
rawText, err := g.generateText(body)
|
||||
if err != nil {
|
||||
return []string{query}, fmt.Errorf("gemini query expansion request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return []string{query}, fmt.Errorf("gemini query expansion returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
|
||||
return []string{query}, err
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Candidates []struct {
|
||||
Content struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"parts"`
|
||||
} `json:"content"`
|
||||
} `json:"candidates"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return []string{query}, fmt.Errorf("gemini query expansion response decode failed: %w", err)
|
||||
}
|
||||
if len(payload.Candidates) == 0 || len(payload.Candidates[0].Content.Parts) == 0 {
|
||||
return []string{query}, fmt.Errorf("gemini query expansion returned no candidates")
|
||||
}
|
||||
|
||||
jsonText, err := extractJSONObject(payload.Candidates[0].Content.Parts[0].Text)
|
||||
jsonText, err := extractJSONObject(rawText)
|
||||
if err != nil {
|
||||
return []string{query}, fmt.Errorf("gemini query expansion JSON extraction failed: %w", err)
|
||||
strictBody := map[string]any{
|
||||
"contents": []map[string]any{
|
||||
{
|
||||
"parts": []map[string]string{
|
||||
{
|
||||
"text": `STRICT JSON ONLY.
|
||||
Output must start with { and end with }.
|
||||
Do not add prose, explanations, markdown, code fences, or labels.
|
||||
Return exactly this shape: {"querywords":["..."]}.
|
||||
Generate up to 10 search queries for media discovery across Google Video, Envato, and Artgrid.
|
||||
If the original query is Korean, include strong English stock-footage search phrases.
|
||||
User query: ` + query,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"generationConfig": map[string]any{
|
||||
"responseMimeType": "application/json",
|
||||
"temperature": 0.1,
|
||||
"maxOutputTokens": 220,
|
||||
},
|
||||
}
|
||||
rawText, retryErr := g.generateText(strictBody)
|
||||
if retryErr != nil {
|
||||
return []string{query}, retryErr
|
||||
}
|
||||
jsonText, err = extractJSONObject(rawText)
|
||||
if err != nil {
|
||||
return []string{query}, fmt.Errorf("gemini query expansion JSON extraction failed after strict retry: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var parsed QueryExpansion
|
||||
if err := json.Unmarshal([]byte(jsonText), &parsed); err != nil {
|
||||
return []string{query}, fmt.Errorf("gemini query expansion JSON parse failed: %w; raw=%q", err, truncateForError(payload.Candidates[0].Content.Parts[0].Text, 200))
|
||||
return []string{query}, fmt.Errorf("gemini query expansion JSON parse failed: %w; raw=%q", err, truncateForError(rawText, 200))
|
||||
}
|
||||
|
||||
queries := []string{query}
|
||||
@@ -121,6 +126,38 @@ User query: ` + query,
|
||||
return queries, nil
|
||||
}
|
||||
|
||||
func (g *GeminiService) generateText(body map[string]any) (string, error) {
|
||||
rawBody, _ := json.Marshal(body)
|
||||
endpoint := "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=" + g.APIKey
|
||||
resp, err := g.Client.Post(endpoint, "application/json", bytes.NewReader(rawBody))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("gemini request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return "", fmt.Errorf("gemini returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Candidates []struct {
|
||||
Content struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"parts"`
|
||||
} `json:"content"`
|
||||
} `json:"candidates"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return "", fmt.Errorf("gemini response decode failed: %w", err)
|
||||
}
|
||||
if len(payload.Candidates) == 0 || len(payload.Candidates[0].Content.Parts) == 0 {
|
||||
return "", fmt.Errorf("gemini returned no candidates")
|
||||
}
|
||||
return payload.Candidates[0].Content.Parts[0].Text, nil
|
||||
}
|
||||
|
||||
func (g *GeminiService) Recommend(query string, candidates []SearchResult) ([]AIRecommendation, error) {
|
||||
if g.APIKey == "" {
|
||||
return nil, fmt.Errorf("gemini api key is not configured")
|
||||
|
||||
+58
-20
@@ -21,13 +21,20 @@ const previewDuration = document.getElementById("previewDuration");
|
||||
const qualitySelect = document.getElementById("qualitySelect");
|
||||
const confirmDownload = document.getElementById("confirmDownload");
|
||||
const closePreviewModal = document.getElementById("closePreviewModal");
|
||||
const startRange = document.getElementById("startRange");
|
||||
const endRange = document.getElementById("endRange");
|
||||
const rangeSummary = document.getElementById("rangeSummary");
|
||||
const rangeFill = document.getElementById("rangeFill");
|
||||
const startThumb = document.getElementById("startThumb");
|
||||
const endThumb = document.getElementById("endThumb");
|
||||
const startLabel = document.getElementById("startLabel");
|
||||
const endLabel = document.getElementById("endLabel");
|
||||
const setStartFromPreview = document.getElementById("setStartFromPreview");
|
||||
const setEndFromPreview = document.getElementById("setEndFromPreview");
|
||||
|
||||
let pendingDownload = null;
|
||||
let cropStart = 0;
|
||||
let cropEnd = 0;
|
||||
let cropMax = 0;
|
||||
let activeThumb = null;
|
||||
|
||||
function setStatus(label, progress) {
|
||||
statusLabel.textContent = label;
|
||||
@@ -43,18 +50,26 @@ function toClock(totalSeconds) {
|
||||
}
|
||||
|
||||
function syncRanges() {
|
||||
let start = Number(startRange.value || 0);
|
||||
let end = Number(endRange.value || 0);
|
||||
let start = cropStart;
|
||||
let end = cropEnd;
|
||||
if (start > end) {
|
||||
if (document.activeElement === startRange) {
|
||||
if (activeThumb === "start") {
|
||||
end = start;
|
||||
endRange.value = String(end);
|
||||
} else {
|
||||
start = end;
|
||||
startRange.value = String(start);
|
||||
}
|
||||
}
|
||||
cropStart = start;
|
||||
cropEnd = end;
|
||||
const startPct = cropMax > 0 ? (cropStart / cropMax) * 100 : 0;
|
||||
const endPct = cropMax > 0 ? (cropEnd / cropMax) * 100 : 0;
|
||||
startThumb.style.left = `calc(${startPct}% - 10px)`;
|
||||
endThumb.style.left = `calc(${endPct}% - 10px)`;
|
||||
rangeFill.style.left = `${startPct}%`;
|
||||
rangeFill.style.width = `${Math.max(0, endPct - startPct)}%`;
|
||||
rangeSummary.textContent = `${toClock(start)} - ${toClock(end)}`;
|
||||
startLabel.textContent = `Start ${toClock(start)}`;
|
||||
endLabel.textContent = `End ${toClock(end)}`;
|
||||
}
|
||||
|
||||
function renderQueryVariants(queries = []) {
|
||||
@@ -188,11 +203,9 @@ function openPreviewModal(preview) {
|
||||
option.textContent = item.label;
|
||||
qualitySelect.appendChild(option);
|
||||
}
|
||||
const maxDuration = Number(preview.durationSeconds || 0);
|
||||
startRange.max = String(maxDuration);
|
||||
endRange.max = String(maxDuration);
|
||||
startRange.value = "0";
|
||||
endRange.value = String(maxDuration);
|
||||
cropMax = Number(preview.durationSeconds || 0);
|
||||
cropStart = 0;
|
||||
cropEnd = cropMax;
|
||||
syncRanges();
|
||||
previewModal.classList.remove("hidden");
|
||||
previewModal.classList.add("flex");
|
||||
@@ -205,8 +218,9 @@ function closeModal() {
|
||||
previewMediaFrame.style.aspectRatio = "";
|
||||
previewModal.classList.add("hidden");
|
||||
previewModal.classList.remove("flex");
|
||||
startRange.value = "0";
|
||||
endRange.value = "0";
|
||||
cropStart = 0;
|
||||
cropEnd = 0;
|
||||
cropMax = 0;
|
||||
syncRanges();
|
||||
pendingDownload = null;
|
||||
}
|
||||
@@ -273,8 +287,8 @@ confirmDownload.addEventListener("click", async () => {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
url: pendingDownload.url,
|
||||
start: toClock(startRange.value),
|
||||
end: toClock(endRange.value),
|
||||
start: toClock(cropStart),
|
||||
end: toClock(cropEnd),
|
||||
quality: qualitySelect.value,
|
||||
force: pendingDownload.force,
|
||||
}),
|
||||
@@ -292,16 +306,40 @@ previewModal.addEventListener("click", (event) => {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
startRange.addEventListener("input", syncRanges);
|
||||
endRange.addEventListener("input", syncRanges);
|
||||
setStartFromPreview.addEventListener("click", () => {
|
||||
startRange.value = String(Math.floor(previewVideo.currentTime || 0));
|
||||
cropStart = Math.floor(previewVideo.currentTime || 0);
|
||||
activeThumb = "start";
|
||||
syncRanges();
|
||||
});
|
||||
setEndFromPreview.addEventListener("click", () => {
|
||||
endRange.value = String(Math.floor(previewVideo.currentTime || 0));
|
||||
cropEnd = Math.floor(previewVideo.currentTime || 0);
|
||||
activeThumb = "end";
|
||||
syncRanges();
|
||||
});
|
||||
for (const [thumb, name] of [[startThumb, "start"], [endThumb, "end"]]) {
|
||||
thumb.addEventListener("pointerdown", (event) => {
|
||||
event.preventDefault();
|
||||
activeThumb = name;
|
||||
thumb.setPointerCapture(event.pointerId);
|
||||
});
|
||||
thumb.addEventListener("pointermove", (event) => {
|
||||
if (activeThumb !== name || cropMax <= 0) {
|
||||
return;
|
||||
}
|
||||
const track = thumb.parentElement.getBoundingClientRect();
|
||||
const ratio = Math.max(0, Math.min(1, (event.clientX - track.left) / track.width));
|
||||
const value = Math.round(ratio * cropMax);
|
||||
if (name === "start") {
|
||||
cropStart = value;
|
||||
} else {
|
||||
cropEnd = value;
|
||||
}
|
||||
syncRanges();
|
||||
});
|
||||
thumb.addEventListener("pointerup", () => {
|
||||
activeThumb = null;
|
||||
});
|
||||
}
|
||||
previewVideo.addEventListener("loadedmetadata", () => {
|
||||
if (previewVideo.videoWidth > 0 && previewVideo.videoHeight > 0) {
|
||||
previewMediaFrame.style.aspectRatio = `${previewVideo.videoWidth} / ${previewVideo.videoHeight}`;
|
||||
|
||||
+10
-8
@@ -96,14 +96,16 @@
|
||||
<span id="rangeSummary">00:00:00 - 00:00:00</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<label class="block space-y-2">
|
||||
<span class="text-xs uppercase tracking-[0.2em] text-zinc-500">Start</span>
|
||||
<input id="startRange" type="range" min="0" max="0" value="0" step="1" class="slider-thumb w-full appearance-none bg-transparent" />
|
||||
</label>
|
||||
<label class="block space-y-2">
|
||||
<span class="text-xs uppercase tracking-[0.2em] text-zinc-500">End</span>
|
||||
<input id="endRange" type="range" min="0" max="0" value="0" step="1" class="slider-thumb w-full appearance-none bg-transparent" />
|
||||
</label>
|
||||
<div class="dual-slider relative h-10">
|
||||
<div class="dual-slider__track absolute left-0 right-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-white/15"></div>
|
||||
<div id="rangeFill" class="dual-slider__fill absolute top-1/2 h-1 -translate-y-1/2 rounded-full bg-white"></div>
|
||||
<button id="startThumb" type="button" class="dual-slider__thumb absolute top-1/2 h-5 w-5 -translate-y-1/2 rounded-full border-2 border-zinc-950 bg-white"></button>
|
||||
<button id="endThumb" type="button" class="dual-slider__thumb absolute top-1/2 h-5 w-5 -translate-y-1/2 rounded-full border-2 border-zinc-950 bg-white"></button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs uppercase tracking-[0.2em] text-zinc-500">
|
||||
<span id="startLabel">Start 00:00:00</span>
|
||||
<span id="endLabel">End 00:00:00</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex gap-3">
|
||||
<button id="setStartFromPreview" type="button" class="flex-1 rounded-2xl border border-white/10 px-4 py-3 text-sm text-zinc-200 transition hover:border-white/30">Set Start</button>
|
||||
|
||||
+4
-30
@@ -26,34 +26,8 @@ body {
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.slider-thumb::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid #09090b;
|
||||
background: #fafafa;
|
||||
cursor: pointer;
|
||||
margin-top: -7px;
|
||||
}
|
||||
|
||||
.slider-thumb::-moz-range-thumb {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid #09090b;
|
||||
background: #fafafa;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slider-thumb::-webkit-slider-runnable-track {
|
||||
height: 4px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.slider-thumb::-moz-range-track {
|
||||
height: 4px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
.dual-slider__thumb {
|
||||
touch-action: none;
|
||||
cursor: ew-resize;
|
||||
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user