This commit is contained in:
@@ -348,7 +348,9 @@ func rankSearchResults(query string, results []services.SearchResult) []services
|
||||
}
|
||||
negativeTerms := []string{
|
||||
"shocking", "amazing", "crazy", "must watch", "reaction", "gossip", "celebrity",
|
||||
"thumbnail", "meme", "prank", "drama", "breaking", "viral",
|
||||
"thumbnail", "meme", "prank", "drama", "breaking", "viral", "tutorial",
|
||||
"how to", "review", "walkthrough", "course", "lesson", "podcast", "interview",
|
||||
"premiere pro", "after effects", "explained", "breakdown", "vlog",
|
||||
}
|
||||
type scoredResult struct {
|
||||
item services.SearchResult
|
||||
@@ -379,11 +381,11 @@ func rankSearchResults(query string, results []services.SearchResult) []services
|
||||
}
|
||||
switch result.Source {
|
||||
case "Google Video":
|
||||
score += 3
|
||||
score += 2
|
||||
case "Envato":
|
||||
score += 4
|
||||
score += 5
|
||||
case "Artgrid":
|
||||
score += 4
|
||||
score += 5
|
||||
}
|
||||
scored = append(scored, scoredResult{item: result, score: score})
|
||||
}
|
||||
|
||||
+73
-47
@@ -52,30 +52,30 @@ func (s *SearchService) SearchMedia(queries []string) ([]SearchResult, error) {
|
||||
categories string
|
||||
engine string
|
||||
queryBuilder func(string) string
|
||||
match func(string) bool
|
||||
match func(SearchResult) bool
|
||||
}{
|
||||
{
|
||||
name: "Google Video",
|
||||
categories: "videos",
|
||||
engine: s.GoogleVideoEngine,
|
||||
queryBuilder: func(query string) string {
|
||||
return query
|
||||
return buildGoogleVideoQuery(query)
|
||||
},
|
||||
match: func(string) bool { return true },
|
||||
match: isUsefulGoogleVideoResult,
|
||||
},
|
||||
{
|
||||
name: "Envato",
|
||||
categories: "general",
|
||||
engine: s.WebEngine,
|
||||
queryBuilder: buildEnvatoQuery,
|
||||
match: isEnvatoURL,
|
||||
match: isRenderableEnvatoResult,
|
||||
},
|
||||
{
|
||||
name: "Artgrid",
|
||||
categories: "general",
|
||||
engine: s.WebEngine,
|
||||
queryBuilder: buildArtgridQuery,
|
||||
match: func(link string) bool { return strings.Contains(strings.ToLower(link), "artgrid.io") },
|
||||
match: isRenderableArtgridResult,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -88,10 +88,7 @@ func (s *SearchService) SearchMedia(queries []string) ([]SearchResult, error) {
|
||||
continue
|
||||
}
|
||||
for _, source := range sources {
|
||||
searchQuery := query
|
||||
if source.queryBuilder != nil {
|
||||
searchQuery = source.queryBuilder(query)
|
||||
}
|
||||
searchQuery := source.queryBuilder(query)
|
||||
|
||||
items, err := s.search(searchQuery, source.categories, source.engine, source.name)
|
||||
if err != nil {
|
||||
@@ -112,10 +109,7 @@ func (s *SearchService) SearchMedia(queries []string) ([]SearchResult, error) {
|
||||
if item.Link == "" || seen[item.Link] {
|
||||
continue
|
||||
}
|
||||
if source.match != nil && !source.match(item.Link) {
|
||||
continue
|
||||
}
|
||||
if !isRenderableLink(item.Link, item.Source) {
|
||||
if !source.match(item) {
|
||||
continue
|
||||
}
|
||||
seen[item.Link] = true
|
||||
@@ -138,7 +132,7 @@ func (s *SearchService) search(query, categories, engine, source string) ([]Sear
|
||||
values.Set("q", query)
|
||||
values.Set("format", "json")
|
||||
values.Set("safesearch", "0")
|
||||
values.Set("language", "ko-KR")
|
||||
values.Set("language", "en-US")
|
||||
if categories != "" {
|
||||
values.Set("categories", categories)
|
||||
}
|
||||
@@ -189,6 +183,71 @@ func (s *SearchService) search(query, categories, engine, source string) ([]Sear
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func buildGoogleVideoQuery(query string) string {
|
||||
return fmt.Sprintf(`"%s" ("stock footage" OR "b-roll" OR cinematic OR "establishing shot" OR editorial) -tutorial -"how to" -review -reaction -course -podcast -vlog -interview -breakdown -edit -editing`, query)
|
||||
}
|
||||
|
||||
func buildEnvatoQuery(query string) string {
|
||||
return fmt.Sprintf(`"%s" ("stock footage" OR "stock video" OR "motion graphics" OR cinematic OR "b-roll") (site:elements.envato.com OR site:videohive.net/item) -site:elements.envato.com/stock-video -site:elements.envato.com/video-templates -site:elements.envato.com/stock-video/stock-footage`, query)
|
||||
}
|
||||
|
||||
func buildArtgridQuery(query string) string {
|
||||
return fmt.Sprintf(`"%s" ("stock footage" OR "b-roll" OR cinematic OR editorial) site:artgrid.io/clip/`, query)
|
||||
}
|
||||
|
||||
func isUsefulGoogleVideoResult(result SearchResult) bool {
|
||||
text := strings.ToLower(result.Title + " " + result.Snippet)
|
||||
for _, banned := range []string{
|
||||
"tutorial", "how to", "review", "reaction", "podcast", "interview", "walkthrough",
|
||||
"course", "lesson", "edit tutorial", "editing tutorial", "premiere pro", "after effects",
|
||||
"breakdown", "explained", "vlog",
|
||||
} {
|
||||
if strings.Contains(text, banned) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, desired := range []string{
|
||||
"b-roll", "stock footage", "cinematic", "footage", "establishing shot", "4k",
|
||||
} {
|
||||
if strings.Contains(text, desired) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
lowerLink := strings.ToLower(result.Link)
|
||||
return strings.Contains(lowerLink, "youtube.com/watch") || strings.Contains(lowerLink, "youtu.be/")
|
||||
}
|
||||
|
||||
func isRenderableEnvatoResult(result SearchResult) bool {
|
||||
parsed, err := url.Parse(result.Link)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
host := strings.ToLower(parsed.Host)
|
||||
path := strings.Trim(parsed.Path, "/")
|
||||
if strings.Contains(host, "videohive.net") {
|
||||
return strings.HasPrefix(path, "item/")
|
||||
}
|
||||
if strings.Contains(host, "elements.envato.com") {
|
||||
if path == "" || strings.Contains(path, "/") {
|
||||
return false
|
||||
}
|
||||
return regexp.MustCompile(`-[A-Z0-9]{6,}$`).MatchString(path)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isRenderableArtgridResult(result SearchResult) bool {
|
||||
parsed, err := url.Parse(result.Link)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(parsed.Host), "artgrid.io") {
|
||||
return false
|
||||
}
|
||||
path := strings.Trim(parsed.Path, "/")
|
||||
return regexp.MustCompile(`^clip/[0-9]+/`).MatchString(path)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
@@ -225,39 +284,6 @@ func inferDisplayLink(link string, parsed []any) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func isEnvatoURL(link string) bool {
|
||||
lower := strings.ToLower(link)
|
||||
return strings.Contains(lower, "envato") || strings.Contains(lower, "videohive.net")
|
||||
}
|
||||
|
||||
func isRenderableLink(link, source string) bool {
|
||||
parsed, err := url.Parse(link)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
path := strings.Trim(parsed.Path, "/")
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(link)
|
||||
switch source {
|
||||
case "Envato":
|
||||
return strings.Contains(lower, "/item/") || strings.Contains(lower, "/stock-video/") || strings.Contains(lower, "/video-templates/")
|
||||
case "Artgrid":
|
||||
return strings.Contains(lower, "artgrid.io") && len(strings.Split(path, "/")) >= 2
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func buildEnvatoQuery(query string) string {
|
||||
return fmt.Sprintf(`%s ("stock video" OR footage OR "video template" OR cinematic) (site:elements.envato.com/stock-video OR site:elements.envato.com/video-templates OR site:videohive.net/item)`, query)
|
||||
}
|
||||
|
||||
func buildArtgridQuery(query string) string {
|
||||
return fmt.Sprintf(`%s ("stock footage" OR "b-roll" OR cinematic OR editorial) (site:artgrid.io)`, query)
|
||||
}
|
||||
|
||||
func deriveThumbnail(link string) string {
|
||||
if videoID := extractYouTubeID(link); videoID != "" {
|
||||
return "https://i.ytimg.com/vi/" + videoID + "/hqdefault.jpg"
|
||||
|
||||
+100
-14
@@ -39,9 +39,11 @@ func NewGeminiService(apiKey string) *GeminiService {
|
||||
|
||||
func (g *GeminiService) ExpandQuery(query string) ([]string, error) {
|
||||
if g.APIKey == "" {
|
||||
return fallbackQueryExpansion(query), nil
|
||||
return fallbackQueryExpansion(query, query), nil
|
||||
}
|
||||
|
||||
englishBase := g.TranslateQuery(query)
|
||||
|
||||
body := map[string]any{
|
||||
"systemInstruction": map[string]any{
|
||||
"parts": []map[string]string{
|
||||
@@ -55,12 +57,13 @@ func (g *GeminiService) ExpandQuery(query string) ([]string, error) {
|
||||
"parts": []map[string]string{
|
||||
{
|
||||
"text": `Return JSON only in this shape: {"querywords":["..."]}.
|
||||
Generate at most 10 concise search variations for media discovery across Google Video, Envato, and Artgrid.
|
||||
If the user query is in Korean, include strong English search variants that a stock footage editor would use.
|
||||
Generate at most 10 concise English search variations for media discovery across Google Video, Envato, and Artgrid.
|
||||
The queries must be usable directly in English search engines for stock footage discovery.
|
||||
Prioritize media, video footage, stock footage, cinematic b-roll, editorial footage, and scene-based search terms.
|
||||
Avoid celebrity gossip, reaction-style phrasing, clickbait phrasing, and generic web search wording.
|
||||
Mix Korean and English when useful, but make sure several queries are clean English production keywords.
|
||||
User query: ` + query,
|
||||
Do not output Korean unless it is part of a proper noun.
|
||||
Original user query: ` + query + `
|
||||
English base translation: ` + englishBase,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -86,7 +89,7 @@ User query: ` + query,
|
||||
|
||||
rawText, err := g.generateText(body)
|
||||
if err != nil {
|
||||
return fallbackQueryExpansion(query), nil
|
||||
return fallbackQueryExpansion(query, englishBase), nil
|
||||
}
|
||||
|
||||
jsonText, err := extractJSONObject(rawText)
|
||||
@@ -108,8 +111,9 @@ 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,
|
||||
Every query must be in natural English and suitable for stock-footage search.
|
||||
Original user query: ` + query + `
|
||||
English base translation: ` + englishBase,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -134,20 +138,20 @@ User query: ` + query,
|
||||
}
|
||||
rawText, retryErr := g.generateText(strictBody)
|
||||
if retryErr != nil {
|
||||
return fallbackQueryExpansion(query), nil
|
||||
return fallbackQueryExpansion(query, englishBase), nil
|
||||
}
|
||||
jsonText, err = extractJSONObject(rawText)
|
||||
if err != nil {
|
||||
return fallbackQueryExpansion(query), nil
|
||||
return fallbackQueryExpansion(query, englishBase), nil
|
||||
}
|
||||
}
|
||||
|
||||
var parsed QueryExpansion
|
||||
if err := json.Unmarshal([]byte(jsonText), &parsed); err != nil {
|
||||
return fallbackQueryExpansion(query), nil
|
||||
return fallbackQueryExpansion(query, englishBase), nil
|
||||
}
|
||||
|
||||
queries := fallbackQueryExpansion(query)
|
||||
queries := fallbackQueryExpansion(query, englishBase)
|
||||
seen := map[string]bool{}
|
||||
for _, existing := range queries {
|
||||
seen[strings.ToLower(strings.TrimSpace(existing))] = true
|
||||
@@ -167,6 +171,47 @@ User query: ` + query,
|
||||
return queries, nil
|
||||
}
|
||||
|
||||
func (g *GeminiService) TranslateQuery(query string) string {
|
||||
if strings.TrimSpace(query) == "" || looksMostlyASCII(query) || g.APIKey == "" {
|
||||
return strings.TrimSpace(query)
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"systemInstruction": map[string]any{
|
||||
"parts": []map[string]string{
|
||||
{
|
||||
"text": "You translate media search intents into natural English. Output one plain English search phrase only. No labels, no quotes, no explanations.",
|
||||
},
|
||||
},
|
||||
},
|
||||
"contents": []map[string]any{
|
||||
{
|
||||
"parts": []map[string]string{
|
||||
{
|
||||
"text": "Translate this user query into concise English suitable for stock-footage search: " + query,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"generationConfig": map[string]any{
|
||||
"responseMimeType": "text/plain",
|
||||
"temperature": 0.1,
|
||||
"maxOutputTokens": 40,
|
||||
},
|
||||
}
|
||||
|
||||
rawText, err := g.generateText(body)
|
||||
if err != nil {
|
||||
return strings.TrimSpace(query)
|
||||
}
|
||||
|
||||
translated := sanitizePlainEnglishLine(rawText)
|
||||
if translated == "" {
|
||||
return strings.TrimSpace(query)
|
||||
}
|
||||
return translated
|
||||
}
|
||||
|
||||
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
|
||||
@@ -402,8 +447,11 @@ func truncateForError(text string, limit int) string {
|
||||
return trimmed[:limit] + "..."
|
||||
}
|
||||
|
||||
func fallbackQueryExpansion(query string) []string {
|
||||
base := strings.TrimSpace(query)
|
||||
func fallbackQueryExpansion(originalQuery, englishQuery string) []string {
|
||||
base := strings.TrimSpace(englishQuery)
|
||||
if base == "" {
|
||||
base = strings.TrimSpace(originalQuery)
|
||||
}
|
||||
candidates := []string{
|
||||
base,
|
||||
base + " b-roll",
|
||||
@@ -416,6 +464,9 @@ func fallbackQueryExpansion(query string) []string {
|
||||
base + " 4k footage",
|
||||
base + " cinematic b-roll",
|
||||
}
|
||||
if strings.TrimSpace(originalQuery) != "" && !strings.EqualFold(strings.TrimSpace(originalQuery), strings.TrimSpace(englishQuery)) {
|
||||
candidates = append(candidates, strings.TrimSpace(originalQuery))
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
queries := make([]string, 0, len(candidates))
|
||||
@@ -433,3 +484,38 @@ func fallbackQueryExpansion(query string) []string {
|
||||
}
|
||||
return queries
|
||||
}
|
||||
|
||||
func sanitizePlainEnglishLine(text string) string {
|
||||
lines := strings.Split(text, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(strings.Trim(line, "\"'`"))
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(line)
|
||||
for _, prefix := range []string{"translation:", "english:", "translated query:"} {
|
||||
if strings.HasPrefix(lower, prefix) {
|
||||
line = strings.TrimSpace(line[len(prefix):])
|
||||
lower = strings.ToLower(line)
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(lower, "here is") || strings.HasPrefix(lower, "the translation") {
|
||||
continue
|
||||
}
|
||||
if line != "" {
|
||||
return line
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func looksMostlyASCII(text string) bool {
|
||||
ascii := 0
|
||||
runes := []rune(text)
|
||||
for _, r := range runes {
|
||||
if r <= 127 {
|
||||
ascii++
|
||||
}
|
||||
}
|
||||
return ascii >= len(runes)*8/10
|
||||
}
|
||||
|
||||
+7
-5
@@ -70,7 +70,7 @@
|
||||
</main>
|
||||
|
||||
<div id="previewModal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/80 px-4">
|
||||
<div class="w-full max-w-4xl rounded-3xl border border-white/10 bg-zinc-950 p-5 shadow-2xl">
|
||||
<div class="w-full max-w-5xl rounded-3xl border border-white/10 bg-zinc-950 p-5 shadow-2xl">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.3em] text-zinc-500">Download Preview</p>
|
||||
@@ -78,12 +78,12 @@
|
||||
</div>
|
||||
<button id="closePreviewModal" class="rounded-full border border-white/10 px-3 py-2 text-xs uppercase tracking-[0.2em] text-zinc-300">Close</button>
|
||||
</div>
|
||||
<div class="mt-5 grid gap-5 xl:grid-cols-[minmax(0,1.15fr)_360px]">
|
||||
<div class="mt-5 flex flex-col gap-5">
|
||||
<div id="previewMediaFrame" class="min-w-0 flex min-h-[320px] items-center justify-center overflow-hidden rounded-3xl border border-white/10 bg-black/30 p-2">
|
||||
<video id="previewVideo" class="hidden max-h-[60vh] w-full bg-black object-contain" controls playsinline></video>
|
||||
<img id="previewThumbnail" class="max-h-[60vh] w-full object-contain" alt="" />
|
||||
</div>
|
||||
<div class="min-w-0 space-y-4">
|
||||
<div class="min-w-0 grid gap-4 lg:grid-cols-2">
|
||||
<div class="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
|
||||
<div class="flex items-center justify-between text-sm text-zinc-400">
|
||||
<span>Detected duration</span>
|
||||
@@ -116,7 +116,9 @@
|
||||
<span class="text-sm text-zinc-400">Quality</span>
|
||||
<select id="qualitySelect" class="w-full rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white"></select>
|
||||
</label>
|
||||
<button id="confirmDownload" class="w-full rounded-2xl border border-white bg-white px-5 py-3 text-sm font-medium text-black transition hover:bg-zinc-200">Confirm Download</button>
|
||||
<div class="lg:col-span-2">
|
||||
<button id="confirmDownload" class="w-full rounded-2xl border border-white bg-white px-5 py-3 text-sm font-medium text-black transition hover:bg-zinc-200">Confirm Download</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -136,6 +138,6 @@
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script src="/app.js?v=20260313c" defer></script>
|
||||
<script src="/app.js?v=20260313d" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user